mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
docs(ios-handoff): C-UI&State design system + state mgmt + i18n
This commit is contained in:
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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user