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,131 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { ChevronLeft, MapPin, CalendarDays, Cake, Package, Scale, PawPrint } from "lucide-react";
import { formatDate } from "@/lib/utils";
import { ageString } from "@/lib/animals/age";
import { ANIMAL_LOG_LABELS, ANIMAL_LOG_COLORS } from "@/lib/animals/animal-fields";
import { EditAnimalButton, type AnimalFields } from "@/components/animals/animal-dialog";
import { DeleteAnimalButton } from "@/components/animals/delete-animal-button";
import { AddAnimalLogButton } from "@/components/animals/add-animal-log-button";
function toDateInput(d: Date | null): string {
return d ? new Date(d).toISOString().split("T")[0] : "";
}
export async function generateMetadata({ params }: { params: { id: string } }) {
const a = await db.animal.findUnique({ where: { id: params.id }, select: { name: true } });
return { title: a?.name ?? "Animal" };
}
export default async function AnimalDetailPage({ params }: { params: { id: string } }) {
const [animal, locations] = await Promise.all([
db.animal.findUnique({
where: { id: params.id },
include: { location: { select: { id: true, name: true } }, logs: { orderBy: { date: "desc" } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
if (!animal || !animal.active) notFound();
const age = ageString(animal.birthdate, new Date());
const latestWeight = animal.logs.find((l) => l.type === "WEIGHT" && l.weight != null);
const editFields: AnimalFields = {
id: animal.id,
name: animal.name,
species: animal.species ?? "",
breed: animal.breed ?? "",
sex: animal.sex ?? "",
locationId: animal.locationId ?? "",
birthdate: toDateInput(animal.birthdate),
acquiredAt: toDateInput(animal.acquiredAt),
source: animal.source ?? "",
notes: animal.notes ?? "",
};
const facts: { icon: React.ReactNode; label: string; value: React.ReactNode }[] = [];
if (animal.location) facts.push({ icon: <MapPin className="h-4 w-4" />, label: "Location", value: animal.location.name });
if (animal.sex) facts.push({ icon: <PawPrint className="h-4 w-4" />, label: "Sex", value: animal.sex });
if (animal.birthdate) facts.push({ icon: <Cake className="h-4 w-4" />, label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
if (animal.acquiredAt) facts.push({ icon: <CalendarDays className="h-4 w-4" />, label: "Acquired", value: formatDate(animal.acquiredAt) });
if (animal.source) facts.push({ icon: <Package className="h-4 w-4" />, label: "From", value: animal.source });
if (latestWeight?.weight != null) facts.push({ icon: <Scale className="h-4 w-4" />, label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
return (
<div className="space-y-6 max-w-2xl">
<div className="flex items-center gap-3">
<Link href="/animals" className="text-muted-foreground hover:text-foreground transition-colors">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="min-w-0">
<h1 className="font-display text-2xl font-semibold">{animal.name}</h1>
<p className="text-sm text-muted-foreground">{[animal.species, animal.breed].filter(Boolean).join(" · ")}</p>
</div>
{age && <Badge variant="secondary" className="ml-auto shrink-0">{age}</Badge>}
</div>
<div className="flex flex-wrap items-center gap-2">
<EditAnimalButton animal={editFields} locations={locations} />
<AddAnimalLogButton animalId={animal.id} />
<DeleteAnimalButton id={animal.id} name={animal.name} />
</div>
{animal.imageUrl && (
<div className="rounded-lg overflow-hidden h-56 bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={animal.imageUrl} alt={animal.name} className="w-full h-full object-cover" />
</div>
)}
{facts.length > 0 && (
<Card>
<CardContent className="pt-4 grid grid-cols-2 gap-3 text-sm">
{facts.map((f, i) => (
<div key={i} className="flex items-start gap-2">
<span className="text-muted-foreground mt-0.5 shrink-0">{f.icon}</span>
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{f.label}</p>
<p className="truncate">{f.value}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
{animal.notes && (
<Card><CardContent className="pt-4 text-sm whitespace-pre-wrap">{animal.notes}</CardContent></Card>
)}
<div>
<h2 className="font-display text-lg font-semibold mb-3">Care log</h2>
{animal.logs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm border rounded-lg">
No entries yet log a feeding, vet visit, or weigh-in above.
</div>
) : (
<div className="space-y-2">
{animal.logs.map((log) => (
<div key={log.id} className="flex items-start gap-3 p-3 rounded-lg border bg-card">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-semibold uppercase tracking-wide ${ANIMAL_LOG_COLORS[log.type]}`}>
{ANIMAL_LOG_LABELS[log.type]}
</span>
{log.weight != null && <span className="text-xs text-muted-foreground">· {Number(log.weight)} lbs</span>}
<span className="text-xs text-muted-foreground ml-auto">{formatDate(log.date)}</span>
</div>
{log.notes && <p className="text-sm mt-1">{log.notes}</p>}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import Link from "next/link";
import { db } from "@/lib/db";
import { Badge } from "@/components/ui/badge";
import { PawPrint, MapPin, ChevronRight } from "lucide-react";
import { ageString } from "@/lib/animals/age";
import { AddAnimalButton } from "@/components/animals/animal-dialog";
export const metadata = { title: "Animals" };
const now = new Date();
export default async function AnimalsPage() {
const [animals, locations] = await Promise.all([
db.animal.findMany({
where: { active: true },
orderBy: { name: "asc" },
include: { location: { select: { id: true, name: true } }, _count: { select: { logs: true } } },
}),
db.location.findMany({ where: { active: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
]);
// Group by species ("Other" bucket last).
const groups = new Map<string, typeof animals>();
for (const a of animals) {
const key = a.species?.trim() || "Other";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(a);
}
const sorted = Array.from(groups.entries()).sort((a, b) =>
a[0] === "Other" ? 1 : b[0] === "Other" ? -1 : a[0].localeCompare(b[0]),
);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="font-display text-2xl font-semibold">Animals &amp; pets</h1>
<p className="text-sm text-muted-foreground mt-0.5">
{animals.length} {animals.length === 1 ? "animal" : "animals"}
</p>
</div>
<AddAnimalButton locations={locations} />
</div>
{animals.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<PawPrint className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p className="font-medium">No animals yet</p>
<p className="text-sm mt-1">Add a pet or some livestock to start a care log.</p>
</div>
) : (
<div className="space-y-6">
{sorted.map(([species, list]) => (
<div key={species} className="space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{species}</p>
<div className="divide-y border rounded-lg overflow-hidden">
{list.map((a) => {
const age = ageString(a.birthdate, now);
return (
<Link key={a.id} href={`/animals/${a.id}`}
className="flex items-center gap-3 px-4 py-3 hover:bg-accent transition-colors">
{a.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.imageUrl} alt="" className="h-9 w-9 rounded-full object-cover shrink-0" />
) : (
<span className="h-9 w-9 rounded-full bg-[hsl(var(--leaf)/.1)] flex items-center justify-center shrink-0">
<PawPrint className="h-4 w-4 text-[hsl(var(--leaf)/.6)]" />
</span>
)}
<div className="min-w-0 flex-1">
<p className="font-medium">{a.name}</p>
<p className="text-xs text-muted-foreground">
{[a.breed, age].filter(Boolean).join(" · ") || a.species}
</p>
</div>
{a.location?.name && (
<Badge variant="outline" className="gap-1 text-xs">
<MapPin className="h-2.5 w-2.5" />{a.location.name}
</Badge>
)}
<ChevronRight className="h-4 w-4 text-muted-foreground shrink-0" />
</Link>
);
})}
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -11,7 +11,7 @@ export async function GET() {
}
const [locations, plantTypes, users, apiTokens, plantGroups, plants, plantLogs, tasks, taskCompletions,
labels, items, labelOnItems, itemAttachments, itemLogs, auditEvents] =
labels, items, labelOnItems, itemAttachments, itemLogs, animals, animalLogs, auditEvents] =
await Promise.all([
db.location.findMany(),
db.plantType.findMany(),
@@ -27,13 +27,15 @@ export async function GET() {
db.labelOnItem.findMany(),
db.itemAttachment.findMany(),
db.itemLog.findMany(),
db.animal.findMany(),
db.animalLog.findMany(),
db.auditEvent.findMany({ orderBy: { createdAt: "asc" } }),
]);
const manifest = {
version: 1,
exportedAt: new Date().toISOString(),
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "auditEvents"],
tables: ["locations", "plantTypes", "users", "apiTokens", "plantGroups", "plants", "plantLogs", "tasks", "taskCompletions", "labels", "items", "labelOnItems", "itemAttachments", "itemLogs", "animals", "animalLogs", "auditEvents"],
};
const zip = zipSync({
@@ -52,6 +54,8 @@ export async function GET() {
"labelOnItems.json": strToU8(JSON.stringify(labelOnItems, null, 2)),
"itemAttachments.json": strToU8(JSON.stringify(itemAttachments, null, 2)),
"itemLogs.json": strToU8(JSON.stringify(itemLogs, null, 2)),
"animals.json": strToU8(JSON.stringify(animals, null, 2)),
"animalLogs.json": strToU8(JSON.stringify(animalLogs, null, 2)),
"auditEvents.json": strToU8(JSON.stringify(auditEvents, null, 2)),
});

View File

@@ -85,6 +85,8 @@ export async function POST(req: Request) {
const labelOnItems = files["labelOnItems.json"] ? asArray("labelOnItems.json") : [];
const itemAttachments = files["itemAttachments.json"] ? asArray("itemAttachments.json") : [];
const itemLogs = files["itemLogs.json"] ? asArray("itemLogs.json") : [];
const animals = files["animals.json"] ? asArray("animals.json") : [];
const animalLogs = files["animalLogs.json"] ? asArray("animalLogs.json") : [];
const auditEvents = asArray("auditEvents.json");
// Restore inside a transaction — wipe then reload in FK order.
@@ -94,9 +96,11 @@ export async function POST(req: Request) {
await tx.itemLog.deleteMany();
await tx.itemAttachment.deleteMany();
await tx.labelOnItem.deleteMany();
await tx.animalLog.deleteMany();
await tx.task.deleteMany();
await tx.item.deleteMany();
await tx.label.deleteMany();
await tx.animal.deleteMany();
await tx.plant.deleteMany();
await tx.plantGroup.deleteMany();
await tx.plantType.deleteMany();
@@ -121,6 +125,8 @@ export async function POST(req: Request) {
if (labelOnItems.length) await tx.labelOnItem.createMany({ data: labelOnItems as never });
if (itemAttachments.length) await tx.itemAttachment.createMany({ data: itemAttachments as never });
if (itemLogs.length) await tx.itemLog.createMany({ data: itemLogs as never });
if (animals.length) await tx.animal.createMany({ data: animals as never });
if (animalLogs.length) await tx.animalLog.createMany({ data: animalLogs as never });
if (tasks.length) await tx.task.createMany({ data: tasks as never });
if (taskCompletions.length) await tx.taskCompletion.createMany({ data: taskCompletions as never });
if (auditEvents.length) await tx.auditEvent.createMany({ data: auditEvents as never });

View File

@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalLogInput } from "@/lib/api/schemas";
import { addAnimalLog } from "@/lib/services/animals";
// POST /api/v1/animals/:id/logs
export async function POST(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalLogInput.parse(await req.json());
return NextResponse.json(await addAnimalLog(ctx, params.id, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,33 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalUpdateInput } from "@/lib/api/schemas";
import { getAnimal, updateAnimal, deleteAnimal } from "@/lib/services/animals";
export async function GET(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:read");
return NextResponse.json(await getAnimal(ctx, params.id));
} catch (err) {
return handleApiError(err);
}
}
export async function PATCH(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalUpdateInput.parse(await req.json());
return NextResponse.json(await updateAnimal(ctx, params.id, input));
} catch (err) {
return handleApiError(err);
}
}
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
try {
const ctx = await authenticateRequest(req, "animals:write");
await deleteAnimal(ctx, params.id);
return NextResponse.json({ ok: true });
} catch (err) {
return handleApiError(err);
}
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { authenticateRequest, handleApiError } from "@/lib/api/authenticate";
import { animalCreateInput } from "@/lib/api/schemas";
import { listAnimals, createAnimal } from "@/lib/services/animals";
// GET /api/v1/animals?q=&species=
export async function GET(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:read");
const sp = new URL(req.url).searchParams;
return NextResponse.json(
await listAnimals(ctx, { q: sp.get("q") ?? undefined, species: sp.get("species") ?? undefined }),
);
} catch (err) {
return handleApiError(err);
}
}
// POST /api/v1/animals
export async function POST(req: Request) {
try {
const ctx = await authenticateRequest(req, "animals:write");
const input = animalCreateInput.parse(await req.json());
return NextResponse.json(await createAnimal(ctx, input), { status: 201 });
} catch (err) {
return handleApiError(err);
}
}