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

+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]);