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:
34
src/lib/animals/age.test.ts
Normal file
34
src/lib/animals/age.test.ts
Normal 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
22
src/lib/animals/age.ts
Normal 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`;
|
||||
}
|
||||
31
src/lib/animals/animal-fields.ts
Normal file
31
src/lib/animals/animal-fields.ts
Normal 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",
|
||||
];
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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
112
src/lib/services/animals.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user