diff --git a/prisma/migrations/20260616051637_v0_14_animals/migration.sql b/prisma/migrations/20260616051637_v0_14_animals/migration.sql
new file mode 100644
index 0000000..9d53ce5
--- /dev/null
+++ b/prisma/migrations/20260616051637_v0_14_animals/migration.sql
@@ -0,0 +1,66 @@
+-- CreateEnum
+CREATE TYPE "AnimalLogType" AS ENUM ('NOTE', 'FEEDING', 'VET', 'WEIGHT', 'MEDICATION', 'BREEDING', 'ACQUIRED', 'DIED');
+
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "animalId" TEXT;
+
+-- CreateTable
+CREATE TABLE "Animal" (
+ "id" TEXT NOT NULL,
+ "name" TEXT NOT NULL,
+ "species" TEXT,
+ "breed" TEXT,
+ "sex" TEXT,
+ "locationId" TEXT,
+ "birthdate" TIMESTAMP(3),
+ "acquiredAt" TIMESTAMP(3),
+ "source" TEXT,
+ "notes" TEXT,
+ "imageUrl" TEXT,
+ "active" BOOLEAN NOT NULL DEFAULT true,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Animal_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "AnimalLog" (
+ "id" TEXT NOT NULL,
+ "animalId" TEXT NOT NULL,
+ "type" "AnimalLogType" NOT NULL,
+ "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "notes" TEXT,
+ "weight" DECIMAL(10,2),
+ "actorId" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "AnimalLog_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Animal_locationId_idx" ON "Animal"("locationId");
+
+-- CreateIndex
+CREATE INDEX "Animal_species_idx" ON "Animal"("species");
+
+-- CreateIndex
+CREATE INDEX "Animal_active_idx" ON "Animal"("active");
+
+-- CreateIndex
+CREATE INDEX "AnimalLog_animalId_idx" ON "AnimalLog"("animalId");
+
+-- CreateIndex
+CREATE INDEX "AnimalLog_date_idx" ON "AnimalLog"("date");
+
+-- CreateIndex
+CREATE INDEX "Task_animalId_idx" ON "Task"("animalId");
+
+-- AddForeignKey
+ALTER TABLE "Task" ADD CONSTRAINT "Task_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Animal" ADD CONSTRAINT "Animal_locationId_fkey" FOREIGN KEY ("locationId") REFERENCES "Location"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "AnimalLog" ADD CONSTRAINT "AnimalLog_animalId_fkey" FOREIGN KEY ("animalId") REFERENCES "Animal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index be5eb86..cce618d 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -101,6 +101,7 @@ model Location {
updatedAt DateTime @updatedAt
plants Plant[]
items Item[]
+ animals Animal[]
@@index([parentId])
@@index([kind])
@@ -239,6 +240,8 @@ model Task {
plant Plant? @relation(fields: [plantId], references: [id])
itemId String?
item Item? @relation(fields: [itemId], references: [id], onDelete: SetNull)
+ animalId String?
+ animal Animal? @relation(fields: [animalId], references: [id], onDelete: SetNull)
recurrence RecurrenceType @default(YEARLY)
intervalDays Int? // for CUSTOM: every N days
month Int? // 1–12: for YEARLY, which month
@@ -252,6 +255,7 @@ model Task {
@@index([nextDue])
@@index([plantId])
@@index([itemId])
+ @@index([animalId])
@@index([active])
}
@@ -395,3 +399,58 @@ model ItemLog {
@@index([itemId])
@@index([date])
}
+
+// ---------------------------------------------------------------------------
+// Animals & pets — livestock and household pets, leaves in a Location with
+// their own care log (vet, feeding, weight, meds) and recurring reminders.
+// ---------------------------------------------------------------------------
+
+enum AnimalLogType {
+ NOTE
+ FEEDING
+ VET
+ WEIGHT
+ MEDICATION
+ BREEDING
+ ACQUIRED
+ DIED
+}
+
+model Animal {
+ id String @id @default(cuid())
+ name String
+ species String? // "Dog", "Cat", "Chicken", "Goat"
+ breed String?
+ sex String? // freeform: "Female", "Male", "Wether"
+ locationId String?
+ location Location? @relation(fields: [locationId], references: [id], onDelete: SetNull)
+ birthdate DateTime?
+ acquiredAt DateTime?
+ source String?
+ notes String?
+ imageUrl String?
+ active Boolean @default(true)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ logs AnimalLog[]
+ tasks Task[]
+
+ @@index([locationId])
+ @@index([species])
+ @@index([active])
+}
+
+model AnimalLog {
+ id String @id @default(cuid())
+ animalId String
+ animal Animal @relation(fields: [animalId], references: [id], onDelete: Cascade)
+ type AnimalLogType
+ date DateTime @default(now())
+ notes String?
+ weight Decimal? @db.Decimal(10, 2) // for WEIGHT entries
+ actorId String?
+ createdAt DateTime @default(now())
+
+ @@index([animalId])
+ @@index([date])
+}
diff --git a/src/app/(dashboard)/animals/[id]/page.tsx b/src/app/(dashboard)/animals/[id]/page.tsx
new file mode 100644
index 0000000..a7a985a
--- /dev/null
+++ b/src/app/(dashboard)/animals/[id]/page.tsx
@@ -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: , label: "Location", value: animal.location.name });
+ if (animal.sex) facts.push({ icon: , label: "Sex", value: animal.sex });
+ if (animal.birthdate) facts.push({ icon: , label: "Born", value: `${formatDate(animal.birthdate)}${age ? ` · ${age}` : ""}` });
+ if (animal.acquiredAt) facts.push({ icon: , label: "Acquired", value: formatDate(animal.acquiredAt) });
+ if (animal.source) facts.push({ icon: , label: "From", value: animal.source });
+ if (latestWeight?.weight != null) facts.push({ icon: , label: "Latest weight", value: `${Number(latestWeight.weight)} lbs` });
+
+ return (
+
+
+
+
+
+
+
{animal.name}
+
{[animal.species, animal.breed].filter(Boolean).join(" · ")}
+
+ {age &&
{age}}
+
+
+
+
+ {animal.imageUrl && (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ )}
+
+ {facts.length > 0 && (
+
+
+ {facts.map((f, i) => (
+
+
{f.icon}
+
+
{f.label}
+
{f.value}
+
+
+ ))}
+
+
+ )}
+
+ {animal.notes && (
+
{animal.notes}
+ )}
+
+
+
Care log
+ {animal.logs.length === 0 ? (
+
+ No entries yet — log a feeding, vet visit, or weigh-in above.
+
+ ) : (
+
+ {animal.logs.map((log) => (
+
+
+
+
+ {ANIMAL_LOG_LABELS[log.type]}
+
+ {log.weight != null && · {Number(log.weight)} lbs}
+ {formatDate(log.date)}
+
+ {log.notes &&
{log.notes}
}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(dashboard)/animals/page.tsx b/src/app/(dashboard)/animals/page.tsx
new file mode 100644
index 0000000..bf0d52f
--- /dev/null
+++ b/src/app/(dashboard)/animals/page.tsx
@@ -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();
+ 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 (
+
+
+
+
Animals & pets
+
+ {animals.length} {animals.length === 1 ? "animal" : "animals"}
+
+
+
+
+
+ {animals.length === 0 ? (
+
+
+
No animals yet
+
Add a pet or some livestock to start a care log.
+
+ ) : (
+
+ {sorted.map(([species, list]) => (
+
+
{species}
+
+ {list.map((a) => {
+ const age = ageString(a.birthdate, now);
+ return (
+
+ {a.imageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+
+
+ )}
+
+
{a.name}
+
+ {[a.breed, age].filter(Boolean).join(" · ") || a.species}
+
+
+ {a.location?.name && (
+
+ {a.location.name}
+
+ )}
+
+
+ );
+ })}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/backup/route.ts b/src/app/api/admin/backup/route.ts
index 0ddf5a0..62c221d 100644
--- a/src/app/api/admin/backup/route.ts
+++ b/src/app/api/admin/backup/route.ts
@@ -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)),
});
diff --git a/src/app/api/admin/restore/route.ts b/src/app/api/admin/restore/route.ts
index 89c9f63..43f5765 100644
--- a/src/app/api/admin/restore/route.ts
+++ b/src/app/api/admin/restore/route.ts
@@ -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 });
diff --git a/src/app/api/v1/animals/[id]/logs/route.ts b/src/app/api/v1/animals/[id]/logs/route.ts
new file mode 100644
index 0000000..de13483
--- /dev/null
+++ b/src/app/api/v1/animals/[id]/logs/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/v1/animals/[id]/route.ts b/src/app/api/v1/animals/[id]/route.ts
new file mode 100644
index 0000000..73830cd
--- /dev/null
+++ b/src/app/api/v1/animals/[id]/route.ts
@@ -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);
+ }
+}
diff --git a/src/app/api/v1/animals/route.ts b/src/app/api/v1/animals/route.ts
new file mode 100644
index 0000000..2b908bd
--- /dev/null
+++ b/src/app/api/v1/animals/route.ts
@@ -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);
+ }
+}
diff --git a/src/components/animals/add-animal-log-button.tsx b/src/components/animals/add-animal-log-button.tsx
new file mode 100644
index 0000000..3f7b2c0
--- /dev/null
+++ b/src/components/animals/add-animal-log-button.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+import { ClipboardList } from "lucide-react";
+import { ANIMAL_LOG_TYPES, ANIMAL_LOG_LABELS } from "@/lib/animals/animal-fields";
+
+export function AddAnimalLogButton({ animalId }: { animalId: string }) {
+ const [open, setOpen] = useState(false);
+ const [type, setType] = useState("NOTE");
+ const [notes, setNotes] = useState("");
+ const [weight, setWeight] = useState("");
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ setBusy(true);
+ const res = await fetch(`/api/v1/animals/${animalId}/logs`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ type, notes: notes.trim() || undefined, weight: weight ? Number(weight) : null }),
+ });
+ setBusy(false);
+ if (!res.ok) { toast({ title: "Error", description: "Could not save log.", variant: "destructive" }); return; }
+ toast({ title: "Logged!" });
+ setNotes(""); setWeight(""); setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/animals/animal-dialog.tsx b/src/components/animals/animal-dialog.tsx
new file mode 100644
index 0000000..2216a53
--- /dev/null
+++ b/src/components/animals/animal-dialog.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
+} from "@/components/ui/dialog";
+import {
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
+} from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+import { Plus, Pencil } from "lucide-react";
+import { COMMON_SPECIES } from "@/lib/animals/animal-fields";
+
+export type LocationOpt = { id: string; name: string };
+
+export type AnimalFields = {
+ id?: string;
+ name: string;
+ species: string;
+ breed: string;
+ sex: string;
+ locationId: string;
+ birthdate: string;
+ acquiredAt: string;
+ source: string;
+ notes: string;
+};
+
+export function blankAnimal(): AnimalFields {
+ return { name: "", species: "", breed: "", sex: "", locationId: "", birthdate: "", acquiredAt: "", source: "", notes: "" };
+}
+
+const NONE = "__none__";
+
+function AnimalDialog({ trigger, title, initial, locations }: {
+ trigger: React.ReactNode; title: string; initial: AnimalFields; locations: LocationOpt[];
+}) {
+ const [open, setOpen] = useState(false);
+ const [f, setF] = useState(initial);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const router = useRouter();
+ const { toast } = useToast();
+
+ function set(k: K, v: AnimalFields[K]) {
+ setF((p) => ({ ...p, [k]: v }));
+ }
+
+ async function submit(e: React.FormEvent) {
+ e.preventDefault();
+ if (!f.name.trim()) { setError("Please give the animal a name."); return; }
+ setError("");
+ setBusy(true);
+ const payload = {
+ name: f.name.trim(),
+ species: f.species.trim() || undefined,
+ breed: f.breed.trim() || undefined,
+ sex: f.sex.trim() || undefined,
+ locationId: f.locationId || null,
+ birthdate: f.birthdate || null,
+ acquiredAt: f.acquiredAt || null,
+ source: f.source.trim() || undefined,
+ notes: f.notes.trim() || undefined,
+ };
+ const res = await fetch(f.id ? `/api/v1/animals/${f.id}` : "/api/v1/animals", {
+ method: f.id ? "PATCH" : "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ setBusy(false);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: err.error ?? "Could not save.", variant: "destructive" });
+ return;
+ }
+ toast({ title: f.id ? "Saved!" : "Added!" });
+ setOpen(false);
+ router.refresh();
+ }
+
+ return (
+ <>
+ { setF(initial); setOpen(true); }}>{trigger}
+
+ >
+ );
+}
+
+export function AddAnimalButton({ locations }: { locations: LocationOpt[] }) {
+ return (
+ Add animal} />
+ );
+}
+
+export function EditAnimalButton({ animal, locations }: { animal: AnimalFields; locations: LocationOpt[] }) {
+ return (
+ Edit} />
+ );
+}
diff --git a/src/components/animals/delete-animal-button.tsx b/src/components/animals/delete-animal-button.tsx
new file mode 100644
index 0000000..6115fb3
--- /dev/null
+++ b/src/components/animals/delete-animal-button.tsx
@@ -0,0 +1,55 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
+} from "@/components/ui/dialog";
+import { useToast } from "@/hooks/use-toast";
+import { Trash2 } from "lucide-react";
+
+export function DeleteAnimalButton({ id, name }: { id: string; name: string }) {
+ const [open, setOpen] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const router = useRouter();
+ const { toast } = useToast();
+
+ async function handleDelete() {
+ setBusy(true);
+ const res = await fetch(`/api/v1/animals/${id}`, { method: "DELETE" });
+ setBusy(false);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ toast({ title: "Error", description: err.error ?? "Could not remove.", variant: "destructive" });
+ return;
+ }
+ toast({ title: "Removed", description: `"${name}" removed.` });
+ setOpen(false);
+ router.push("/animals");
+ router.refresh();
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx
index 7f35292..2839926 100644
--- a/src/components/layout/sidebar.tsx
+++ b/src/components/layout/sidebar.tsx
@@ -2,7 +2,7 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package } from "lucide-react";
+import { Leaf, Home, Wrench, LayoutDashboard, Bell, HardDrive, Lightbulb, RefreshCw, BookOpen, KeyRound, Package, PawPrint } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
@@ -10,6 +10,7 @@ const navItems = [
{ label: "Garden", href: "/garden", icon: Leaf },
{ label: "Plant types", href: "/garden/types", icon: BookOpen },
{ label: "Inventory", href: "/inventory", icon: Package },
+ { label: "Animals", href: "/animals", icon: PawPrint },
{ label: "Reminders", href: "/tasks", icon: Bell },
// Home maintenance and Equipment come in future releases
// { label: "Home", href: "/home", icon: Home },
diff --git a/src/lib/animals/age.test.ts b/src/lib/animals/age.test.ts
new file mode 100644
index 0000000..38c6c5d
--- /dev/null
+++ b/src/lib/animals/age.test.ts
@@ -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");
+ });
+});
diff --git a/src/lib/animals/age.ts b/src/lib/animals/age.ts
new file mode 100644
index 0000000..47ac468
--- /dev/null
+++ b/src/lib/animals/age.ts
@@ -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`;
+}
diff --git a/src/lib/animals/animal-fields.ts b/src/lib/animals/animal-fields.ts
new file mode 100644
index 0000000..93bade0
--- /dev/null
+++ b/src/lib/animals/animal-fields.ts
@@ -0,0 +1,31 @@
+import { AnimalLogType } from "@prisma/client";
+
+export const ANIMAL_LOG_LABELS: Record = {
+ NOTE: "Note",
+ FEEDING: "Feeding",
+ VET: "Vet visit",
+ WEIGHT: "Weight",
+ MEDICATION: "Medication",
+ BREEDING: "Breeding",
+ ACQUIRED: "Acquired",
+ DIED: "Died / removed",
+};
+
+export const ANIMAL_LOG_COLORS: Record = {
+ 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",
+];
diff --git a/src/lib/api/schemas.ts b/src/lib/api/schemas.ts
index 05f5366..bee53d4 100644
--- a/src/lib/api/schemas.ts
+++ b/src/lib/api/schemas.ts
@@ -59,3 +59,31 @@ export type TaskCompleteInput = z.infer;
export type ItemCreateInput = z.infer;
export type ItemUpdateInput = z.infer;
export type ItemLogInput = z.infer;
+
+// --- 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;
+export type AnimalUpdateInput = z.infer;
+export type AnimalLogInput = z.infer;
diff --git a/src/lib/api/scopes.ts b/src/lib/api/scopes.ts
index 6a37e67..035e528 100644
--- a/src/lib/api/scopes.ts
+++ b/src/lib/api/scopes.ts
@@ -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" },
];
diff --git a/src/lib/changelog.ts b/src/lib/changelog.ts
index 03477a1..52f0a00 100644
--- a/src/lib/changelog.ts
+++ b/src/lib/changelog.ts
@@ -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",
diff --git a/src/lib/services/animals.ts b/src/lib/services/animals.ts
new file mode 100644
index 0000000..1fa0fae
--- /dev/null
+++ b/src/lib/services/animals.ts
@@ -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 = {};
+ 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;
+}
diff --git a/src/lib/version.ts b/src/lib/version.ts
index 5b6ff40..8e9360e 100644
--- a/src/lib/version.ts
+++ b/src/lib/version.ts
@@ -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";