From 6153be04cf51ee12d03ce1083a6883496d1c58b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 23:28:12 +0200 Subject: [PATCH] docs(ios-handoff): C-UI&State design system + state mgmt + i18n --- .planning/v15-ios-handoff/12-design-system.md | 357 +++++++++++++++ .../v15-ios-handoff/13-state-management.md | 429 ++++++++++++++++++ .planning/v15-ios-handoff/19-i18n-system.md | 343 ++++++++++++++ 3 files changed, 1129 insertions(+) create mode 100644 .planning/v15-ios-handoff/12-design-system.md create mode 100644 .planning/v15-ios-handoff/13-state-management.md create mode 100644 .planning/v15-ios-handoff/19-i18n-system.md diff --git a/.planning/v15-ios-handoff/12-design-system.md b/.planning/v15-ios-handoff/12-design-system.md new file mode 100644 index 000000000..3c4968800 --- /dev/null +++ b/.planning/v15-ios-handoff/12-design-system.md @@ -0,0 +1,357 @@ +--- +file: 12-design-system.md +purpose: Visual language reference — tokens, components, mobile-first rules, accessibility floors — so the iOS native build mirrors the web look without re-deriving it. +when_to_read: Before laying out any iOS screen, picking colours, or sizing touch targets. Re-read when porting a specific web surface (dashboard tile, chart band, range bar) to UIKit/SwiftUI. +prerequisites: 10-architecture.md (stack), 11-web-ui-tour.md (per-page composition) +estimated_tokens: 5200 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# Design System — Tokens, Components, Mobile-First Rules + +## TL;DR + +Dracula in the dark, Alucard in the light, both wired through shadcn CSS variables. Tailwind v4 with `@theme inline` re-exports every token. Mobile-first: every surface starts at 360 px, progressive-enhances upward; no top/bottom split of the same concept on one screen; touch targets ≥ 44 × 44 CSS px; WCAG AA contrast in both themes. iOS native should ship one palette per appearance (light/dark) and reuse the chart hex literals verbatim so screenshots line up next to the web app. + +--- + +## 1. Token Architecture + +### 1.1 Layer cake + +``` + ┌──────────────────────────────┐ + │ globals.css │ + │ ─────────── │ + │ Dracula raw palette (root) │ + │ Dark mode overrides (.dark) │ + │ Light mode overrides (.light)│ + └─────────────┬────────────────┘ + │ + ┌─────────────▼────────────────┐ + │ @theme inline {…} │ + │ ──────────────── │ + │ Re-exports CSS vars as │ + │ Tailwind `--color-*` tokens │ + │ so `bg-card` / `text-fg` JIT│ + └─────────────┬────────────────┘ + │ + ┌─────────────▼────────────────┐ + │ shadcn components │ + │ ──────────────── │ + │ Read tokens by semantic name│ + │ (primary, muted, ring …) │ + └──────────────────────────────┘ +``` + +### 1.2 Raw Dracula palette + +```css +/* from src/app/globals.css:108-122 */ +:root { + --dracula-bg: #282a36; + --dracula-current: #44475a; + --dracula-fg: #f8f8f2; + --dracula-comment: #9aa3b3; + --dracula-cyan: #8be9fd; + --dracula-green: #50fa7b; + --dracula-orange: #ffb86c; + --dracula-pink: #ff79c6; + --dracula-purple: #bd93f9; + --dracula-red: #ff5555; + --dracula-yellow: #f1fa8c; + --dracula-lavender: #d6acff; +} +``` + +### 1.3 Semantic aliases + +| Semantic | Dark (Dracula) | Light (Alucard) | Use | +|----------|----------------|------------------|-----| +| `--background` | `#282a36` | `#f5f5f5` | Page canvas | +| `--foreground` | `#f8f8f2` | `#1f1f1f` | Body text | +| `--card` | `#1e1f29` | `#ffffff` | Tiles, panels, dialogs | +| `--muted` | `#44475a` | `#dcdeef` | Disabled chrome | +| `--muted-foreground` | `#9aa3b3` | `#5b6273` | Captions, hints | +| `--primary` | `#bd93f9` (purple) | `#644ac9` | Buttons, CTAs | +| `--destructive` | `#ff5555` | `#cb3a2a` | Delete, danger | +| `--border` | `#44475a` | `#cfcfde` | Hairlines | +| `--success` | `#50fa7b` | `#14720a` | Positive state | +| `--warning` | `#ffb86c` | `#a34d14` | Caution | +| `--info` | `#8be9fd` | `#036a96` | Neutral note | + +> STOP HERE if you intend to introduce a new colour outside this list. Marc's rule: charts and badges go through the named tokens; raw hex only inside Recharts series definitions. + +### 1.4 Chart series hex literals (lock these) + +Recharts series colours are hard-coded in the dashboard for visual identity. Mirror exactly on iOS: + +```ts +// from src/app/page.tsx:990, 1038, 1059, 1079, 1113, 1133 +weight → #bd93f9 // purple +bp_systolic → #ff79c6 // pink +bp_diastolic → #8be9fd // cyan +pulse → #50fa7b // green +body_fat → #ffb86c // orange +sleep → #8be9fd // cyan +steps → #50fa7b // green +bmi (derived)→ #f1fa8c // yellow +``` + +Traffic-light bands (range overlays): + +```ts +red → #ff5555 opacity 0.16 +orange → #ffb86c opacity 0.18 +green → #50fa7b opacity 0.20 +``` + +The Recharts library may not exist on iOS; whatever native chart wrapper iOS picks (Swift Charts) must reproduce these colour stops at the same opacities or screenshots will visibly diverge between platforms. + +--- + +## 2. Theme Toggle + +| Concern | Web implementation | iOS equivalent | +|---------|--------------------|-----------------| +| Switch | `ThemeProvider` from `next-themes` writes `.dark` / `.light` on `` | `UITraitCollection.userInterfaceStyle` / `@Environment(\.colorScheme)` | +| Storage | `localStorage` + a cookie so SSR doesn't flash | `UserDefaults` mirrored from the `User.theme` field | +| Default | "system" — follows OS appearance | Same | + +The web app re-exports the theme in a cookie because the Next.js SSR step needs it before hydration. iOS can skip the cookie dance; just persist locally and respect `traitCollection`. + +--- + +## 3. Component Library + +### 3.1 Inventory + +``` +src/components/ui/ ← shadcn primitives +├── alert-dialog.tsx +├── avatar.tsx +├── badge.tsx +├── button.tsx ← cva variants: default / destructive / outline / secondary / ghost / link +├── card.tsx +├── date-input.tsx +├── dialog.tsx +├── dropdown-menu.tsx +├── empty-state.tsx ← custom; the only non-shadcn primitive +├── input.tsx +├── label.tsx +├── logo.tsx +├── password-strength.tsx +├── progress.tsx +├── select.tsx +├── separator.tsx +├── sheet.tsx ← bottom sheets + side drawers +├── skeleton.tsx +├── sonner.tsx ← toasts +├── switch.tsx +├── table.tsx +├── tabs.tsx +└── tooltip.tsx +``` + +### 3.2 Button variants + +```ts +// from src/components/ui/button.tsx:8-34 +default → bg-primary text-primary-fg hover:bg-primary/90 +destructive → bg-destructive text-white hover:bg-destructive/90 +outline → border bg-background hover:bg-accent +secondary → bg-secondary text-secondary-fg hover:bg-secondary/80 +ghost → hover:bg-accent (no background at rest) +link → text-primary underline-offset-4 hover:underline + +size: default(h-9) | xs(h-6) | sm(h-8) | lg(h-10) + icon(size-9) | icon-xs(size-6) | icon-sm(size-8) | icon-lg(size-10) +``` + +iOS port table: + +| Web variant | UIKit / SwiftUI equivalent | +|-------------|-----------------------------| +| `default` | `UIButton.Configuration.filled` tinted with `primary` | +| `destructive` | `.filled` tinted red, `.role = .destructive` on Apple menu items | +| `outline` | `.bordered` | +| `secondary` | `.gray` | +| `ghost` | `.plain` | +| `link` | `UIButton` w/ `attributedTitle` underline | + +### 3.3 The custom EmptyState + +The web app has exactly one bespoke primitive: ``. Used on the dashboard zero-state, every insight sub-page when the metric has < 5 readings, and the medications list when empty. Same shape on iOS: + +```tsx +// from src/components/ui/empty-state.tsx, conceptual contract +{ icon: ReactNode, title: string, description?: string, action?: ReactNode } +``` + +iOS equivalent: a reusable `EmptyStateView` SwiftUI struct with the same four slots. + +--- + +## 4. Mobile-First Conventions + +### 4.1 Marc's mobile rules + +1. **No top/bottom split of the same concept.** Don't put a provider dropdown at the top of Settings and the provider config form at the bottom — the user has to scroll to see the consequence of the toggle. Drive the form *under* the dropdown. (Memory: `feedback_settings_no_split`.) +2. **Single-line headings + inline trend arrow + baseline alignment** (W20a). Tile headings never wrap; trend arrow sits on the same baseline as the value. +3. **Tabs above the hero**, not below. Sub-page navigation lives at the top of the scroll view; the hero is the first scroll-snap target underneath. +4. **Full-width grid rule.** The tile strip starts and ends at the exact same x-coordinates as the chart row below it. No 12-column grids on mobile; the tiles fill the container. +5. **Equal-height trends row.** Every chart in the chart row is the same height to give a calm vertical rhythm. + +### 4.2 Touch targets + +```ts +// post-Fix-L (W21) — WCAG 2.5.5 floor +min-w-11 min-h-11 // 44×44 CSS px +``` + +`size="sm"` (h-8 = 32 px) is *below* the floor; add `className="min-h-11"` whenever a small button lives in a mobile-first surface. The web app currently does this on every quick-add dropdown trigger. + +iOS gets this for free: UIKit's `hitTest` honours `accessibilityFrame`; SwiftUI's default control sizes are already ≥ 44 pt. Just don't shrink them. + +### 4.3 Container widths + +| Breakpoint | Container | +|------------|-----------| +| `< 640px` | full-width with `px-4` page padding | +| `≥ 640px` | `max-w-2xl mx-auto` | +| `≥ 1024px` | `max-w-5xl mx-auto` | +| `≥ 1280px` | `max-w-6xl mx-auto` | + +iOS: lock the readable-content layout to ~720 pt max on iPad; on iPhone, fill the safe-area width with system-standard insets. + +--- + +## 5. Specific Patterns You Will Re-implement + +### 5.1 Trend tile (dashboard strip) + +``` +┌───────────────────────────────┐ +│ ⚖ Weight ↗ +1.2│ ← icon + label + trend arrow + delta +│ │ +│ 78.4 kg │ ← latest value (large, bold) +│ │ +│ 7d: 78.1 30d: 77.6 │ ← sub-values +└───────────────────────────────┘ +``` + +Source: `` at `src/components/charts/trend-card.tsx`. Key contract: every tile is the same width (CSS grid `auto-fit, minmax(9rem, 1fr)`) and the chart row below shares the same start/end columns. Translate to iOS: a `LazyVGrid` with `GridItem(.adaptive(minimum: 144))` and equal-height rows. + +### 5.2 MedicationDetailSection chrome (Fix-N lock) + +``` +┌──────────────────────────────────────────┐ +│ Titration (drug INN badge) [+ Add] │ ← header row, px-3 py-2.5 +├──────────────────────────────────────────┤ ← border-border/60 hairline +│ │ +│ …section body… │ ← px-3 py-3 +│ │ +└──────────────────────────────────────────┘ + border-border/60 rounded-md border +``` + +Locked since W21 Fix-N. The wrapper `` enforces this contract so the three GLP-1 panels (Titration, Scheduling, SideEffects) cannot drift on padding or border opacity. Code at `src/components/medications/medication-detail-section.tsx`. + +iOS: a `SectionContainer` view with the exact same paddings (12 pt horizontal, 10 pt header-vertical, 12 pt body-vertical) and a hairline divider at 60 % `separator` opacity. + +### 5.3 Range bar (Dracula consistency) + +The "value vs traffic-light range" chip used everywhere (Targets page, dashboard sub-pages, doctor report): + +``` + ▓▓▓▓▓░░░░░░░░░░░░░░░ ← red zone (#ff5555 @ 16%) + ░░░▓▓▓▓▓▓▓▓░░░░░░░░░ ← orange (#ffb86c @ 18%) + ░░░░░░░░░▓▓▓▓▓▓▓░░░░ ← green (#50fa7b @ 20%) + ░░░░░░░░░░░░░░▓▓▓▓▓▓ ← orange + ░░░░░░░░░░░░░░░░░▓▓▓ ← red + ↑ + current value marker +``` + +Opacities are non-negotiable. Less opacity reads as "barely there"; more opacity drowns the value marker. iOS Swift Charts: use `RuleMark` per band with `.foregroundStyle(.color.opacity(0.20))`. + +### 5.4 InsightsCardPreview (dashboard) + +A compact 3-recommendation preview pinned above the chart row on the dashboard. Severity-ordered, with a "View all" link → `/insights`. Self-hides when the user has no provider. Source: `src/components/insights/insights-card.tsx`. + +iOS: a `InsightPreviewCard` view at the top of the dashboard `ScrollView`, identical content shape — title, severity dot, body, "View all" trailing chevron. + +--- + +## 6. Typography + +| Role | Web class | Size | Weight | +|------|-----------|------|--------| +| Page title (h1) | `text-2xl font-bold tracking-tight` | 24 px | 700 | +| Section heading | `text-lg font-semibold` | 18 px | 600 | +| Tile label | `text-sm font-medium text-muted-fg` | 14 px | 500 | +| Tile value | `text-2xl font-bold` | 24 px | 700 | +| Body | `text-sm` | 14 px | 400 | +| Caption / hint | `text-xs text-muted-fg` | 12 px | 400 | +| Mono (rare — tokens, ids) | `font-mono` | inherits | + +Font stack: system sans / system mono via `--font-sans` and `--font-mono` (Geist Sans / Geist Mono in production, but the variable lets you swap). iOS: SF Pro Text + SF Mono — closest analogues. + +--- + +## 7. Spacing & Radius + +| Token | Value | +|-------|-------| +| `--radius` | `0.625rem` (10 px) — the base | +| `--radius-sm` | 6 px | +| `--radius-md` | 8 px | +| `--radius-lg` | 10 px | +| `--radius-xl` | 14 px | + +Cards use `rounded-md` (8 px); buttons `rounded-md`; dialogs `rounded-lg`; the Sheet bottom-drawer uses a top-only `rounded-t-2xl`. + +iOS: `RoundedRectangle(cornerRadius: 10)` for cards, `12` for sheets. + +--- + +## 8. Accessibility Floors + +| WCAG | Web enforcement | iOS port | +|------|------------------|----------| +| 1.4.3 Contrast | Both palettes audited AA on the white/dark card | Re-audit on iOS — system grays differ | +| 2.1.1 Keyboard | Focus ring `ring-3 ring-ring/50` on every focusable | UIKit gets focus engine for free on iPad | +| 2.4.7 Focus visible | `focus-visible:` everywhere | iOS focus on tvOS only; iPhone uses VoiceOver | +| 2.5.5 Touch targets | `min-h-11 min-w-11` | System buttons already ≥ 44 pt | +| 4.1.2 Name/role/value | `aria-label`, `aria-labelledby`, `role` on custom widgets | `.accessibilityLabel`, `.accessibilityRole` | + +> Since v1.4.24: the `MedicationDetailSection` wrapper enforces `aria-labelledby` wiring so the three GLP-1 panels can no longer drift apart on screen-reader semantics. The iOS sibling should expose each section as an `.accessibilityElement(children: .contain)` with a heading element marked `.accessibilityAddTraits(.isHeader)`. + +--- + +## 9. Self-Test + +If your iOS screen passes this checklist it is design-system compliant: + +- [ ] Background = `Color("dracula-bg")` in dark, `Color("alucard-bg")` in light, sourced from a colour asset catalog with matched dark/light variants. +- [ ] Every chart series uses the exact hex in §1.4. +- [ ] No tappable target < 44 × 44 pt. +- [ ] Page title is 24 pt bold; nothing on the screen is larger except the latest tile value. +- [ ] Card corners are 10 pt rounded; section headers carry a hairline divider at 60 % separator alpha. +- [ ] A range bar uses the three opacity stops `0.16 / 0.18 / 0.20` for red/orange/green. +- [ ] An EmptyState component is used for every zero-data surface (no inline "no data" sentence). +- [ ] Dynamic Type scales the page (test at `xxxLarge`). + +--- + +## 10. What NOT to Port + +The web app has a handful of accidents iOS should not inherit: + +| Web reality | iOS guidance | +|-------------|---------------| +| `next-themes` cookie-based default | Use `traitCollection.userInterfaceStyle` only | +| Recharts `` width-fixed hack | Swift Charts auto-sizes | +| `getRangeColorClass()` returning string class names | Use `Color` directly via a `valueBand(for:)` helper | +| `data-tour-id="…"` markers (Shepherd.js spotlight) | iOS uses `.accessibilityIdentifier` for UI tests instead | +| `next/dynamic` chart imports for SSR avoidance | No equivalent — every Swift view is rendered client-side | diff --git a/.planning/v15-ios-handoff/13-state-management.md b/.planning/v15-ios-handoff/13-state-management.md new file mode 100644 index 000000000..2dd69facc --- /dev/null +++ b/.planning/v15-ios-handoff/13-state-management.md @@ -0,0 +1,429 @@ +--- +file: 13-state-management.md +purpose: TanStack Query conventions on the web side plus the iOS data-layer mirror so the native app reuses the same cache contracts without poisoning state. +when_to_read: Before wiring any iOS network call, designing an iOS data store, or porting a mutation. Re-read when a screen looks "stale" or a cache invalidation isn't propagating. +prerequisites: 10-architecture.md, 12-design-system.md +estimated_tokens: 4400 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# State Management — Cache Keys, Unwrap Pattern, Streaming + +## TL;DR + +Every web query goes through `queryKeys.*` factories so a typo cannot silently poison the cache. Responses are always unwrapped via `(await res.json()).data`. Mutations invalidate every read query that touches the resource. iOS should mirror this with a typed `CacheKey` enum, an `APIEnvelope` decoder, and a `CacheInvalidator` that maps mutation → keys-to-evict. + +--- + +## 1. The One Rule You Cannot Break + +> Same `queryKey` + different `queryFn` unwrap shape = silent cache poisoning. + +This is Marc's hard-earned rule (memory: `feedback_react_query_key_collision`). Two `useQuery` calls keyed `["medications"]` where one returns `{ data: Medication[] }.data` and another returns the envelope itself will mutate each other's cache on focus refresh. The user sees the medications page flicker between a list and an object literal. + +Defensive convention: + +1. Every query key comes from `src/lib/query-keys.ts` — no inline string arrays. +2. Every query function does `const json = await res.json(); return json.data as T;`. +3. The factory return type is `as const` so two keys cannot collide structurally without TypeScript noticing. + +iOS analogue: + +```swift +// from .planning/v15-ios-handoff/13-state-management.md (concept) +enum CacheKey: Hashable { + case auth + case me + case analytics + case dashboardWidgets + case medications + case medicationDetail(String) + case insightsComprehensive + case insightsBpStatus(locale: String) + // … +} + +struct APIEnvelope: Decodable { + let data: T + let error: APIError? + let meta: APIMeta? +} +``` + +The Swift `Hashable` enum makes structural collisions impossible — `medicationDetail("abc")` and `medicationDetail("def")` are distinct values; you can't accidentally write `.medications` when you meant `.medicationDetail(id)`. + +--- + +## 2. The `queryKeys` Factory (canonical list) + +```ts +// from src/lib/query-keys.ts — selected highlights +auth() → ["auth"] +authRegistrationStatus() → ["auth", "registration-status"] + +measurements() → ["measurements"] +moodEntries() → ["mood-entries"] + +analytics() → ["analytics"] +moodAnalytics() → ["mood-analytics"] + +insightsRoot() → ["insights"] +insightsComprehensive() → ["insights", "comprehensive"] +insightsTargets() → ["insights", "targets"] +insightsAdvisor() → ["insights", "advisor"] +insightsBpStatus(locale) → ["insights", "blood-pressure-status", locale] +insightsWeightStatus(locale) → ["insights", "weight-status", locale] +insightsPulseStatus(locale) → ["insights", "pulse-status", locale] +insightsBmiStatus(locale) → ["insights", "bmi-status", locale] +insightsMoodStatus(locale) → ["insights", "mood-status", locale] +insightsMedicationComplianceStatus(locale) + → ["insights", "medication-compliance-status", locale] + +medications() → ["medications"] +medicationDetail(id) → ["medications", id] +medicationComplianceChart(id) → ["compliance-chart-inline", id] +medicationPhaseConfig(id) → ["phase-config", id] +medicationIntakeSummary() → ["medications", "intake-summary"] + +dashboardWidgets() → ["user", "dashboardWidgets"] +dashboardGlp1() → ["dashboard", "glp1"] + +sourcePriority() → ["auth", "source-priority"] + +gamificationAchievements() → ["gamification", "achievements"] +notificationsPreferences() → ["notifications", "preferences"] +adminSettings() / adminStatus() / adminUsers() / adminTokens() / adminAuditLog(filter) +tokens() / telegram() / telegramSettings() / withings() +``` + +Locale is part of the key for per-locale-cached endpoints (every `*-status` route). Switching language invalidates only the matching locale variant; existing English text stays warm. + +--- + +## 3. The Envelope Contract + +Every API route in the project wraps its payload: + +```json +{ + "data": { … the actual thing … }, + "error": null, + "meta": { "requestId": "…", "version": "1.4.25" } +} +``` + +On error: + +```json +{ + "data": null, + "error": { "code": "RATE_LIMITED", "message": "…", "details": {…} }, + "meta": { … } +} +``` + +### Web side + +```ts +// from src/app/page.tsx:201-209 +const { data } = useQuery({ + queryKey: queryKeys.analytics(), + queryFn: async () => { + const res = await fetch("/api/analytics"); + if (!res.ok) throw new Error("Failed"); + const json = await res.json(); + return json.data as AnalyticsData; // ← single-step unwrap + }, + enabled: isAuthenticated, +}); +``` + +### iOS side — exact mirror + +```swift +func fetchEnvelope( + _ url: URL, as type: T.Type +) async throws -> T { + let (data, response) = try await session.data(from: url) + guard let http = response as? HTTPURLResponse, http.statusCode < 400 else { + throw APIError.from(data: data, status: (response as? HTTPURLResponse)?.statusCode) + } + let envelope = try JSONDecoder.healthLog.decode(APIEnvelope.self, from: data) + return envelope.data +} +``` + +No iOS call site should ever decode the envelope manually. Centralising it in `fetchEnvelope` makes "what does `data` mean" unambiguous and gives one place to bolt on `requestId` logging or `meta.version` mismatch warnings. + +--- + +## 4. Cache Invalidation Pattern + +> A mutation invalidates every read query that touches the resource. + +The web app gets this wrong if a mutation only invalidates its own list. The dashboard depends on the same data through `analytics()`, so deleting a measurement must invalidate *both* `["measurements"]` and `["analytics"]`. + +### Web side example + +```ts +// conceptual — pattern shared across mutation hooks +const deleteMeasurement = useMutation({ + mutationFn: (id: string) => fetch(`/api/measurements/${id}`, { method: "DELETE" }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.measurements() }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.moodAnalytics() }); + queryClient.invalidateQueries({ queryKey: queryKeys.insightsComprehensive() }); + }, +}); +``` + +### Invalidation matrix + +| Mutation | Invalidates | +|----------|-------------| +| `POST/PUT/DELETE /api/measurements` | `measurements()`, `analytics()`, `insightsRoot()` (prefix) | +| `POST/PUT/DELETE /api/mood-entries` | `moodEntries()`, `moodAnalytics()`, `analytics()`, `insightsRoot()` | +| `POST/PUT/DELETE /api/medications` | `medications()`, `medicationDetail(id)`, `medicationIntakeSummary()`, `dashboardGlp1()`, `analytics()` | +| `POST /api/medications/:id/intake` | same + `medicationComplianceChart(id)` | +| `POST /api/insights/generate` | `insightsAdvisor()` (shared with dashboard preview) | +| `PUT /api/auth/me/source-priority` | `sourcePriority()`, `analytics()` (cumulative metrics rerun) | +| `PUT /api/auth/me/coach-prefs` | `["coach", "prefs"]`, every conversation list query | + +### iOS mirror + +```swift +final class CacheInvalidator { + private let store: NetworkCache + func onMutation(_ kind: MutationKind) { + switch kind { + case .measurementChange: + store.invalidate([.measurements, .analytics, .insightsRoot]) + case .moodEntryChange: + store.invalidate([.moodEntries, .moodAnalytics, .analytics, .insightsRoot]) + case .medicationChange(let id): + store.invalidate([ + .medications, .medicationDetail(id), + .medicationIntakeSummary, .dashboardGlp1, .analytics + ]) + // … + } + } +} +``` + +The web invalidation rule is "prefix match"; TanStack Query treats `["insights"]` as a prefix and invalidates every key starting with it (`["insights", "comprehensive"]`, `["insights", "blood-pressure-status", "en"]`, etc). iOS should expose the same semantics — typically by storing keys in a nested dictionary and walking subtrees. + +--- + +## 5. Optimistic Updates + +The Coach conversation list uses optimistic deletion to keep the rail responsive. Pattern: + +```ts +// from src/components/insights/coach-panel/use-coach.ts:135-170 (Delete hook) +useMutation({ + mutationFn: async (id) => fetch(`/api/insights/chat/${id}`, { method: "DELETE" }), + onMutate: async (id) => { + await queryClient.cancelQueries({ queryKey: QUERY_KEYS.list() }); + const previous = queryClient.getQueryData(QUERY_KEYS.list()); + if (previous) { + queryClient.setQueryData(QUERY_KEYS.list(), { + ...previous, + conversations: previous.conversations.filter((c) => c.id !== id), + }); + } + return { previous }; + }, + onError: (_e, _id, ctx) => { + if (ctx?.previous) queryClient.setQueryData(QUERY_KEYS.list(), ctx.previous); + }, + onSettled: () => queryClient.invalidateQueries({ queryKey: QUERY_KEYS.list() }), +}); +``` + +Three-phase contract: `onMutate` (paint optimistic), `onError` (roll back), `onSettled` (re-fetch ground truth). + +iOS port — adopt the same three-phase model with `async let` cancellation tokens. The optimistic state lives in a `@Published` view-model property; on rollback, replace it with the captured snapshot. + +The Coach drawer also surfaces an **optimistic user-message bubble** so the user sees their message instantly while the server is still streaming the assistant reply. See `CoachOptimisticUserMessage` at `src/components/insights/coach-panel/use-coach.ts:230-260`. iOS must replicate this — the SSE roundtrip latency on a cellular network makes the "where did my message go" feeling otherwise unavoidable. + +--- + +## 6. The `useUser` / `useAuth` Pattern + +```ts +// from src/hooks/use-auth.ts:1-44 +export function useAuth() { + const query = useQuery({ + queryKey: ["auth", "me"], + queryFn: fetchMe, // returns AuthUser + retry: false, // 401 → fail-fast → redirect to login + staleTime: 5 * 60 * 1000, // 5 min — re-fetches on focus past this + }); + return { + user: query.data ?? null, + isLoading: query.isLoading, + isAuthenticated: !!query.data, + error: query.error, + refetch: query.refetch, + }; +} +``` + +Important: `["auth", "me"]` is hand-keyed (not via `queryKeys.auth()`) for historical reasons. Both forms route to the same cache slot because the array shape is identical. **Do not "fix" this on the web side**; the test suite asserts the literal. + +iOS counterpart: + +```swift +@MainActor +final class AuthStore: ObservableObject { + @Published private(set) var user: AuthUser? + @Published private(set) var isLoading = true + + var isAuthenticated: Bool { user != nil } + + func refresh() async { + do { + self.user = try await api.fetch(.me, as: AuthUser.self) + } catch APIError.unauthorized { + self.user = nil + await router.navigate(to: .login) + } catch { /* keep last-known */ } + self.isLoading = false + } +} +``` + +The 401 → logout flow is built-in: any 401 from the iOS API client clears the auth store and routes to login. + +--- + +## 7. The Shared `useInsightStatus` Hook (extracted-shared pattern) + +Until v1.4.18 the six insights sub-pages each had a copy-pasted 13-line `useQuery` block. W18 extracted them into a single hook: + +```ts +// from src/hooks/use-insight-status.ts:60-78 +export function useInsightStatus(metric: InsightStatusMetric) { + const { isAuthenticated } = useAuth(); + const { locale } = useTranslations(); + return useQuery({ + queryKey: QUERY_KEY_FACTORY[metric](locale), + queryFn: async (): Promise => { + const res = await fetch(`/api/insights/${metric}-status?locale=${locale}`); + if (!res.ok) throw new Error("Failed"); + const json = (await res.json()) as { data: InsightStatusData }; + return json.data; + }, + enabled: isAuthenticated, + staleTime: 60 * 1000, + }); +} +``` + +Pattern matters more than the code: when ≥ 3 call sites copy-paste the same shape, extract. (See `18-pattern-cookbook.md` recipe "Extract a shared helper".) iOS adopts the same rule — when three view models hit the same endpoint with locale variance, the third one extracts a `loadInsightStatus(_ metric:)` helper. + +--- + +## 8. Coach Streaming — SSE + +The Coach is the only **streaming** surface in the app. Everything else is request/response. + +### Wire format + +`POST /api/insights/chat` returns `text/event-stream; charset=utf-8`. Frames: + +``` +data: {"type":"token","token":"Hello"}\n\n +data: {"type":"token","token":" world"}\n\n +data: {"type":"provenance","sources":[…]}\n\n +data: {"type":"done","messageId":"abc","conversationId":"xyz"}\n\n +``` + +Or on error: + +``` +data: {"type":"error","code":"AllProvidersFailed","message":"…"}\n\n +``` + +### Web parser + +```ts +// from src/components/insights/coach-panel/use-coach.ts:170-205 +export function parseSseChunk(buffer: string, chunk: string) { + const combined = buffer + chunk; + const events: CoachStreamEvent[] = []; + let cursor = 0; + while (cursor < combined.length) { + const sep = combined.indexOf("\n\n", cursor); + if (sep === -1) break; + const frame = combined.slice(cursor, sep); + cursor = sep + 2; + const dataLine = frame.split("\n").map((l) => l.trim()).find((l) => l.startsWith("data:")); + if (!dataLine) continue; + const payload = dataLine.slice("data:".length).trim(); + if (!payload) continue; + try { + const parsed = JSON.parse(payload) as CoachStreamEvent; + if (parsed && "type" in parsed) events.push(parsed); + } catch { /* forward-compat: ignore unknown frames */ } + } + return { events, rest: combined.slice(cursor) }; +} +``` + +Note the **forward-compat** comment — unknown event types must be silently dropped so server-side `type` additions (e.g. `tool_call` in v1.5) don't break older clients. + +### iOS port + +```swift +func streamCoachReply( + body: CoachChatRequest, + locale: String, + onEvent: @escaping (CoachStreamEvent) -> Void +) async throws { + var request = URLRequest(url: api.url(.coachChat)) + request.httpMethod = "POST" + request.setValue("text/event-stream", forHTTPHeaderField: "Accept") + request.httpBody = try JSONEncoder().encode(body) + + let (bytes, _) = try await session.bytes(for: request) + var buffer = "" + for try await line in bytes.lines { + // accumulate, scan for `\n\n` separator, JSON-decode `data:` payload + // (same algorithm as parseSseChunk above) + } +} +``` + +Use `URLSession.bytes(for:)` (iOS 15+) — it gives an `AsyncSequence` that streams as bytes arrive. Wrap in a `TextDecoder` + the same frame-splitter as the web parser. + +> STOP HERE if you try to make iOS poll for chunks. SSE on iOS is supported natively; do not introduce long-polling as a "simpler" alternative — the latency floor doubles and the user-facing typing animation breaks. + +--- + +## 9. Stale-Time Policy + +| Surface | `staleTime` | Why | +|---------|-------------|-----| +| `/api/auth/me` | 5 min | Profile rarely changes mid-session | +| `/api/analytics` | 0 | Always re-fetch on focus; dashboard is the user's truth | +| `/api/insights/*-status` | 60 s | Provider round-trips are expensive | +| `/api/insights/chat` (list) | 30 s | New conversations show up quickly | +| `/api/insights/chat/:id` | 60 s | Past messages don't change | +| `/api/medications` (list) | 0 | User edits should reflect immediately | + +iOS: encode this in the `CacheKey.staleTime` per case, or as a constant policy map. + +--- + +## 10. Self-Test + +- [ ] Every iOS network call goes through a single `fetchEnvelope` helper. +- [ ] Every cache key is a typed enum case, not a string. +- [ ] A mutation calls `cacheInvalidator.onMutation(.foo)` and never invalidates by hand. +- [ ] Optimistic mutations follow `onMutate → onError → onSettled` three-phase. +- [ ] The Coach drawer uses `URLSession.bytes` SSE, not polling. +- [ ] 401 responses route through one global handler that clears auth state. +- [ ] An unknown SSE `type` is silently dropped, not thrown. diff --git a/.planning/v15-ios-handoff/19-i18n-system.md b/.planning/v15-ios-handoff/19-i18n-system.md new file mode 100644 index 000000000..0848ba5d4 --- /dev/null +++ b/.planning/v15-ios-handoff/19-i18n-system.md @@ -0,0 +1,343 @@ +--- +file: 19-i18n-system.md +purpose: How the 6-locale translator works, key naming conventions, maintainership policy, and the runtime parity probe — so iOS can mirror the same locale shape and contribute keys without breaking the gate. +when_to_read: Before adding any user-facing string to web or iOS, before adding a new key, when porting a screen that needs localisation. +prerequisites: 10-architecture.md +estimated_tokens: 3100 +version_anchor: v1.4.25 / sha 49f71c92 +--- + +# i18n System — 6 Locales, Flat-File JSON, Parity Probe + +## TL;DR + +Six locales: `de`, `en`, `fr`, `es`, `it`, `pl`. Flat-file JSON bundles at `messages/.json`, imported on both client (`I18nProvider`) and server (`getServerTranslator`). DE + EN are hand-curated; FR / ES / IT / PL are AI-initial translations marked by a maintainership banner. Runtime parity probe (Fix-G) catches missing keys before deploy. Adding a key: edit DE + EN, draft the four others, run the probe. + +--- + +## 1. Locale Inventory + +```ts +// from src/lib/i18n/config.ts:1-15 +export const locales = ["de", "en", "fr", "es", "it", "pl"] as const; +export type Locale = (typeof locales)[number]; +export const defaultLocale: Locale = "en"; + +export const localeLabels: Record = { + de: "Deutsch", + en: "English", + fr: "Français", + es: "Español", + it: "Italiano", + pl: "Polski", +}; + +// W9e — locales actively maintained by Marc. +export const MAINTAINED_LOCALES: ReadonlySet = new Set(["de", "en"]); +``` + +| Locale | Status | Source | +|--------|--------|--------| +| `de` | Hand-curated (Marc's native language) | Translator authoritative | +| `en` | Hand-curated | Translator authoritative | +| `fr` | AI-initial, drafted from EN | Banner surfaced | +| `es` | AI-initial, drafted from EN | Banner surfaced | +| `it` | AI-initial, drafted from EN | Banner surfaced | +| `pl` | AI-initial, drafted from EN | Banner surfaced | + +> Promoting a locale to "maintained": add it to `MAINTAINED_LOCALES`. The banner self-hides for any locale in that set. + +--- + +## 2. File Layout + +``` +messages/ +├── de.json +├── en.json +├── fr.json +├── es.json +├── it.json +└── pl.json +``` + +Flat-file, one JSON per locale, **nested namespaces** within each file: + +```json +{ + "common": { "save": "Save", "cancel": "Cancel", "add": "Add" }, + "dashboard": { "title": "Dashboard", "weightShort": "Weight" }, + "insights": { "title": "Insights", "loadError": "Failed to load insights" }, + "medications": { "back": "Back", "newIntake": "New intake" }, + … +} +``` + +### Deviation from spec + +> Since v1.4.25 W14b: the original spec called for **per-locale subdirectories** (`messages/de/common.json`, `messages/de/dashboard.json`, …). Marc kept the flat-file shape because Webpack tree-shaking already handles per-namespace bundling at the import level, and per-file edits across 6 locales create 6× more PR noise. Document this — iOS-Claude should not "fix" it. + +### Namespaces (top-level keys in each file) + +``` +common doctorReport errorBoundary nav auth dashboard +measurements mood medications charts chart comparison +insights thresholds onboarding gettingStarted trendHints +settings admin achievements bugreport notifications +i18n targets passwordStrength telegram medicationReminders +``` + +27 namespaces. Total keys per locale: **2 453** (parity-enforced — every locale has identical key set). + +--- + +## 3. Key Naming Convention + +``` +..[.] +``` + +Examples: + +| Key | Meaning | +|-----|---------| +| `common.save` | A "Save" button label, anywhere | +| `dashboard.weightShort` | Weight tile label on the dashboard tile strip | +| `dashboard.welcomeBackWithName` | Greeting with `{name}` parameter | +| `insights.loadError` | Error toast when comprehensive endpoint fails | +| `medications.intakeHistoryTitle` | Page title for `/medications/[id]/history` | +| `charts.colorGreen` | Hint legend label for green band | +| `trendHints.firstTrend` | "First trend after 5 readings" hint under charts | + +### Rules + +1. **lowerCamelCase** for keys; never `snake_case` or `kebab-case`. +2. **Shortest unambiguous form.** `dashboard.weightShort` because the dashboard tile is too narrow for "Weight in kilograms". +3. **Parameters in `{curlies}`** — `t("dashboard.welcomeBackWithName", { name: "Marc" })`. +4. **Re-use `common.*`** for verbs/nouns shared across surfaces (`common.save`, `common.cancel`, `common.add`). +5. **One key per visual phrase.** Don't compose sentences from sub-keys at runtime; translators need full context. + +--- + +## 4. Client Translator + +```tsx +// from src/lib/i18n/context.tsx (selected) +const I18nContext = createContext(null); + +function detectSystemLocale(): Locale { + if (typeof window === "undefined") return defaultLocale; + const browserLang = navigator.language.split("-")[0]; + return (locales as readonly string[]).includes(browserLang) + ? (browserLang as Locale) + : defaultLocale; +} + +// useTranslations() returns { t, locale, setLocale } +``` + +Usage: + +```tsx +const { t, locale } = useTranslations(); +

{t("dashboard.title")}

+

{t("dashboard.welcomeBackWithName", { greeting: "Good morning", name: "Marc" })}

+``` + +Lookup order: + +1. Cookie `healthlog-locale` (SSR-friendly so first paint matches) +2. localStorage `healthlog-locale` (client-side fallback) +3. `navigator.language` (system locale) +4. `defaultLocale` = `"en"` + +Cookie wins to avoid a hydration flash. + +--- + +## 5. Server Translator + +```ts +// from src/lib/i18n/server-translator.ts:1-50 +export function getServerTranslator(locale: Locale): ServerTranslator { + return { + locale, + t(key, params) { + let value = resolveKey(allMessages[locale], key); + if (value === undefined && locale !== defaultLocale) { + value = resolveKey(allMessages[defaultLocale], key); // EN fallback + } + if (value === undefined) return key; // raw key fallback + if (params) { + for (const [k, v] of Object.entries(params)) { + value = value.replace(new RegExp(`\\{${k}\\}`, "g"), String(v)); + } + } + return value; + }, + }; +} +``` + +Used by: + +- API routes (refusal copy, validation error messages) +- Background jobs (notification dispatch, doctor-report PDF) +- Email templates + +Locale resolution on the server uses `resolveServerLocale()` from `src/lib/i18n/server-locale.ts` — reads the cookie via Next.js `cookies()` API, falls back to the request `Accept-Language` header, then to `defaultLocale`. + +--- + +## 6. Fallback Chain + +``` + ┌──────────────────────────────┐ + │ Requested key │ + └─────────────┬────────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + yes │ Exists in user's locale? │ + ◄─────┤ (de/fr/es/it/pl) │ + └─────────────┬────────────────┘ + │ no + ▼ + ┌──────────────────────────────┐ + yes │ Exists in `en`? │ + ◄─────┤ │ + └─────────────┬────────────────┘ + │ no + ▼ + ┌──────────────────────────────┐ + │ Return the raw key │ + │ (developer noticed a typo) │ + └──────────────────────────────┘ +``` + +Returning the raw key on a miss is **deliberate** — production renders the unstyled string `dashboard.weightShort` which is immediately visible as a bug. Silent fallback to a related key would hide the typo. + +--- + +## 7. Maintainership Banner + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ⚠ This locale (Français) is AI-translated. Help us improve it: │ +│ open an issue at github.com/.../healthlog/issues. │ +└─────────────────────────────────────────────────────────────────┘ +``` + +Source: `` component, mounted in the auth shell, self-gates on `isMaintainedLocale(locale) === false`. + +iOS port: the same banner above the tab bar (or as a one-time banner on first switch into the locale) with a tap-target opening the GitHub issues URL via `SFSafariViewController`. + +--- + +## 8. Runtime Parity Probe (Fix-G) + +Since v1.4.25 Fix-G the project has a **runtime probe** that fails CI when locales drift apart on key set: + +```ts +// Conceptual contract — actual code in test suite +test("locale parity", () => { + const enKeys = flatten(en); + for (const locale of ["de", "fr", "es", "it", "pl"]) { + const localeKeys = flatten(require(`messages/${locale}.json`)); + const missing = enKeys.filter((k) => !localeKeys.includes(k)); + const extra = localeKeys.filter((k) => !enKeys.includes(k)); + expect(missing).toEqual([]); + expect(extra).toEqual([]); + } +}); +``` + +The probe fails CI if any locale is missing a key OR has an extra one. Fix-G hardened this so the probe runs as part of the unit test suite, not just the lint step — drift can't slip past a `--no-verify` push. + +The W15 cleanup deleted **380 dead keys** from all 6 locales; the deferred W15 second pass (~148 keys) lands in v1.4.26. + +--- + +## 9. Adding a Key — Recipe + +> Full recipe in `18-pattern-cookbook.md`. Summary: + +1. Open `messages/en.json` and `messages/de.json` side-by-side. Add the key in **both**. +2. For FR/ES/IT/PL, draft the translation from EN (DeepL → review by a native speaker if available). For Polish, prefer formal `Pan/Pani` register. +3. Run `pnpm test -- i18n-parity` (Fix-G probe) to confirm key sets match. +4. Reference the key in the component via `t("namespace.key")` — never inline `t("namespace.k" + variable)`; the probe can't trace dynamic keys. +5. Commit. The Marc-Voice CHANGELOG entry mentions only the user-visible behaviour, not "added 6 locale keys". + +### Anti-patterns + +| Anti-pattern | Why it breaks | +|--------------|---------------| +| `t(\`dashboard.${slug}\`)` | Probe can't statically verify the key exists | +| `t("foo.bar") + " " + t("foo.baz")` | Translators get fragments without context — German word order alone makes this wrong | +| Plural via concatenation | Use ICU MessageFormat plural rules (when needed) — currently only English-style "1 reading / N readings" via separate keys `dashboard.readingCountOne` / `dashboard.readingCountMany` | +| Hard-coded English next to `t()` calls | Either everything is i18n or nothing is — half-translated screens read worst | + +--- + +## 10. Umlaute & Encoding + +Marc's rule (memory: `feedback_umlaute_required`): "Nrnberg" is a bug. UTF-8 end-to-end through every encoding step: + +| Hop | Check | +|-----|-------| +| `messages/*.json` | Saved as UTF-8 without BOM | +| Bundler | Webpack inherits the source encoding | +| HTTP response | `Content-Type: application/json; charset=utf-8` | +| Database | Postgres `client_encoding = 'UTF8'` | +| Logs | Pino encodes JSON natively | +| Emails | `Content-Type: text/html; charset=utf-8` in headers | + +iOS: `JSONDecoder` and `URLSession` are UTF-8 by default. Don't override `String.Encoding.isoLatin1` anywhere. If you see "Mnchen" instead of "München", you have a `Data → String` step using a wrong encoding. + +--- + +## 11. iOS-Side Translator + +```swift +// Concept +struct Translator { + let locale: Locale + private let bundle: [String: Any] // loaded from messages/.json shipped with app + + func t(_ key: String, _ params: [String: CustomStringConvertible] = [:]) -> String { + var value = resolve(key, in: bundle) ?? resolve(key, in: enBundle) ?? key + for (k, v) in params { + value = value.replacingOccurrences(of: "{\(k)}", with: v.description) + } + return value + } + + private func resolve(_ key: String, in bundle: [String: Any]) -> String? { + let parts = key.split(separator: ".") + var current: Any = bundle + for part in parts { + guard let dict = current as? [String: Any], let next = dict[String(part)] else { + return nil + } + current = next + } + return current as? String + } +} +``` + +Ship the same six JSON bundles inside the app's main bundle. On launch, resolve locale via `Locale.current.languageCode` and pick the matching bundle. Re-use the EN fallback rule for missing keys. + +> STOP HERE if you consider Apple's `Localizable.strings`. The contract is to share the **same JSON bundles** the server reads so a key the server emits (e.g. a refusal message returned from the Coach API) renders identically to a key the iOS UI looks up locally. `Localizable.strings` would fork the source of truth. + +--- + +## 12. Self-Test + +- [ ] iOS locale switcher offers exactly the six locales above. +- [ ] EN fallback works (delete a key from `de.json` — UI falls back to EN). +- [ ] Raw-key fallback works (typo a key — UI shows the typo'd key, not crash). +- [ ] Maintainership banner visible in FR, hidden in DE/EN. +- [ ] Adding a new key without running the parity probe fails CI. +- [ ] All 2 453 keys are present in every locale (run `python3 -c "import json; …count…"` per `13-state-management.md`). +- [ ] German umlauts render correctly on every screen.