docs(ios-handoff): C-UI&State design system + state mgmt + i18n

This commit is contained in:
Marc-André Bombeck committed 2026-05-14 23:28:12 +02:00
1 parent 0a9257357b
commit 6153be04cf
3 files changed
+1129

No files matched your search

@@ -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 `<html>` | `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: `<EmptyState>`. 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: `<TrendCard>` 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 `<MedicationDetailSection>` 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 `<ResponsiveContainer>` 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 |
@@ -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<T>` 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<T: Decodable>: 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<T: Decodable>(
_ 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<T>.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<CoachConversationsPage>(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<InsightStatusData> => {
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<UInt8>` 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<T>` 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.
+343
View File
@@ -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/<locale>.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<Locale, string> = {
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<Locale> = 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
```
<namespace>.<surface>.<role>[.<state>]
```
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<I18nContextValue | null>(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();
<h1>{t("dashboard.title")}</h1>
<p>{t("dashboard.welcomeBackWithName", { greeting: "Good morning", name: "Marc" })}</p>
```
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: `<MaintainershipBanner>` 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/<locale>.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.