feat(health): health card share link, PDF export, weight chart in card

- ShareDialog: expiry selector (7/14/30/60/90 days, default 14), shows
  expiry date after generation, "Als PDF öffnen / drucken" button
- WeightChart extracted to standalone component (WeightChart.tsx) so it
  can be shared between HealthDashboard and the public health card page
- Health card page: renders WeightChart when 2+ weight entries exist
- AutoPrint client component: auto-triggers window.print() when card is
  opened with ?print=1 (600ms delay for render to settle)
- README: Phase 6 + Phase 7 marked complete with full feature summaries;
  project structure and router list updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-15 06:06:28 +02:00
1 parent b9f786c87f
commit 80559c3b3f
12 files changed
+780 -371

No files matched your search

+102 -30
View File
@@ -5,9 +5,13 @@ 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";
import { PrintButton } from "@/components/health/PrintButton";
import { AutoPrint } from "@/components/health/AutoPrint";
import { WeightChart } from "@/components/health/WeightChart";
interface PageProps {
params: Promise<{ token: string }>;
searchParams: Promise<{ print?: string }>;
}
function formatDate(d: Date | string) {
@@ -27,8 +31,10 @@ function isOverdue(date: Date | string): boolean {
return new Date(date) < new Date();
}
export default async function HealthCardPage({ params }: PageProps) {
export default async function HealthCardPage({ params, searchParams }: PageProps) {
const { token } = await params;
const { print } = await searchParams;
const autoPrint = print === "1";
const card = await prisma.healthCard.findUnique({
where: { token },
@@ -47,16 +53,48 @@ export default async function HealthCardPage({ params }: PageProps) {
if (!card) notFound();
// Expired link
if (card.expiresAt && card.expiresAt < new Date()) {
return (
<div className="min-h-screen bg-muted/30 flex items-center justify-center px-4">
<div className="bg-card border rounded-xl p-8 max-w-sm text-center space-y-4">
<div className="flex justify-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-destructive" />
</div>
</div>
<h1 className="text-lg font-semibold">Link abgelaufen</h1>
<p className="text-sm text-muted-foreground">
Dieser Health-Card-Link ist am{" "}
<strong>{formatDate(card.expiresAt)}</strong> abgelaufen.
Bitte den Besitzer um einen neuen Link.
</p>
<div className="flex items-center justify-center gap-2 pt-2">
<PawPrint className="h-4 w-4 text-orange-500" />
<span className="text-sm font-medium text-muted-foreground">PawFeed</span>
</div>
</div>
</div>
);
}
const { pet } = card;
const avatarUrl = getAvatarUrl(pet.avatarKey);
return (
<div className="min-h-screen bg-muted/30">
{autoPrint && <AutoPrint />}
{/* Header bar */}
<div className="bg-card border-b px-4 py-3 flex items-center gap-2">
<div className="bg-card border-b px-4 py-3 flex items-center gap-2 print:hidden">
<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>
<span className="text-xs text-muted-foreground">· Health Card</span>
{card.expiresAt && (
<span className="text-xs text-muted-foreground ml-auto">
Gültig bis {formatDate(card.expiresAt)}
</span>
)}
<PrintButton />
</div>
<div className="max-w-[600px] mx-auto px-4 py-8 space-y-6">
@@ -80,21 +118,32 @@ export default async function HealthCardPage({ params }: PageProps) {
{/* 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>
<h2 className="text-base font-semibold flex items-center gap-2">
<span className="w-1 h-5 rounded-full bg-orange-500 inline-block" />
Impfungen
</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 key={vac.id} className="flex items-start justify-between gap-2 py-1.5 border-b last:border-0">
<div>
<p className="text-sm font-medium">{vac.name}</p>
<p className="text-xs text-muted-foreground">Given {formatDate(vac.dateGiven)}</p>
<p className="text-xs text-muted-foreground">
Gegeben: {formatDate(vac.dateGiven)}
{vac.notes ? ` · ${vac.notes}` : ""}
</p>
</div>
<div className="text-right shrink-0">
{vac.nextDueDate && (
<Badge variant={overdue ? "destructive" : "secondary"} className="text-xs gap-1">
<Badge
variant={overdue ? "destructive" : "secondary"}
className="text-xs gap-1"
>
{overdue && <AlertTriangle className="h-3 w-3" />}
{overdue ? "Overdue" : `Due ${formatDate(vac.nextDueDate)}`}
{overdue
? `Überfällig seit ${formatDate(vac.nextDueDate)}`
: `Fällig ${formatDate(vac.nextDueDate)}`}
</Badge>
)}
</div>
@@ -108,25 +157,43 @@ export default async function HealthCardPage({ params }: PageProps) {
{/* 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>
<h2 className="text-base font-semibold flex items-center gap-2">
<span className="w-1 h-5 rounded-full bg-orange-300 inline-block" />
Gewichtsverlauf
</h2>
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground border-b">
<th className="text-left pb-2 font-medium">Datum</th>
<th className="text-right pb-2 font-medium">Gewicht</th>
<th className="text-left pb-2 pl-4 font-medium">Notiz</th>
</tr>
</thead>
<tbody>
{pet.weightLogs.map((log) => (
<tr key={log.id} className="border-b last:border-0">
<td className="py-1.5 text-muted-foreground">{formatDate(log.date)}</td>
<td className="py-1.5 text-right font-medium tabular-nums">
{formatWeight(log.weightGrams)}
</td>
<td className="py-1.5 pl-4 text-xs text-muted-foreground">{log.notes ?? ""}</td>
</tr>
))}
</tbody>
</table>
{pet.weightLogs.length >= 2 && (
<WeightChart data={pet.weightLogs} />
)}
</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>
<h2 className="text-base font-semibold flex items-center gap-2">
<span className="w-1 h-5 rounded-full bg-gray-400 inline-block" />
Tierarztbesuche
</h2>
<div className="space-y-3">
{pet.vetVisits.map((visit, i) => (
<div key={visit.id}>
@@ -134,7 +201,9 @@ export default async function HealthCardPage({ params }: PageProps) {
<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>
<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>
@@ -149,16 +218,19 @@ export default async function HealthCardPage({ params }: PageProps) {
</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>
)}
{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">Noch keine Gesundheitsdaten vorhanden.</p>
</div>
)}
{/* Footer */}
<p className="text-xs text-muted-foreground text-center pb-4">
Generated via PawFeed · {new Date().toLocaleDateString("de-DE")}
</p>
<div className="flex items-center justify-between text-xs text-muted-foreground pb-4">
<span>Erstellt via PawFeed · {new Date().toLocaleDateString("de-DE")}</span>
{card.expiresAt && <span>Gültig bis {formatDate(card.expiresAt)}</span>}
</div>
</div>
</div>
);
+197
View File
@@ -0,0 +1,197 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { Trash2, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { getAvatarUrl } from "@/lib/avatar-url";
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
interface CommentSheetProps {
postId: string;
petId: string; // active pet (commenter)
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommentSheet({ postId, petId, open, onOpenChange }: CommentSheetProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [body, setBody] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { data, isLoading } = useQuery(
trpc.comments.listByPost.queryOptions({ postId, limit: 50 })
);
const comments = data?.comments ?? [];
const createMutation = useMutation(
trpc.comments.create.mutationOptions({
onSuccess: () => {
setBody("");
void queryClient.invalidateQueries({
queryKey: [["trpc", "comments", "listByPost"]],
});
void queryClient.invalidateQueries({
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error("Couldn't post comment. Please try again."),
})
);
const deleteMutation = useMutation(
trpc.comments.delete.mutationOptions({
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: [["trpc", "comments", "listByPost"]],
});
void queryClient.invalidateQueries({
queryKey: [["trpc", "feed", "getFeed"]],
});
},
onError: () => toast.error("Couldn't delete comment. Please try again."),
})
);
const handleSubmit = useCallback(() => {
const trimmed = body.trim();
if (!trimmed || createMutation.isPending) return;
createMutation.mutate({ postId, petId, body: trimmed });
}, [body, postId, petId, createMutation]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
// Auto-focus textarea when sheet opens
useEffect(() => {
if (open) {
setTimeout(() => textareaRef.current?.focus(), 100);
}
}, [open]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="bottom" className="h-[80vh] flex flex-col p-0">
<SheetHeader className="px-4 pt-4 pb-2">
<SheetTitle className="text-left">Comments</SheetTitle>
</SheetHeader>
<Separator />
<ScrollArea className="flex-1 px-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-10">
No comments yet — be the first!
</p>
) : (
<div className="py-2">
{comments.map((comment) => {
const isOwn = comment.pet.id === petId;
const avatarUrl = getAvatarUrl(comment.pet.avatarKey);
return (
<div key={comment.id} className="flex gap-3 py-3">
<Avatar className="w-8 h-8 shrink-0">
{avatarUrl && (
<img src={avatarUrl} alt={comment.pet.name} className="object-cover" />
)}
<AvatarFallback className="text-xs">
{comment.pet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm font-medium truncate">
{comment.pet.name}
</span>
<span className="text-xs text-muted-foreground shrink-0">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<p className="text-sm mt-0.5 break-words">{comment.body}</p>
</div>
{isOwn && (
<button
type="button"
aria-label="Delete comment"
disabled={deleteMutation.isPending}
onClick={() =>
deleteMutation.mutate({ commentId: comment.id, petId })
}
className="shrink-0 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
);
})}
</div>
)}
</ScrollArea>
<Separator />
{/* Comment input — sticky at bottom */}
<div className="flex gap-2 px-4 py-3 bg-background">
<Textarea
ref={textareaRef}
rows={1}
maxLength={500}
placeholder="Add a comment…"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 resize-none min-h-[40px] max-h-[120px] text-sm"
/>
<Button
size="sm"
disabled={!body.trim() || createMutation.isPending}
onClick={handleSubmit}
className="bg-orange-500 hover:bg-orange-600 text-white self-end"
>
{createMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Post"
)}
</Button>
</div>
</SheetContent>
</Sheet>
);
}
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
export function AutoPrint() {
useEffect(() => {
const t = setTimeout(() => window.print(), 600);
return () => clearTimeout(t);
}, []);
return null;
}
+88 -286
View File
@@ -3,7 +3,7 @@
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 { Plus, Trash2, Share2, RefreshCw, Copy, Check, Loader2, AlertTriangle, ExternalLink } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -19,6 +19,14 @@ import {
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { WeightChart } from "@/components/health/WeightChart";
interface HealthDashboardProps {
petId: string;
@@ -43,279 +51,6 @@ function isOverdue(date: Date | string): boolean {
return new Date(date) < new Date();
}
// ── Weight Chart ──────────────────────────────────────────────────────────────
type ChartEntry = { date: Date | string; weightGrams: number };
function WeightChart({ data }: { data: ChartEntry[] }) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
// Sort oldest → newest for left-to-right reading
const sorted = [...data]
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
const values = sorted.map((e) => e.weightGrams / 1000);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rawRange = maxV - minV;
const pad = rawRange === 0 ? 0.5 : rawRange * 0.18;
const yMin = minV - pad;
const yMax = maxV + pad;
const yRange = yMax - yMin;
// SVG canvas
const W = 500, H = 190;
const L = 46, R = 12, T = 14, B = 38;
const cW = W - L - R;
const cH = H - T - B;
const xOf = (i: number) =>
sorted.length === 1 ? L + cW / 2 : L + (i / (sorted.length - 1)) * cW;
const yOf = (v: number) => T + cH - ((v - yMin) / yRange) * cH;
const pts = sorted.map((e, i) => ({ x: xOf(i), y: yOf(e.weightGrams / 1000), e }));
// Smooth bezier path between points
const linePath = pts
.map((p, i) => {
if (i === 0) return `M ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
const areaPath =
`M ${pts[0].x} ${T + cH} ` +
pts
.map((p, i) => {
if (i === 0) return `L ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ") +
` L ${pts[pts.length - 1].x} ${T + cH} Z`;
// Y-axis ticks (4 evenly spaced)
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) =>
yMin + (yRange * i) / yTicks
);
// X-axis labels: at most 5, always show first + last
const maxLabels = 5;
const step = Math.max(1, Math.ceil(sorted.length / maxLabels));
const xLabelIndices = new Set(
sorted
.map((_, i) => i)
.filter((i) => i % step === 0 || i === sorted.length - 1)
);
// Trend label
const delta = values[values.length - 1] - values[0];
const trendSign = delta > 0 ? "+" : "";
const trendLabel = `${trendSign}${delta.toFixed(2)} kg`;
const trendColor = "#9ca3af"; // neutral gray — weight change is context-dependent
return (
<div className="rounded-xl border bg-muted/20 p-4">
{/* Header */}
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Verlauf
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{trendLabel}
</span>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ maxHeight: 190 }}
role="img"
aria-label="Weight history chart"
>
<defs>
<linearGradient id="pawfeed-wg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity="0.28" />
<stop offset="100%" stopColor="#f97316" stopOpacity="0" />
</linearGradient>
{/* Clip to chart area */}
<clipPath id="pawfeed-clip">
<rect x={L} y={T} width={cW} height={cH} />
</clipPath>
</defs>
{/* Horizontal grid lines + Y labels */}
{yTickVals.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={L} y1={y} x2={W - R} y2={y}
stroke="currentColor"
strokeOpacity={i === 0 ? 0.12 : 0.06}
strokeWidth="1"
strokeDasharray={i === 0 ? "0" : "3 3"}
/>
<text
x={L - 5}
y={y}
textAnchor="end"
dominantBaseline="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{v.toFixed(v < 1 ? 2 : 1)}
</text>
</g>
);
})}
{/* Area fill (clipped) */}
<path
d={areaPath}
fill="url(#pawfeed-wg)"
clipPath="url(#pawfeed-clip)"
/>
{/* Line */}
<path
d={linePath}
fill="none"
stroke="#f97316"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
clipPath="url(#pawfeed-clip)"
/>
{/* Invisible hit areas + dots */}
{pts.map((p, i) => {
const isHovered = hoveredIdx === i;
return (
<g key={i}>
{/* Large invisible hit target */}
<circle
cx={p.x}
cy={p.y}
r={16}
fill="transparent"
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
style={{ cursor: "pointer" }}
/>
{/* Outer glow ring on hover */}
{isHovered && (
<circle cx={p.x} cy={p.y} r={8} fill="#f97316" fillOpacity="0.15" />
)}
{/* Dot */}
<circle
cx={p.x}
cy={p.y}
r={isHovered ? 5 : 3.5}
fill={isHovered ? "#f97316" : "white"}
stroke="#f97316"
strokeWidth={isHovered ? 0 : 2}
style={{ transition: "r 0.12s ease, fill 0.12s ease" }}
pointerEvents="none"
/>
</g>
);
})}
{/* X-axis labels */}
{sorted.map((e, i) => {
if (!xLabelIndices.has(i)) return null;
const d = new Date(e.date);
const label = `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
return (
<text
key={i}
x={xOf(i)}
y={H - 8}
textAnchor="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{label}
</text>
);
})}
{/* Tooltip */}
{hoveredIdx !== null && (() => {
const p = pts[hoveredIdx];
const weight = formatWeight(p.e.weightGrams);
const dateStr = formatDate(p.e.date);
const tw = 96, th = 40;
let tx = p.x - tw / 2;
tx = Math.max(L, Math.min(tx, W - R - tw));
let ty = p.y - th - 12;
if (ty < T) ty = p.y + 14;
return (
<g pointerEvents="none">
{/* Connector line */}
<line
x1={p.x}
y1={ty + th}
x2={p.x}
y2={p.y - 6}
stroke="#f97316"
strokeOpacity="0.3"
strokeWidth="1"
strokeDasharray="2 2"
/>
{/* Box */}
<rect
x={tx} y={ty}
width={tw} height={th}
rx="7"
fill="#18181b"
fillOpacity="0.88"
/>
{/* Orange accent left bar */}
<rect
x={tx} y={ty}
width={3} height={th}
rx="7"
fill="#f97316"
/>
{/* Weight value */}
<text
x={tx + tw / 2 + 1}
y={ty + 15}
textAnchor="middle"
fontSize="12"
fontWeight="700"
fill="#f97316"
>
{weight}
</text>
{/* Date */}
<text
x={tx + tw / 2 + 1}
y={ty + 29}
textAnchor="middle"
fontSize="9.5"
fill="white"
fillOpacity="0.55"
>
{dateStr}
</text>
</g>
);
})()}
</svg>
</div>
);
}
// ── Weight Tab ────────────────────────────────────────────────────────────────
function WeightTab({ petId }: { petId: string }) {
@@ -697,6 +432,14 @@ function VaccinesTab({ petId }: { petId: string }) {
// ── Share Dialog ──────────────────────────────────────────────────────────────
const EXPIRY_OPTIONS = [
{ value: "7", label: "7 Tage" },
{ value: "14", label: "14 Tage" },
{ value: "30", label: "30 Tage" },
{ value: "60", label: "60 Tage" },
{ value: "90", label: "90 Tage" },
] as const;
function ShareDialog({ petId, petName, open, onOpenChange }: {
petId: string;
petName: string;
@@ -705,30 +448,40 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
}) {
const trpc = useTRPC();
const [token, setToken] = useState<string | null>(null);
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
const [expiryDays, setExpiryDays] = useState<"7" | "14" | "30" | "60" | "90">("14");
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"),
onSuccess: (data) => {
setToken(data.token);
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
},
onError: () => toast.error("Link konnte nicht generiert werden"),
});
const regenerateMutation = useMutation({
...trpc.health.regenerateCard.mutationOptions(),
onSuccess: (data) => {
setToken(data.token);
toast.success("Link regenerated — old link is now invalid");
setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null);
toast.success("Neuer Link erstellt — alter Link ist jetzt ungültig");
},
onError: () => toast.error("Failed to regenerate link"),
onError: () => toast.error("Link konnte nicht neu generiert werden"),
});
const handleOpen = (v: boolean) => {
onOpenChange(v);
if (v && !token) {
getOrCreateMutation.mutate({ petId });
getOrCreateMutation.mutate({ petId, expiryDays: parseInt(expiryDays) as 7 | 14 | 30 | 60 | 90 });
}
};
const handleGenerate = () => {
getOrCreateMutation.mutate({ petId, expiryDays: parseInt(expiryDays) as 7 | 14 | 30 | 60 | 90 });
};
const cardUrl = token ? `${window.location.origin}/health-card/${token}` : null;
const handleCopy = async () => {
@@ -738,17 +491,44 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
setTimeout(() => setCopied(false), 2000);
};
const handleOpenPdf = () => {
if (!cardUrl) return;
window.open(cardUrl + "?print=1", "_blank");
};
const isPending = getOrCreateMutation.isPending || regenerateMutation.isPending;
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Share {petName}&apos;s Health Card</DialogTitle>
<DialogTitle>Health Card teilen — {petName}</DialogTitle>
<DialogDescription>
Anyone with this link can view {petName}&apos;s health records — weight, vaccines, and vet visits. No login required.
Jeder mit diesem Link kann {petName}&apos;s Gesundheitsdaten einsehen — Gewicht, Impfungen und Tierarztbesuche. Kein Login erforderlich.
</DialogDescription>
</DialogHeader>
{(getOrCreateMutation.isPending) && (
{/* Expiry selector + Generate */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Gültigkeitsdauer</Label>
<div className="flex gap-2">
<Select value={expiryDays} onValueChange={(v) => setExpiryDays(v as typeof expiryDays)}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EXPIRY_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" onClick={handleGenerate} disabled={isPending} className="shrink-0">
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Generieren"}
</Button>
</div>
</div>
{isPending && !cardUrl && (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
@@ -756,25 +536,47 @@ function ShareDialog({ petId, petName, open, onOpenChange }: {
{cardUrl && (
<div className="space-y-4">
{/* Link + copy */}
<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>
{/* Expiry info */}
{expiresAt && (
<p className="text-xs text-muted-foreground">
Gültig bis <span className="font-medium text-foreground">{formatDate(expiresAt)}</span>
</p>
)}
{/* PDF button */}
<Button
variant="secondary"
size="sm"
className="w-full gap-2"
onClick={handleOpenPdf}
>
<ExternalLink className="h-4 w-4" />
Als PDF öffnen / drucken
</Button>
<Separator />
{/* Regenerate */}
<div className="flex items-start gap-3">
<div className="flex-1">
<p className="text-sm font-medium">Regenerate link</p>
<p className="text-sm font-medium">Link neu generieren</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.
Erstellt einen neuen Link und macht den aktuellen ungültig. Nützlich zum Widerrufen von Zugriffen.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => regenerateMutation.mutate({ petId })}
disabled={regenerateMutation.isPending}
onClick={() => regenerateMutation.mutate({ petId, expiryDays: parseInt(expiryDays) as 7 | 14 | 30 | 60 | 90 })}
disabled={isPending}
className="shrink-0"
>
{regenerateMutation.isPending
+18
View File
@@ -0,0 +1,18 @@
"use client";
import { Printer } from "lucide-react";
import { Button } from "@/components/ui/button";
export function PrintButton() {
return (
<Button
variant="outline"
size="sm"
onClick={() => window.print()}
className="ml-auto flex items-center gap-1.5"
>
<Printer className="h-4 w-4" />
Als PDF speichern
</Button>
);
}
+225
View File
@@ -0,0 +1,225 @@
"use client";
import { useState } from "react";
export type ChartEntry = { date: Date | string; weightGrams: number };
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`;
}
export function WeightChart({ data }: { data: ChartEntry[] }) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
const sorted = [...data].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()
);
const values = sorted.map((e) => e.weightGrams / 1000);
const minV = Math.min(...values);
const maxV = Math.max(...values);
const rawRange = maxV - minV;
const pad = rawRange === 0 ? 0.5 : rawRange * 0.18;
const yMin = minV - pad;
const yMax = maxV + pad;
const yRange = yMax - yMin;
const W = 500, H = 190;
const L = 46, R = 12, T = 14, B = 38;
const cW = W - L - R;
const cH = H - T - B;
const xOf = (i: number) =>
sorted.length === 1 ? L + cW / 2 : L + (i / (sorted.length - 1)) * cW;
const yOf = (v: number) => T + cH - ((v - yMin) / yRange) * cH;
const pts = sorted.map((e, i) => ({ x: xOf(i), y: yOf(e.weightGrams / 1000), e }));
const linePath = pts
.map((p, i) => {
if (i === 0) return `M ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
const areaPath =
`M ${pts[0].x} ${T + cH} ` +
pts
.map((p, i) => {
if (i === 0) return `L ${p.x} ${p.y}`;
const prev = pts[i - 1];
const cpX = (prev.x + p.x) / 2;
return `C ${cpX} ${prev.y} ${cpX} ${p.y} ${p.x} ${p.y}`;
})
.join(" ") +
` L ${pts[pts.length - 1].x} ${T + cH} Z`;
const yTicks = 4;
const yTickVals = Array.from({ length: yTicks + 1 }, (_, i) =>
yMin + (yRange * i) / yTicks
);
const maxLabels = 5;
const step = Math.max(1, Math.ceil(sorted.length / maxLabels));
const xLabelIndices = new Set(
sorted
.map((_, i) => i)
.filter((i) => i % step === 0 || i === sorted.length - 1)
);
const delta = values[values.length - 1] - values[0];
const trendSign = delta > 0 ? "+" : "";
const trendLabel = `${trendSign}${delta.toFixed(2)} kg`;
return (
<div className="rounded-xl border bg-muted/20 p-4">
<div className="flex items-center justify-between mb-2 px-1">
<span className="text-xs font-medium text-muted-foreground tracking-wide uppercase">
Verlauf
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{trendLabel}
</span>
</div>
<svg
viewBox={`0 0 ${W} ${H}`}
className="w-full"
style={{ maxHeight: 190 }}
role="img"
aria-label="Weight history chart"
>
<defs>
<linearGradient id="pawfeed-wg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#f97316" stopOpacity="0.28" />
<stop offset="100%" stopColor="#f97316" stopOpacity="0" />
</linearGradient>
<clipPath id="pawfeed-clip">
<rect x={L} y={T} width={cW} height={cH} />
</clipPath>
</defs>
{yTickVals.map((v, i) => {
const y = yOf(v);
return (
<g key={i}>
<line
x1={L} y1={y} x2={W - R} y2={y}
stroke="currentColor"
strokeOpacity={i === 0 ? 0.12 : 0.06}
strokeWidth="1"
strokeDasharray={i === 0 ? "0" : "3 3"}
/>
<text
x={L - 5} y={y}
textAnchor="end"
dominantBaseline="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{v.toFixed(v < 1 ? 2 : 1)}
</text>
</g>
);
})}
<path d={areaPath} fill="url(#pawfeed-wg)" clipPath="url(#pawfeed-clip)" />
<path
d={linePath}
fill="none"
stroke="#f97316"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
clipPath="url(#pawfeed-clip)"
/>
{pts.map((p, i) => {
const isHovered = hoveredIdx === i;
return (
<g key={i}>
<circle
cx={p.x} cy={p.y} r={16}
fill="transparent"
onMouseEnter={() => setHoveredIdx(i)}
onMouseLeave={() => setHoveredIdx(null)}
style={{ cursor: "pointer" }}
/>
{isHovered && (
<circle cx={p.x} cy={p.y} r={8} fill="#f97316" fillOpacity="0.15" />
)}
<circle
cx={p.x} cy={p.y}
r={isHovered ? 5 : 3.5}
fill={isHovered ? "#f97316" : "white"}
stroke="#f97316"
strokeWidth={isHovered ? 0 : 2}
style={{ transition: "r 0.12s ease, fill 0.12s ease" }}
pointerEvents="none"
/>
</g>
);
})}
{sorted.map((e, i) => {
if (!xLabelIndices.has(i)) return null;
const d = new Date(e.date);
const label = `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
return (
<text
key={i}
x={xOf(i)} y={H - 8}
textAnchor="middle"
fontSize="9"
fill="currentColor"
fillOpacity="0.4"
>
{label}
</text>
);
})}
{hoveredIdx !== null && (() => {
const p = pts[hoveredIdx];
const weight = formatWeight(p.e.weightGrams);
const dateStr = formatDate(p.e.date);
const tw = 96, th = 40;
let tx = p.x - tw / 2;
tx = Math.max(L, Math.min(tx, W - R - tw));
let ty = p.y - th - 12;
if (ty < T) ty = p.y + 14;
return (
<g pointerEvents="none">
<line
x1={p.x} y1={ty + th} x2={p.x} y2={p.y - 6}
stroke="#f97316" strokeOpacity="0.3" strokeWidth="1" strokeDasharray="2 2"
/>
<rect x={tx} y={ty} width={tw} height={th} rx="7" fill="#18181b" fillOpacity="0.88" />
<rect x={tx} y={ty} width={3} height={th} rx="7" fill="#f97316" />
<text x={tx + tw / 2 + 1} y={ty + 15} textAnchor="middle" fontSize="12" fontWeight="700" fill="#f97316">
{weight}
</text>
<text x={tx + tw / 2 + 1} y={ty + 29} textAnchor="middle" fontSize="9.5" fill="white" fillOpacity="0.55">
{dateStr}
</text>
</g>
);
})()}
</svg>
</div>
);
}
+3 -2
View File
@@ -100,9 +100,10 @@ export function VideoUploadForm({ onClose }: VideoUploadFormProps) {
toast.success("Video uploaded — processing in the background");
onClose();
} catch (err) {
console.error(err);
const msg = err instanceof Error ? err.message : String(err);
console.error("[VideoUpload]", msg);
setUploadState("error");
toast.error("Upload failed. Please try again.");
toast.error(`Upload failed: ${msg}`);
}
}, [file, activePetId, caption, createUploadMutation, onClose]);
+24 -8
View File
@@ -135,27 +135,43 @@ export const healthRouter = router({
// ── Health Card ──────────────────────────────────────────────────────────────
getOrCreateCard: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.input(z.object({
petId: z.string().min(1),
expiryDays: z.union([
z.literal(7), z.literal(14), z.literal(30),
z.literal(60), z.literal(90),
]).default(14),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
const card = await ctx.prisma.healthCard.upsert({
where: { petId: input.petId },
update: {},
create: { petId: input.petId },
update: { expiresAt },
create: { petId: input.petId, expiresAt },
});
return { token: card.token };
return { token: card.token, expiresAt: card.expiresAt };
}),
regenerateCard: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.input(z.object({
petId: z.string().min(1),
expiryDays: z.union([
z.literal(7), z.literal(14), z.literal(30),
z.literal(60), z.literal(90),
]).default(14),
}))
.mutation(async ({ ctx, input }) => {
await assertOwner(ctx.prisma, input.petId, ctx.userId);
const newToken = crypto.randomUUID().replace(/-/g, "");
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + input.expiryDays);
const card = await ctx.prisma.healthCard.upsert({
where: { petId: input.petId },
update: { token: newToken },
create: { petId: input.petId, token: newToken },
update: { token: newToken, expiresAt },
create: { petId: input.petId, token: newToken, expiresAt },
});
return { token: card.token };
return { token: card.token, expiresAt: card.expiresAt };
}),
});