Files
Moonbase/src/app/(dashboard)/animals/page.tsx
tonym 39be24c2fc 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>
2026-06-16 00:23:53 -05:00

93 lines
3.8 KiB
TypeScript

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>
);
}