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:
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal file
131
src/app/(dashboard)/animals/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/app/(dashboard)/animals/page.tsx
Normal file
92
src/app/(dashboard)/animals/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal file
15
src/app/api/v1/animals/[id]/logs/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
33
src/app/api/v1/animals/[id]/route.ts
Normal file
33
src/app/api/v1/animals/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
28
src/app/api/v1/animals/route.ts
Normal file
28
src/app/api/v1/animals/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user