feat(07): Phase 7 — Health & Vet Tracking

Schema: WeightLog, VetVisit, Vaccine, HealthCard models
Router: health.ts — CRUD for weight/vet/vaccines + health card token management
Pages:
  - /pets/[petId]/health — owner-only dashboard (weight, vet visits, vaccines)
  - /health-card/[token] — public shareable health card (no auth required)
Profile: Health button added for own pets; Edit + Health side by side

Health data is owner-private; the Health Card link is the only public
access point, shareable via token URL (vet, friends, sitters).
Token can be regenerated to revoke access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-15 05:07:56 +02:00
1 parent 5d396a7bb4
commit f1c798a4b0
7 files changed
+983 -7

No files matched your search

+55
View File
@@ -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 {
@@ -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 <HealthDashboard petId={petId} petName={pet.name} />;
}
+15 -7
View File
@@ -78,14 +78,22 @@ export default async function PetProfilePage({ params }: PageProps) {
</p>
)}
{/* Edit link — only own pet */}
{/* Owner actions — edit + health */}
{isOwnPet && (
<Link
href={`/pets/${pet.id}/edit`}
className={buttonVariants({ variant: "outline" })}
>
Edit profile
</Link>
<div className="flex items-center gap-2">
<Link
href={`/pets/${pet.id}/edit`}
className={buttonVariants({ variant: "outline" })}
>
Edit profile
</Link>
<Link
href={`/pets/${pet.id}/health`}
className={buttonVariants({ variant: "outline" })}
>
Health
</Link>
</div>
)}
</div>
+165
View File
@@ -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 (
<div className="min-h-screen bg-muted/30">
{/* Header bar */}
<div className="bg-card border-b px-4 py-3 flex items-center gap-2">
<PawPrint className="h-4 w-4 text-orange-500" aria-hidden="true" />
<span className="text-sm font-semibold text-foreground">PawFeed</span>
<span className="text-xs text-muted-foreground ml-auto">Health Card</span>
</div>
<div className="max-w-[600px] mx-auto px-4 py-8 space-y-6">
{/* Pet identity */}
<div className="bg-card rounded-xl border p-6 flex items-center gap-4">
<Avatar className="w-16 h-16 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={pet.name} />}
<AvatarFallback className="text-lg font-semibold">
{pet.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-xl font-semibold">{pet.name}</h1>
<div className="flex items-center gap-2 mt-1">
<Badge variant="secondary">{pet.species.name}</Badge>
{pet.breed && <Badge variant="outline">{pet.breed.name}</Badge>}
</div>
</div>
</div>
{/* Vaccines */}
{pet.vaccines.length > 0 && (
<div className="bg-card rounded-xl border p-5 space-y-3">
<h2 className="text-base font-semibold">Vaccines</h2>
<div className="space-y-2">
{pet.vaccines.map((vac) => {
const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
return (
<div key={vac.id} className="flex items-start justify-between gap-2 py-1.5">
<div>
<p className="text-sm font-medium">{vac.name}</p>
<p className="text-xs text-muted-foreground">Given {formatDate(vac.dateGiven)}</p>
</div>
<div className="text-right shrink-0">
{vac.nextDueDate && (
<Badge variant={overdue ? "destructive" : "secondary"} className="text-xs gap-1">
{overdue && <AlertTriangle className="h-3 w-3" />}
{overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`}
</Badge>
)}
</div>
</div>
);
})}
</div>
</div>
)}
{/* Weight history */}
{pet.weightLogs.length > 0 && (
<div className="bg-card rounded-xl border p-5 space-y-3">
<h2 className="text-base font-semibold">Weight History</h2>
<div className="space-y-1">
{pet.weightLogs.map((log) => (
<div key={log.id} className="flex items-center justify-between py-1">
<p className="text-sm text-muted-foreground">{formatDate(log.date)}</p>
<div className="flex items-center gap-3">
{log.notes && <p className="text-xs text-muted-foreground">{log.notes}</p>}
<p className="text-sm font-medium tabular-nums">{formatWeight(log.weightGrams)}</p>
</div>
</div>
))}
</div>
</div>
)}
{/* Vet visits */}
{pet.vetVisits.length > 0 && (
<div className="bg-card rounded-xl border p-5 space-y-3">
<h2 className="text-base font-semibold">Vet Visits</h2>
<div className="space-y-3">
{pet.vetVisits.map((visit, i) => (
<div key={visit.id}>
{i > 0 && <Separator />}
<div className="pt-2 first:pt-0">
<div className="flex items-baseline justify-between gap-2">
<p className="text-sm font-medium">{visit.reason}</p>
<p className="text-xs text-muted-foreground shrink-0">{formatDate(visit.date)}</p>
</div>
{visit.vetName && (
<p className="text-xs text-muted-foreground mt-0.5">{visit.vetName}</p>
)}
{visit.notes && (
<p className="text-xs text-muted-foreground mt-1">{visit.notes}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
{pet.vaccines.length === 0 && pet.weightLogs.length === 0 && pet.vetVisits.length === 0 && (
<div className="bg-card rounded-xl border p-8 text-center text-muted-foreground">
<p className="text-sm">No health records have been added yet.</p>
</div>
)}
{/* Footer */}
<p className="text-xs text-muted-foreground text-center pb-4">
Generated via PawFeed · {new Date().toLocaleDateString("de-DE")}
</p>
</div>
</div>
);
}
+558
View File
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "entry" : "entries"}</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Weight
</Button>
</div>
{showForm && (
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="w-date" className="text-xs">Date</Label>
<Input id="w-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="w-kg" className="text-xs">Weight (kg)</Label>
<Input
id="w-kg"
type="number"
step="0.001"
min="0.001"
max="500"
placeholder="e.g. 4.2"
value={weightKg}
onChange={(e) => setWeightKg(e.target.value)}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="w-notes" className="text-xs">Notes (optional)</Label>
<Input id="w-notes" maxLength={200} placeholder="e.g. after morning walk" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</div>
</div>
)}
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No weight entries yet</p>
)}
{data.length > 0 && (
<div className="space-y-1">
{data.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{formatWeight(entry.weightGrams)}</p>
<p className="text-xs text-muted-foreground">
{formatDate(entry.date)}{entry.notes ? ` · ${entry.notes}` : ""}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0"
onClick={() => deleteMutation.mutate({ id: entry.id, petId })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
// ── 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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "visit" : "visits"}</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Visit
</Button>
</div>
{showForm && (
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="v-date" className="text-xs">Date</Label>
<Input id="v-date" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="v-vet" className="text-xs">Vet / Clinic (optional)</Label>
<Input id="v-vet" maxLength={100} placeholder="e.g. Dr. Müller" value={vetName} onChange={(e) => setVetName(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="v-reason" className="text-xs">Reason *</Label>
<Input id="v-reason" maxLength={100} placeholder="e.g. Annual checkup" value={reason} onChange={(e) => setReason(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="v-notes" className="text-xs">Notes (optional)</Label>
<Textarea id="v-notes" maxLength={500} placeholder="Diagnosis, treatment, medications…" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</div>
</div>
)}
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No vet visits yet</p>
)}
{data.length > 0 && (
<div className="space-y-2">
{data.map((visit) => (
<div key={visit.id} className="flex items-start gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{visit.reason}</p>
<p className="text-xs text-muted-foreground">
{formatDate(visit.date)}{visit.vetName ? ` · ${visit.vetName}` : ""}
</p>
{visit.notes && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{visit.notes}</p>
)}
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0 shrink-0"
onClick={() => deleteMutation.mutate({ id: visit.id, petId })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
)}
</div>
);
}
// ── Vaccines Tab ──────────────────────────────────────────────────────────────
function VaccinesTab({ petId }: { petId: string }) {
const trpc = useTRPC();
const qc = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [name, setName] = useState("");
const [dateGiven, setDateGiven] = useState(new Date().toISOString().slice(0, 10));
const [nextDueDate, setNextDueDate] = useState("");
const [notes, setNotes] = useState("");
const { data = [], isLoading } = useQuery(trpc.health.listVaccines.queryOptions({ petId }));
const addMutation = useMutation({
...trpc.health.addVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
toast.success("Vaccine added");
setShowForm(false);
setName("");
setNextDueDate("");
setNotes("");
},
onError: () => toast.error("Failed to save vaccine"),
});
const deleteMutation = useMutation({
...trpc.health.deleteVaccine.mutationOptions(),
onSuccess: () => {
qc.invalidateQueries({ queryKey: trpc.health.listVaccines.queryKey({ petId }) });
toast.success("Entry deleted");
},
onError: () => toast.error("Failed to delete entry"),
});
const handleAdd = () => {
if (!name.trim() || !dateGiven) {
toast.error("Please fill in vaccine name and date given");
return;
}
addMutation.mutate({
petId,
name: name.trim(),
dateGiven,
nextDueDate: nextDueDate || undefined,
notes: notes.trim() || undefined,
});
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">{data.length} {data.length === 1 ? "vaccine" : "vaccines"}</p>
<Button size="sm" variant="outline" onClick={() => setShowForm((v) => !v)}>
<Plus className="h-4 w-4 mr-1" /> Add Vaccine
</Button>
</div>
{showForm && (
<div className="border rounded-lg p-4 space-y-3 bg-muted/30">
<div className="space-y-1">
<Label htmlFor="vac-name" className="text-xs">Vaccine Name *</Label>
<Input id="vac-name" maxLength={100} placeholder="e.g. Rabies, DHPP" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="vac-given" className="text-xs">Date Given *</Label>
<Input id="vac-given" type="date" value={dateGiven} onChange={(e) => setDateGiven(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="vac-due" className="text-xs">Next Due (optional)</Label>
<Input id="vac-due" type="date" value={nextDueDate} onChange={(e) => setNextDueDate(e.target.value)} />
</div>
</div>
<div className="space-y-1">
<Label htmlFor="vac-notes" className="text-xs">Notes (optional)</Label>
<Input id="vac-notes" maxLength={200} placeholder="Batch number, clinic…" value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex gap-2 justify-end">
<Button size="sm" variant="ghost" onClick={() => setShowForm(false)}>Cancel</Button>
<Button size="sm" onClick={handleAdd} disabled={addMutation.isPending}>
{addMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Save"}
</Button>
</div>
</div>
)}
{isLoading && <div className="flex justify-center py-8"><Loader2 className="h-5 w-5 animate-spin text-muted-foreground" /></div>}
{!isLoading && data.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">No vaccines recorded yet</p>
)}
{data.length > 0 && (
<div className="space-y-2">
{data.map((vac) => {
const overdue = vac.nextDueDate && isOverdue(vac.nextDueDate);
return (
<div key={vac.id} className="flex items-start gap-3 py-2 px-3 rounded-lg hover:bg-muted/50">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-medium">{vac.name}</p>
{overdue && (
<Badge variant="destructive" className="text-xs gap-1">
<AlertTriangle className="h-3 w-3" /> Overdue
</Badge>
)}
{vac.nextDueDate && !overdue && (
<Badge variant="secondary" className="text-xs">
Due {formatDate(vac.nextDueDate)}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
Given {formatDate(vac.dateGiven)}{vac.notes ? ` · ${vac.notes}` : ""}
</p>
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive h-8 w-8 p-0 shrink-0"
onClick={() => deleteMutation.mutate({ id: vac.id, petId })}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
}
// ── Share Dialog ──────────────────────────────────────────────────────────────
function ShareDialog({ petId, petName, open, onOpenChange }: {
petId: string;
petName: string;
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const trpc = useTRPC();
const [token, setToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const getOrCreateMutation = useMutation({
...trpc.health.getOrCreateCard.mutationOptions(),
onSuccess: (data) => setToken(data.token),
onError: () => toast.error("Failed to generate health card link"),
});
const regenerateMutation = useMutation({
...trpc.health.regenerateCard.mutationOptions(),
onSuccess: (data) => {
setToken(data.token);
toast.success("Link regenerated — old link is now invalid");
},
onError: () => toast.error("Failed to regenerate link"),
});
const handleOpen = (v: boolean) => {
onOpenChange(v);
if (v && !token) {
getOrCreateMutation.mutate({ petId });
}
};
const cardUrl = token ? `${window.location.origin}/health-card/${token}` : null;
const handleCopy = async () => {
if (!cardUrl) return;
await navigator.clipboard.writeText(cardUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share {petName}&apos;s Health Card</DialogTitle>
<DialogDescription>
Anyone with this link can view {petName}&apos;s health records — weight, vaccines, and vet visits. No login required.
</DialogDescription>
</DialogHeader>
{(getOrCreateMutation.isPending) && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{cardUrl && (
<div className="space-y-4">
<div className="flex gap-2">
<Input readOnly value={cardUrl} className="text-xs font-mono" />
<Button variant="outline" size="sm" onClick={handleCopy} className="shrink-0">
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Separator />
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="text-sm font-medium">Regenerate link</p>
<p className="text-xs text-muted-foreground mt-0.5">
Creates a new link and invalidates the current one. Useful if you want to revoke access.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => regenerateMutation.mutate({ petId })}
disabled={regenerateMutation.isPending}
className="shrink-0"
>
{regenerateMutation.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <RefreshCw className="h-4 w-4" />
}
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
// ── Main Dashboard ────────────────────────────────────────────────────────────
export function HealthDashboard({ petId, petName }: HealthDashboardProps) {
const [shareOpen, setShareOpen] = useState(false);
return (
<div className="max-w-[640px] mx-auto px-4 py-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-semibold">Health — {petName}</h1>
<Button variant="outline" size="sm" onClick={() => setShareOpen(true)}>
<Share2 className="h-4 w-4 mr-1.5" />
Share Card
</Button>
</div>
<Tabs defaultValue="weight">
<TabsList className="mb-6">
<TabsTrigger value="weight">Weight</TabsTrigger>
<TabsTrigger value="vet">Vet Visits</TabsTrigger>
<TabsTrigger value="vaccines">Vaccines</TabsTrigger>
</TabsList>
<TabsContent value="weight">
<WeightTab petId={petId} />
</TabsContent>
<TabsContent value="vet">
<VetVisitsTab petId={petId} />
</TabsContent>
<TabsContent value="vaccines">
<VaccinesTab petId={petId} />
</TabsContent>
</Tabs>
<ShareDialog
petId={petId}
petName={petName}
open={shareOpen}
onOpenChange={setShareOpen}
/>
</div>
);
}
+2
View File
@@ -16,6 +16,7 @@ import { searchRouter } from "@/trpc/routers/search";
import { notificationsRouter } from "@/trpc/routers/notifications";
import { videosRouter } from "@/trpc/routers/videos";
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
import { healthRouter } from "@/trpc/routers/health";
export const appRouter = router({
pets: petsRouter,
@@ -36,6 +37,7 @@ export const appRouter = router({
videos: videosRouter,
conversations: conversationsRouter,
messages: messagesRouter,
health: healthRouter,
});
// Export the type for use in the client
+161
View File
@@ -0,0 +1,161 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import type { PrismaClient } from "@prisma/client";
async function assertOwner(prisma: PrismaClient, petId: string, userId: string) {
const pet = await prisma.pet.findUnique({ where: { id: petId }, select: { ownerId: true } });
if (!pet || pet.ownerId !== userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
}
export const healthRouter = router({
// ── Weight Logs ─────────────────────────────────────────────────────────────
listWeightLogs: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.weightLog.findMany({
where: { petId: input.petId },
orderBy: { date: "desc" },
});
}),
addWeightLog: protectedProcedure
.input(z.object({
petId: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
weightKg: z.number().positive().max(500),
notes: z.string().max(200).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.weightLog.create({
data: {
petId: input.petId,
date: new Date(input.date),
weightGrams: Math.round(input.weightKg * 1000),
notes: input.notes ?? null,
},
});
}),
deleteWeightLog: protectedProcedure
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await ctx.prisma.weightLog.delete({ where: { id: input.id } });
return { success: true };
}),
// ── Vet Visits ───────────────────────────────────────────────────────────────
listVetVisits: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.vetVisit.findMany({
where: { petId: input.petId },
orderBy: { date: "desc" },
});
}),
addVetVisit: protectedProcedure
.input(z.object({
petId: z.string().min(1),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
reason: z.string().min(1).max(100),
vetName: z.string().max(100).optional(),
notes: z.string().max(500).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.vetVisit.create({
data: {
petId: input.petId,
date: new Date(input.date),
reason: input.reason,
vetName: input.vetName ?? null,
notes: input.notes ?? null,
},
});
}),
deleteVetVisit: protectedProcedure
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await ctx.prisma.vetVisit.delete({ where: { id: input.id } });
return { success: true };
}),
// ── Vaccines ─────────────────────────────────────────────────────────────────
listVaccines: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.vaccine.findMany({
where: { petId: input.petId },
orderBy: { dateGiven: "desc" },
});
}),
addVaccine: protectedProcedure
.input(z.object({
petId: z.string().min(1),
name: z.string().min(1).max(100),
dateGiven: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
nextDueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
notes: z.string().max(200).optional(),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
return ctx.prisma.vaccine.create({
data: {
petId: input.petId,
name: input.name,
dateGiven: new Date(input.dateGiven),
nextDueDate: input.nextDueDate ? new Date(input.nextDueDate) : null,
notes: input.notes ?? null,
},
});
}),
deleteVaccine: protectedProcedure
.input(z.object({ id: z.string().min(1), petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
await ctx.prisma.vaccine.delete({ where: { id: input.id } });
return { success: true };
}),
// ── Health Card ──────────────────────────────────────────────────────────────
getOrCreateCard: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const card = await ctx.prisma.healthCard.upsert({
where: { petId: input.petId },
update: {},
create: { petId: input.petId },
});
return { token: card.token };
}),
regenerateCard: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const newToken = crypto.randomUUID().replace(/-/g, "");
const card = await ctx.prisma.healthCard.upsert({
where: { petId: input.petId },
update: { token: newToken },
create: { petId: input.petId, token: newToken },
});
return { token: card.token };
}),
});