v0.14.0 — Animals & Pets

Livestock and household pets as leaves on the Location tree, mirroring Garden.

- Animal model (species/breed/sex/birthdate/location, soft-delete) + AnimalLog
  (feeding, vet, weight, medication, breeding, acquired, died); Task.animalId for
  recurring care reminders
- API-first /api/v1/animals (CRUD) + logs, animals:read/write scopes; animals
  service shared with the coming MCP
- Animals pages: list grouped by species with auto-computed age, detail with
  facts + latest weight + care log; add/edit/remove + log entry. Nav entry.
  Backup/restore cover the new tables
- Pure age.ts (newborn/days/months/years) with tests; verified end-to-end against
  a running server (create, vet + weigh-in logs, read-back)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-16 00:23:53 -05:00
parent 777569a21a
commit 39be24c2fc
21 changed files with 979 additions and 5 deletions

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from "vitest";
import { ageString } from "./age";
const now = new Date("2026-06-16T00:00:00Z");
describe("ageString", () => {
it("returns null without a birthdate", () => {
expect(ageString(null, now)).toBeNull();
expect(ageString(undefined, now)).toBeNull();
});
it("returns null for a future birthdate", () => {
expect(ageString(new Date("2026-07-01"), now)).toBeNull();
});
it("counts days under a month", () => {
expect(ageString(new Date("2026-06-11T00:00:00Z"), now)).toBe("5 days");
expect(ageString(new Date("2026-06-15T00:00:00Z"), now)).toBe("1 day");
});
it("counts months under two years", () => {
expect(ageString(new Date("2026-03-16T00:00:00Z"), now)).toBe("3 months");
expect(ageString(new Date("2025-06-16T00:00:00Z"), now)).toBe("12 months");
});
it("counts whole years from two years on", () => {
expect(ageString(new Date("2024-06-16T00:00:00Z"), now)).toBe("2 years");
expect(ageString(new Date("2019-01-16T00:00:00Z"), now)).toBe("7 years");
});
it("says newborn on the birth day", () => {
expect(ageString(new Date("2026-06-16T00:00:00Z"), now)).toBe("newborn");
});
});

22
src/lib/animals/age.ts Normal file
View File

@@ -0,0 +1,22 @@
// Pure: a friendly age string from a birthdate. Days under a month, months
// under two years, then whole years.
export function ageString(
birthdate: Date | string | null | undefined,
now: Date,
): string | null {
if (!birthdate) return null;
const b = new Date(birthdate);
if (Number.isNaN(b.getTime()) || b > now) return null;
let months = (now.getFullYear() - b.getFullYear()) * 12 + (now.getMonth() - b.getMonth());
if (now.getDate() < b.getDate()) months--;
if (months < 1) {
const days = Math.floor((now.getTime() - b.getTime()) / 86_400_000);
return days <= 0 ? "newborn" : `${days} day${days === 1 ? "" : "s"}`;
}
if (months < 24) return `${months} month${months === 1 ? "" : "s"}`;
const years = Math.floor(months / 12);
return `${years} years`;
}

View File

@@ -0,0 +1,31 @@
import { AnimalLogType } from "@prisma/client";
export const ANIMAL_LOG_LABELS: Record<AnimalLogType, string> = {
NOTE: "Note",
FEEDING: "Feeding",
VET: "Vet visit",
WEIGHT: "Weight",
MEDICATION: "Medication",
BREEDING: "Breeding",
ACQUIRED: "Acquired",
DIED: "Died / removed",
};
export const ANIMAL_LOG_COLORS: Record<AnimalLogType, string> = {
NOTE: "text-slate-600 dark:text-slate-400",
FEEDING: "text-green-600 dark:text-green-400",
VET: "text-blue-600 dark:text-blue-400",
WEIGHT: "text-violet-600 dark:text-violet-400",
MEDICATION: "text-amber-600 dark:text-amber-400",
BREEDING: "text-pink-600 dark:text-pink-400",
ACQUIRED: "text-teal-600 dark:text-teal-400",
DIED: "text-red-600 dark:text-red-400",
};
export const ANIMAL_LOG_TYPES: AnimalLogType[] = [
"NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED",
];
export const COMMON_SPECIES = [
"Dog", "Cat", "Chicken", "Duck", "Goat", "Sheep", "Pig", "Rabbit", "Horse", "Cow", "Bee", "Other",
];

View File

@@ -59,3 +59,31 @@ export type TaskCompleteInput = z.infer<typeof taskCompleteInput>;
export type ItemCreateInput = z.infer<typeof itemCreateInput>;
export type ItemUpdateInput = z.infer<typeof itemUpdateInput>;
export type ItemLogInput = z.infer<typeof itemLogInput>;
// --- Animals & pets ---
export const animalCreateInput = z.object({
name: z.string().min(1),
species: z.string().optional(),
breed: z.string().optional(),
sex: z.string().optional(),
locationId: z.string().nullable().optional(),
birthdate: z.string().nullable().optional(),
acquiredAt: z.string().nullable().optional(),
source: z.string().optional(),
notes: z.string().optional(),
imageUrl: z.string().nullable().optional(),
});
export const animalUpdateInput = animalCreateInput.partial();
export const animalLogInput = z.object({
type: z.enum(["NOTE", "FEEDING", "VET", "WEIGHT", "MEDICATION", "BREEDING", "ACQUIRED", "DIED"]),
notes: z.string().optional(),
weight: z.coerce.number().nonnegative().nullable().optional(),
date: z.string().optional(),
});
export type AnimalCreateInput = z.infer<typeof animalCreateInput>;
export type AnimalUpdateInput = z.infer<typeof animalUpdateInput>;
export type AnimalLogInput = z.infer<typeof animalLogInput>;

View File

@@ -7,6 +7,7 @@ export const KNOWN_SCOPES = [
"locations:read", "locations:write",
"tasks:read", "tasks:write",
"items:read", "items:write",
"animals:read", "animals:write",
] as const;
export type KnownScope = (typeof KNOWN_SCOPES)[number];
@@ -18,5 +19,6 @@ export const SCOPE_RESOURCES: { key: string; label: string; soon?: boolean }[] =
{ key: "plant-types", label: "Plant types" },
{ key: "locations", label: "Locations" },
{ key: "tasks", label: "Tasks & reminders" },
{ key: "items", label: "Inventory", soon: true },
{ key: "items", label: "Inventory" },
{ key: "animals", label: "Animals & pets" },
];

View File

@@ -8,6 +8,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.14.0",
date: "2026-06-16",
changes: [
"New Animals & Pets section — keep a record for each pet and animal on the homestead: species, breed, birthday (with age shown automatically), and where they live. Log feedings, vet visits, weigh-ins, and medications, and set recurring reminders like vaccinations. Works for the dog and cat as well as chickens or goats.",
],
},
{
version: "0.13.0",
date: "2026-06-16",

112
src/lib/services/animals.ts Normal file
View File

@@ -0,0 +1,112 @@
// Animal service — DB work + AuditEvent for animals & pets. Shared by /api/v1
// routes (which the UI calls) and, later, the MCP server.
import { db } from "@/lib/db";
import { isAdmin } from "@/lib/auth";
import { ApiError, type AuthContext } from "@/lib/api/authenticate";
import type { AnimalCreateInput, AnimalUpdateInput, AnimalLogInput } from "@/lib/api/schemas";
function toDate(s?: string | null): Date | null {
return s ? new Date(s) : null;
}
export function listAnimals(_ctx: AuthContext, opts?: { q?: string; species?: string }) {
return db.animal.findMany({
where: {
active: true,
...(opts?.q ? { name: { contains: opts.q, mode: "insensitive" } } : {}),
...(opts?.species ? { species: opts.species } : {}),
},
orderBy: { name: "asc" },
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
}
export async function getAnimal(_ctx: AuthContext, id: string) {
const animal = await db.animal.findUnique({
where: { id },
include: {
location: { select: { id: true, name: true } },
logs: { orderBy: { date: "desc" }, take: 100 },
},
});
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
return animal;
}
export async function createAnimal(ctx: AuthContext, input: AnimalCreateInput) {
const animal = await db.animal.create({
data: {
name: input.name.trim(),
species: input.species?.trim() || null,
breed: input.breed?.trim() || null,
sex: input.sex?.trim() || null,
locationId: input.locationId || null,
birthdate: toDate(input.birthdate),
acquiredAt: toDate(input.acquiredAt),
source: input.source?.trim() || null,
notes: input.notes?.trim() || null,
imageUrl: input.imageUrl || null,
},
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
await db.auditEvent.create({
data: { action: "animal.create", entityId: animal.id, actorId: ctx.userId, payload: { name: animal.name, species: animal.species, via: ctx.via } },
});
return animal;
}
export async function updateAnimal(ctx: AuthContext, id: string, input: AnimalUpdateInput) {
const existing = await db.animal.findUnique({ where: { id }, select: { active: true } });
if (!existing || !existing.active) throw new ApiError(404, "Animal not found");
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name.trim();
if (input.species !== undefined) data.species = input.species?.trim() || null;
if (input.breed !== undefined) data.breed = input.breed?.trim() || null;
if (input.sex !== undefined) data.sex = input.sex?.trim() || null;
if (input.locationId !== undefined) data.locationId = input.locationId || null;
if (input.birthdate !== undefined) data.birthdate = toDate(input.birthdate);
if (input.acquiredAt !== undefined) data.acquiredAt = toDate(input.acquiredAt);
if (input.source !== undefined) data.source = input.source?.trim() || null;
if (input.notes !== undefined) data.notes = input.notes?.trim() || null;
if (input.imageUrl !== undefined) data.imageUrl = input.imageUrl || null;
const animal = await db.animal.update({
where: { id },
data,
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
});
await db.auditEvent.create({
data: { action: "animal.update", entityId: id, actorId: ctx.userId, payload: { name: animal.name, via: ctx.via } },
});
return animal;
}
export async function deleteAnimal(ctx: AuthContext, id: string) {
if (!isAdmin(ctx.role)) throw new ApiError(403, "Only admins can remove animals");
const animal = await db.animal.findUnique({ where: { id }, select: { active: true, name: true } });
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
await db.animal.update({ where: { id }, data: { active: false } });
await db.auditEvent.create({
data: { action: "animal.delete", entityId: id, actorId: ctx.userId, payload: { name: animal.name, via: ctx.via } },
});
}
export async function addAnimalLog(ctx: AuthContext, animalId: string, input: AnimalLogInput) {
const animal = await db.animal.findUnique({ where: { id: animalId }, select: { active: true } });
if (!animal || !animal.active) throw new ApiError(404, "Animal not found");
const log = await db.animalLog.create({
data: {
animalId,
type: input.type,
date: input.date ? new Date(input.date) : new Date(),
notes: input.notes?.trim() || null,
weight: input.weight ?? null,
actorId: ctx.userId,
},
});
await db.auditEvent.create({
data: { action: "animal.log.add", entityId: animalId, actorId: ctx.userId, payload: { type: input.type, via: ctx.via } },
});
return log;
}

View File

@@ -1,2 +1,2 @@
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
export const APP_VERSION = "0.13.0";
export const APP_VERSION = "0.14.0";