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:
1 parent
b9f786c87f
commit
80559c3b3f
12 files changed
+780
-371
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}'s Health Card</DialogTitle>
|
||||
<DialogTitle>Health Card teilen — {petName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Anyone with this link can view {petName}'s health records — weight, vaccines, and vet visits. No login required.
|
||||
Jeder mit diesem Link kann {petName}'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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user