diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 917a605..dbda604 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,6 +69,12 @@ model Pet { conversationsAsB Conversation[] @relation("ConvPetB") sentMessages Message[] @relation("MessageSender") + // Phase 7 back-relations + weightLogs WeightLog[] + vetVisits VetVisit[] + vaccines Vaccine[] + healthCard HealthCard? + @@index([ownerId]) } @@ -341,6 +347,55 @@ model Message { @@index([senderPetId]) } +// ─── Phase 7 Models ────────────────────────────────────────────────────────── + +model WeightLog { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String + date DateTime @db.Date + weightGrams Int // stored in grams; display as kg + notes String? @db.VarChar(200) + createdAt DateTime @default(now()) + + @@index([petId, date(sort: Desc)]) +} + +model VetVisit { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String + date DateTime @db.Date + reason String @db.VarChar(100) + notes String? @db.VarChar(500) + vetName String? @db.VarChar(100) + createdAt DateTime @default(now()) + + @@index([petId, date(sort: Desc)]) +} + +model Vaccine { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String + name String @db.VarChar(100) + dateGiven DateTime @db.Date + nextDueDate DateTime? @db.Date + notes String? @db.VarChar(200) + createdAt DateTime @default(now()) + + @@index([petId]) +} + +model HealthCard { + id String @id @default(cuid()) + pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade) + petId String @unique + token String @unique @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + // ─── Phase 4 Enums ─────────────────────────────────────────────────────────── enum NotificationType { diff --git a/src/app/(app)/pets/[petId]/health/page.tsx b/src/app/(app)/pets/[petId]/health/page.tsx new file mode 100644 index 0000000..40edd1f --- /dev/null +++ b/src/app/(app)/pets/[petId]/health/page.tsx @@ -0,0 +1,27 @@ +import { notFound, redirect } from "next/navigation"; +import { currentUser } from "@clerk/nextjs/server"; +import { createTRPCCaller } from "@/trpc/server"; +import { HealthDashboard } from "@/components/health/HealthDashboard"; + +interface PageProps { + params: Promise<{ petId: string }>; +} + +export default async function HealthPage({ params }: PageProps) { + const { petId } = await params; + + const [user, trpc] = await Promise.all([currentUser(), createTRPCCaller()]); + + let pet; + try { + pet = await trpc.pets.getProfile({ petId }); + } catch { + notFound(); + } + + if (!user || pet.ownerId !== user.id) { + redirect(`/pets/${petId}`); + } + + return ; +} diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 983d17d..74d1e48 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -78,14 +78,22 @@ export default async function PetProfilePage({ params }: PageProps) {

)} - {/* Edit link — only own pet */} + {/* Owner actions — edit + health */} {isOwnPet && ( - - Edit profile - +
+ + Edit profile + + + Health + +
)} diff --git a/src/app/health-card/[token]/page.tsx b/src/app/health-card/[token]/page.tsx new file mode 100644 index 0000000..caf9033 --- /dev/null +++ b/src/app/health-card/[token]/page.tsx @@ -0,0 +1,165 @@ +import { notFound } from "next/navigation"; +import { PawPrint, AlertTriangle } from "lucide-react"; +import { prisma } from "@/lib/prisma"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { getAvatarUrl } from "@/lib/avatar-url"; + +interface PageProps { + params: Promise<{ token: string }>; +} + +function formatDate(d: Date | string) { + return new Date(d).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function formatWeight(grams: number): string { + if (grams < 1000) return `${grams} g`; + return `${parseFloat((grams / 1000).toFixed(3))} kg`; +} + +function isOverdue(date: Date | string): boolean { + return new Date(date) < new Date(); +} + +export default async function HealthCardPage({ params }: PageProps) { + const { token } = await params; + + const card = await prisma.healthCard.findUnique({ + where: { token }, + include: { + pet: { + include: { + species: true, + breed: true, + weightLogs: { orderBy: { date: "desc" }, take: 20 }, + vetVisits: { orderBy: { date: "desc" }, take: 10 }, + vaccines: { orderBy: { dateGiven: "desc" } }, + }, + }, + }, + }); + + if (!card) notFound(); + + const { pet } = card; + const avatarUrl = getAvatarUrl(pet.avatarKey); + + return ( +
+ {/* Header bar */} +
+
+ +
+ {/* Pet identity */} +
+ + {avatarUrl && } + + {pet.name.slice(0, 2).toUpperCase()} + + +
+

{pet.name}

+
+ {pet.species.name} + {pet.breed && {pet.breed.name}} +
+
+
+ + {/* Vaccines */} + {pet.vaccines.length > 0 && ( +
+

Vaccines

+
+ {pet.vaccines.map((vac) => { + const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate); + return ( +
+
+

{vac.name}

+

Given {formatDate(vac.dateGiven)}

+
+
+ {vac.nextDueDate && ( + + {overdue && } + {overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`} + + )} +
+
+ ); + })} +
+
+ )} + + {/* Weight history */} + {pet.weightLogs.length > 0 && ( +
+

Weight History

+
+ {pet.weightLogs.map((log) => ( +
+

{formatDate(log.date)}

+
+ {log.notes &&

{log.notes}

} +

{formatWeight(log.weightGrams)}

+
+
+ ))} +
+
+ )} + + {/* Vet visits */} + {pet.vetVisits.length > 0 && ( +
+

Vet Visits

+
+ {pet.vetVisits.map((visit, i) => ( +
+ {i > 0 && } +
+
+

{visit.reason}

+

{formatDate(visit.date)}

+
+ {visit.vetName && ( +

{visit.vetName}

+ )} + {visit.notes && ( +

{visit.notes}

+ )} +
+
+ ))} +
+
+ )} + + {pet.vaccines.length === 0 && pet.weightLogs.length === 0 && pet.vetVisits.length === 0 && ( +
+

No health records have been added yet.

+
+ )} + + {/* Footer */} +

+ Generated via PawFeed · {new Date().toLocaleDateString("de-DE")} +

+
+
+ ); +} diff --git a/src/components/health/HealthDashboard.tsx b/src/components/health/HealthDashboard.tsx new file mode 100644 index 0000000..6e7198e --- /dev/null +++ b/src/components/health/HealthDashboard.tsx @@ -0,0 +1,558 @@ +"use client"; + +import { useState } from "react"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle } from "lucide-react"; +import { useTRPC } from "@/trpc/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; + +interface HealthDashboardProps { + petId: string; + petName: string; +} + +function formatDate(d: Date | string) { + return new Date(d).toLocaleDateString("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + }); +} + +function formatWeight(grams: number): string { + if (grams < 1000) return `${grams} g`; + const kg = grams / 1000; + return `${parseFloat(kg.toFixed(3))} kg`; +} + +function isOverdue(date: Date | string): boolean { + return new Date(date) < new Date(); +} + +// ── Weight Tab ──────────────────────────────────────────────────────────────── + +function WeightTab({ petId }: { petId: string }) { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [weightKg, setWeightKg] = useState(""); + const [notes, setNotes] = useState(""); + + const { data = [], isLoading } = useQuery(trpc.health.listWeightLogs.queryOptions({ petId })); + + const addMutation = useMutation({ + ...trpc.health.addWeightLog.mutationOptions(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) }); + toast.success("Weight entry added"); + setShowForm(false); + setWeightKg(""); + setNotes(""); + }, + onError: () => toast.error("Failed to save weight entry"), + }); + + const deleteMutation = useMutation({ + ...trpc.health.deleteWeightLog.mutationOptions(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: trpc.health.listWeightLogs.queryKey({ petId }) }); + toast.success("Entry deleted"); + }, + onError: () => toast.error("Failed to delete entry"), + }); + + const handleAdd = () => { + const kg = parseFloat(weightKg); + if (!date || isNaN(kg) || kg <= 0) { + toast.error("Please fill in date and a valid weight"); + return; + } + addMutation.mutate({ petId, date, weightKg: kg, notes: notes.trim() || undefined }); + }; + + return ( +
+
+

{data.length} {data.length === 1 ? "entry" : "entries"}

+ +
+ + {showForm && ( +
+
+
+ + setDate(e.target.value)} /> +
+
+ + setWeightKg(e.target.value)} + /> +
+
+
+ + setNotes(e.target.value)} /> +
+
+ + +
+
+ )} + + {isLoading &&
} + + {!isLoading && data.length === 0 && ( +

No weight entries yet

+ )} + + {data.length > 0 && ( +
+ {data.map((entry) => ( +
+
+

{formatWeight(entry.weightGrams)}

+

+ {formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""} +

+
+ +
+ ))} +
+ )} +
+ ); +} + +// ── Vet Visits Tab ──────────────────────────────────────────────────────────── + +function VetVisitsTab({ petId }: { petId: string }) { + const trpc = useTRPC(); + const qc = useQueryClient(); + const [showForm, setShowForm] = useState(false); + const [date, setDate] = useState(new Date().toISOString().slice(0, 10)); + const [reason, setReason] = useState(""); + const [vetName, setVetName] = useState(""); + const [notes, setNotes] = useState(""); + + const { data = [], isLoading } = useQuery(trpc.health.listVetVisits.queryOptions({ petId })); + + const addMutation = useMutation({ + ...trpc.health.addVetVisit.mutationOptions(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) }); + toast.success("Vet visit added"); + setShowForm(false); + setReason(""); + setVetName(""); + setNotes(""); + }, + onError: () => toast.error("Failed to save vet visit"), + }); + + const deleteMutation = useMutation({ + ...trpc.health.deleteVetVisit.mutationOptions(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: trpc.health.listVetVisits.queryKey({ petId }) }); + toast.success("Entry deleted"); + }, + onError: () => toast.error("Failed to delete entry"), + }); + + const handleAdd = () => { + if (!date || !reason.trim()) { + toast.error("Please fill in date and reason"); + return; + } + addMutation.mutate({ + petId, + date, + reason: reason.trim(), + vetName: vetName.trim() || undefined, + notes: notes.trim() || undefined, + }); + }; + + return ( +
+
+

{data.length} {data.length === 1 ? "visit" : "visits"}

+ +
+ + {showForm && ( +
+
+
+ + setDate(e.target.value)} /> +
+
+ + setVetName(e.target.value)} /> +
+
+
+ + setReason(e.target.value)} /> +
+
+ +