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

+14
View File
@@ -0,0 +1,14 @@
Debug:
Health - Share Card - Downloadbar machen als PDF Ansicht, mit Profil Foto und allen Health Daten übersichtlich aufgegliedert.
jeweils mit Farblichen Trennern oder Rahmen. So wäre es möglich bei einem Arztwechsel, gleich alle Daten mitnehmen zu können.
Oder für den Notfall ausgedruckt dabei zu haben.
Health - Share Card - Der Link wird nicht generiert, hier müsste ein Unique link erstellt werden, mit einem Verfallsdatum in der regel sollten 14 Tage ausreichen, aber man soll sich das selbst einstellen können, 7,13,30,60,90 Tage zur Auswahl.
+67 -39
View File
@@ -30,6 +30,7 @@ A social platform where pets are the stars. Dogs, cats, and birds get their own
- The social feed uses a **Redis fan-out-on-write** pattern: on each new post, a sorted set entry is written into every follower's feed inbox. `feed.getFeed` reads from Redis and hydrates with Prisma. Falls back to Postgres when Redis is unavailable.
- Authentication is handled by **Clerk** (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users.
- Short-form video is handled by **Mux**: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via `mux-player-react`. The `getByPostId` query self-heals in dev by polling the Mux API directly, so webhooks are not required during local development.
- **Health data is owner-private.** The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters.
---
@@ -45,16 +46,21 @@ src/
pets/[petId]/edit/
pets/[petId]/followers/
pets/[petId]/following/
pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines)
search/ # Pet + People + Hashtag search
messages/ # DM inbox list
messages/[conversationId]/ # Conversation view
onboarding/ # Pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up
health-card/[token]/ # Public shareable health card (no login required)
api/
trpc/ # tRPC HTTP handler
webhooks/
mux/ # Mux webhook handler (video.asset.ready etc.)
components/
feed/ # PostCard, MilestoneCard, RepostCard, FeedList, SkeletonCard
# PawButton (🐾 reaction), PawBackButton (repost), CommentSheet
# PawButton (paw reaction), PawBackButton (repost), CommentSheet
health/ # HealthDashboard (tabs), WeightChart (SVG bezier), PrintButton, AutoPrint
layout/ # Sidebar (with NotificationBell), MobileNav
notifications/ # NotificationBell (badge + count)
pet/ # AvatarUpload, PetForm, PetListItem
@@ -65,7 +71,7 @@ src/
social/ # FollowButton
stories/ # PawRing, StoryViewer, StoryTray, StoryForm
ui/ # shadcn component copies (button, dialog, sheet, etc.)
video/ # VideoCard (polls PROCESSING → plays READY via MuxPlayer)
video/ # VideoCard (polls PROCESSING -> plays READY via MuxPlayer)
context/
ActivePetContext.tsx # Global active pet state (pet switcher)
lib/
@@ -73,7 +79,7 @@ src/
feed-helpers.ts # fanOutPost, backfillOnFollow (Redis)
hashtag-helpers.ts # extractHashtags, saveHashtags
media-url.ts # CDN URL construction for post/story media
milestone-meta.ts # MilestoneType → Lucide icon + label map
milestone-meta.ts # MilestoneType -> Lucide icon + label map
milestone-thresholds.ts # Follower count thresholds for auto-milestones
mux.ts # Mux Node SDK singleton + MUX_WEBHOOK_SECRET
parse-caption.ts # Tokenises captions into text + #hashtag segments
@@ -87,13 +93,13 @@ src/
query-client.ts # TanStack Query client factory
routers/
_app.ts # Root router
pets.ts # CRUD for pet profiles
pets.ts # CRUD for pet profiles + getProfile (no ownership check)
posts.ts # Photo posts (create, updateCaption, delete, byPetId)
stories.ts # 24h stories (create, listActive, hasActiveStory, recordView, getViewers)
milestones.ts # Milestone posts (create, listByPet)
feed.ts # Home feed (getFeed via Redis + Prisma hydration)
follows.ts # Pet-to-pet follow graph
reactions.ts # 🐾 paw reactions (toggle, count per post)
reactions.ts # Paw reactions (toggle, count per post)
comments.ts # Flat comments (create, delete, listByPost)
reposts.ts # Paw-Back repost with attribution
explore.ts # Explore page (species/breed filter)
@@ -103,6 +109,8 @@ src/
blocks.ts # Block a pet
reports.ts # Report a post or pet
media.ts # Avatar presigned upload (getPresignedUrl, confirmUpload)
messages.ts # DM conversations (create, list, send, markRead)
health.ts # Weight logs, vet visits, vaccines, shareable health card
__tests__/
helpers/
prisma-mock.ts # Typed mock PrismaClient factory
@@ -117,7 +125,8 @@ src/
middleware.ts # Next.js middleware (Clerk auth guard)
prisma/
schema.prisma # Full DB schema (Owner, Pet, Post, Story, Follow, Block, Report,
# Reaction, Comment, Repost, Hashtag, Notification, VideoPost)
# Reaction, Comment, Repost, Hashtag, Notification, VideoPost,
# Conversation, Message, WeightLog, VetVisit, Vaccine, HealthCard)
seed.ts # Species + breed seed (Dog/Cat/Bird)
supabase/
migrations/
@@ -155,17 +164,17 @@ cp .env.example .env.local
| Variable | Where to find it |
|---|---|
| `DATABASE_URL` | Supabase → Project Settings → Database → Connection string (pooled, port 6543) |
| `DIRECT_URL` | Supabase → Project Settings → Database → Connection string (direct, port 5432) |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard → API Keys |
| `CLERK_SECRET_KEY` | Clerk Dashboard → API Keys |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase → Project Settings → API → Project URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase → Project Settings → API → service_role key |
| `UPSTASH_REDIS_REST_URL` | Upstash Console → Redis database → REST URL |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console → Redis database → REST Token |
| `MUX_TOKEN_ID` | Mux Dashboard → Settings → API Access Tokens |
| `MUX_TOKEN_SECRET` | Mux Dashboard → Settings → API Access Tokens |
| `MUX_WEBHOOK_SECRET` | Mux Dashboard → Settings → Webhooks → Signing secret |
| `DATABASE_URL` | Supabase -> Project Settings -> Database -> Connection string (pooled, port 6543) |
| `DIRECT_URL` | Supabase -> Project Settings -> Database -> Connection string (direct, port 5432) |
| `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` | Clerk Dashboard -> API Keys |
| `CLERK_SECRET_KEY` | Clerk Dashboard -> API Keys |
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase -> Project Settings -> API -> Project URL |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase -> Project Settings -> API -> service_role key |
| `UPSTASH_REDIS_REST_URL` | Upstash Console -> Redis database -> REST URL |
| `UPSTASH_REDIS_REST_TOKEN` | Upstash Console -> Redis database -> REST Token |
| `MUX_TOKEN_ID` | Mux Dashboard -> Settings -> API Access Tokens |
| `MUX_TOKEN_SECRET` | Mux Dashboard -> Settings -> API Access Tokens |
| `MUX_WEBHOOK_SECRET` | Mux Dashboard -> Settings -> Webhooks -> Signing secret |
> `SUPABASE_SERVICE_ROLE_KEY` and `MUX_TOKEN_SECRET` are server-only secrets. They are imported exclusively in `server-only` modules and never exposed to the client.
@@ -195,11 +204,11 @@ Enable Row Level Security on all tables (run once in Supabase SQL Editor):
### 4. Supabase Storage
Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard → Storage → New bucket).
Create a public bucket named `pet-avatars` in Supabase Storage (Dashboard -> Storage -> New bucket).
### 5. Mux webhook (production only)
In Mux Dashboard → Settings → Webhooks, add an endpoint pointing to `https://your-domain.com/api/webhooks/mux`. Subscribe to:
In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to `https://your-domain.com/api/webhooks/mux`. Subscribe to:
- `video.upload.asset_created`
- `video.asset.ready`
- `video.asset.errored`
@@ -241,7 +250,7 @@ Open [http://localhost:3000](http://localhost:3000). The app redirects unauthent
Clerk User (owner)
└── owns 1..n Pet profiles (Postgres)
└── Pet is the social identity (posts, follows, stories, milestones,
reactions, comments, reposts, notifications, videos)
reactions, comments, reposts, notifications, videos, DMs, health data)
```
An owner can switch between their pets via `ActivePetContext`. All tRPC mutations assert `pet.ownerId === ctx.userId` before writing.
@@ -249,19 +258,19 @@ An owner can switch between their pets via `ActivePetContext`. All tRPC mutation
### Media pipeline — Photos
```
Client → tRPC getPresignedUrl → Supabase Storage signed URL
Client → XHR PUT directly to signed URL (bytes never hit Next.js server)
Client → tRPC create/confirmUpload (writes storage key to DB)
Feed → getMediaUrl(storageKey) → Supabase public CDN URL
Client -> tRPC getPresignedUrl -> Supabase Storage signed URL
Client -> XHR PUT directly to signed URL (bytes never hit Next.js server)
Client -> tRPC create/confirmUpload (writes storage key to DB)
Feed -> getMediaUrl(storageKey) -> Supabase public CDN URL
```
### Media pipeline — Video (Mux)
```
Client → tRPC videos.createUpload → Mux direct upload URL + PROCESSING Post row
Client → XHR PUT directly to Mux URL (bytes never hit Next.js server)
Mux → transcodes → fires webhook → /api/webhooks/mux → updates VideoPost (READY + playbackId)
VideoCard polls videos.getByPostId every 5s → transitions from spinner to MuxPlayer
Client -> tRPC videos.createUpload -> Mux direct upload URL + PROCESSING Post row
Client -> XHR PUT directly to Mux URL (bytes never hit Next.js server)
Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (READY + playbackId)
VideoCard polls videos.getByPostId every 5s -> transitions from spinner to MuxPlayer
```
> In dev (no webhook): `getByPostId` checks the Mux API directly when the status is `PROCESSING` and updates the DB on the fly. No ngrok required.
@@ -274,21 +283,25 @@ Post created
└── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId
feed.getFeed({ petId, cursor })
└── ZRANGE feed:{petId} → postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) → hydrated posts
└── ZRANGE feed:{petId} -> postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
└── Fallback (Redis unavailable): Postgres query on follows + own posts
```
### Notification triggers (fire-and-forget)
```
follows.follow → FOLLOW notification to followeePet
reactions.toggle → REACTION notification to post owner (skip if own post)
comments.create → COMMENT notification to post owner (skip if own post)
follows.follow -> FOLLOW notification to followeePet
reactions.toggle -> REACTION notification to post owner (skip if own post)
comments.create -> COMMENT notification to post owner (skip if own post)
```
All triggers are wrapped in `.catch(() => {})` — they never crash the parent mutation.
### Health data privacy
All health data (weight logs, vet visits, vaccines) is owner-private. The only publicly accessible health surface is the **HealthCard**: a token URL with a user-selected expiry (7 / 14 / 30 / 60 / 90 days). The card page checks expiry server-side before rendering. Opening the card with `?print=1` auto-triggers `window.print()` for browser-native PDF export — no PDF library required.
### Story expiry (lazy-delete)
Stories are never deleted in real time. All queries filter `expiresAt > NOW()`. A weekly cleanup job purges expired rows. Story views are retained 30 days for analytics.
@@ -331,7 +344,7 @@ The viewer identity comes from `ActivePetContext` inside `PawRing` itself — ca
### Phase 3 — Engagement & Discovery ✅
- **🐾 Paw reactions:** single reaction per post, toggle on/off, live count on PostCard
- **Paw reactions:** single reaction per post, toggle on/off, live count on PostCard
- **Comments:** flat thread, create/delete own, CommentSheet drawer
- **Paw-Back (Repost):** repost with attribution header in feed (PostType.REPOST)
- **Explore page:** species tabs + breed filter, random fresh content
@@ -356,13 +369,28 @@ The viewer identity comes from `ActivePetContext` inside `PawRing` itself — ca
- Mux webhook handler at `/api/webhooks/mux` for production (`video.upload.asset_created`, `video.asset.ready`, `video.asset.errored`)
- Video posts appear in feed, profile grid, and notifications alongside photo posts
### Phase 6 — Messaging (planned)
### Phase 6 — Messaging ✅
Direct messages between pet owners via Supabase Realtime.
- `Conversation` + `Message` models; `DmPolicy` enum (`EVERYONE | FOLLOWERS_ONLY`) on Pet
- Pet-to-pet DMs: inbox sorted by last message, conversation view with read/unread tracking
- `readAt` timestamp per message; unread count badge in Sidebar + MobileNav
- DM policy enforced server-side: FOLLOWERS_ONLY pets reject messages from non-followers
- Conversation deduplication: `petAId` always < `petBId` (sorted on create) — one row per pair
### Phase 7 — Health (planned)
### Phase 7 — Health & Vet Tracking ✅
Vet records, vaccination logs, weight tracking, shareable health card.
- `WeightLog`, `VetVisit`, `Vaccine`, `HealthCard` models — all owner-private
- Health dashboard at `/pets/[petId]/health` with three tabs: Weight / Tierarztbesuche / Impfungen
- Weight trend chart: pure SVG bezier curve, no library, PawFeed orange-500 design, hover tooltips; shown when 2+ entries exist
- `WeightChart` is an extracted, reusable client component (`src/components/health/WeightChart.tsx`)
- Overdue vaccine badge (AlertTriangle + destructive variant) when `nextDueDate` is in the past
- **Shareable health card** at `/health-card/[token]` — no login required
- Expiry selectable per share: 7 / 14 / 30 / 60 / 90 days (default 14)
- Expired links show a German-language expiry message with the exact date
- Link can be regenerated (old token invalidated) at any time
- Weight trend chart included in the card view
- PDF export: "Als PDF öffnen / drucken" opens the card with `?print=1` which auto-triggers `window.print()` — browser-native, no PDF library
- `print:hidden` Tailwind variant hides nav chrome during print
---
@@ -383,7 +411,7 @@ npx vitest # watch mode
|---|---|
| `DATABASE_URL` must use the **pooled** connection (port 6543, `?pgbouncer=true`) | Prisma uses this at runtime. `DIRECT_URL` (port 5432) is only for `prisma db push` / migrations. |
| Prisma on Windows requires `DIRECT_URL` set in `.env.local` | The Prisma CLI reads `.env.local` via a custom `prisma.config.ts`. If `npx prisma db push` hangs, check `DIRECT_URL` is set. |
| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new enum values (e.g. `PostType.VIDEO`). |
| After a Prisma schema change, **always restart the dev server** | The Next.js dev server caches the Prisma client in memory. A running server started before `prisma generate` will not see new models. |
| Supabase service_role key is never client-facing | All files that import `supabase-storage.ts` must be `server-only`. The client only ever receives signed URLs. |
| `asChild` prop is not available in the base-nova shadcn preset | Do not use `asChild` on any shadcn component. This affects `PopoverTrigger`, `DropdownMenuItem`, etc. Style the trigger element directly. |
| StoryView records are NOT cascade-deleted when a story expires | This is intentional (D-08): view analytics are retained 30 days. A separate cleanup job will purge old views. |
+7 -6
View File
@@ -388,12 +388,13 @@ model Vaccine {
}
model HealthCard {
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
token String @unique @default(cuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
pet Pet @relation(fields: [petId], references: [id], onDelete: Cascade)
petId String @unique
token String @unique @default(cuid())
expiresAt DateTime? // null = never expires; set on create/regenerate
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
+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 };
}),
});
@@ -0,0 +1,23 @@
-- Enable RLS on all application tables.
--
-- All data access in this project goes through Prisma via a direct Postgres
-- connection (service_role), which bypasses RLS. We do NOT use Supabase
-- PostgREST for any data queries. Enabling RLS with zero policies closes the
-- PostgREST surface entirely: anon/authenticated roles cannot read or write
-- any table through the Supabase REST or Realtime APIs.
--
-- Prisma is unaffected — direct connections with service_role skip RLS.
-- Supabase Storage is a separate subsystem and is also unaffected.
ALTER TABLE "Owner" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Species" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Breed" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Pet" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Post" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "PostImage" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Milestone" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Story" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "StoryView" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Follow" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Block" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "Report" ENABLE ROW LEVEL SECURITY;