From d3b41871190d6f80aa0312f6460924b2434dbd61 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Tue, 19 May 2026 04:16:19 +0200 Subject: [PATCH] docs(fase-20): history doc + CHANGELOG [Unreleased] entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit History doc covers: ENUM-vs-CHECK rationale (plan was written against the wrong primitive — actual gate is the language_code ENUM from migration 001), machine-translation seed + [needs review] marker, ICU-placeholder parity verification, locale-keys diff script contract, and the ParaglideJS {#key lang}-on-navigation cross-cutting gap that constrained the shape of EU-01. Test deltas: pgTAP +5, integration +2, unit +4, e2e +2 — 581 + 3 skipped (was 568 + 3). Documents the pre-existing 010_sync_conflicts flake (offline.test.ts e2e contamination) as out of scope for Fase 20. CHANGELOG gains the [Unreleased] entry. Moves into [v1.0.0] in the release commit. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + docs/history/fase-20-euskera-locale.md | 210 +++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 docs/history/fase-20-euskera-locale.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 17049b9..bb35b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ labels for the area, not log entries. - [new] Required list title + auto-numbered prefix + admin-curated title catalog (Fase 18). - [new] Emoji picker expanded with full food + animals categories (Fase 19). +- [new] Euskera (Basque) locale (Fase 20). Machine-translated seed pending native review. ## [v0.0.0-beta] — 2026-05-19 — MVP + MVP2 (Fases 0–16) diff --git a/docs/history/fase-20-euskera-locale.md b/docs/history/fase-20-euskera-locale.md new file mode 100644 index 0000000..43381b7 --- /dev/null +++ b/docs/history/fase-20-euskera-locale.md @@ -0,0 +1,210 @@ +# Fase 20 — Euskera (Basque, ISO 639-1 `eu`) locale + +**v1.0 · 4/4. Final pass of the v1.0 cycle.** + +**Status: ✅ Shipped 2026-05-19.** + +Adds Euskera as the third supported app language alongside `en` and +`es`. After this fase the app renders in Basque for any user who picks +it from `/settings` or whose browser advertises `eu` as a preferred +locale on first sign-in. The seed translation is machine-quality; a +post-release human review pass strips the `[needs review]` marker. + +Plan: `plan/fase-20-euskera-locale.md`. Migration: +`supabase/migrations/028_users_language_eu.sql`. pgTAP: +`supabase/tests/020_users_language_eu.sql`. + +## What changed + +### 20.1 — Data model (migration 028 + pgTAP 020) + +The plan was written against a CHECK-constraint mental model +(`language in ('en','es')`). The actual gate, since migration 001, is +the `public.language_code` ENUM — so the right primitive is +`ALTER TYPE … ADD VALUE 'eu'`, not a constraint swap. + +- Migration 028 widens the ENUM. `IF NOT EXISTS` makes it idempotent. + The statement cannot run inside a transaction block; the migration + runner handles each file atomically per-file, so it stands alone + (with no `BEGIN/COMMIT` wrapper, by design). +- End-user contract is identical to a CHECK swap: `'eu'` accepted, + `'fr'` rejected. The rejection surface is the cleaner Postgres + `22P02 invalid_text_representation` (PostgREST passes it through as + a structured 400 error) instead of the `23514 check_violation` a + CHECK constraint would have produced. +- pgTAP `020_users_language_eu.sql` (5 assertions): ENUM membership + (`'eu' = ANY (enum_range(...))`), full-set equality (`{en, es, eu}`), + UPDATE round-trip on the seed `Ana` row, and a SAVEPOINT-isolated + negative case asserting `22P02` on `language = 'fr'`. The post-test + ROLLBACK keeps Ana's seed `language='es'` intact. + +The hand-curated `packages/types/src/database.ts` widened to +`'en' | 'es' | 'eu'` because the `supabase` CLI isn't installed on the +deploy host — the Justfile's `db-types` recipe has a documented manual- +edit fallback. + +### 20.2 — eu.json seed + locale parity check + +- `apps/web/messages/eu.json` carries 362 keys (matches en.json / + es.json — `$schema` is excluded from the count). Every value is + hand-translated from the Spanish source-of-truth and suffixed with + ` [needs review]` per plan §20.2.1. The marker is the explicit handle + for the post-release human review pass — a native speaker walks each + key, fixes any wrong translation, and strips the suffix. +- ICU placeholders preserved verbatim — none move position relative to + the source value. Verified at write-time and again with an inline + Node script that compared the sorted placeholder set per key + (`{name}`, `{count}`, `{n}`, `{version}`, `{date}`, `{commit}`, + `{word}`, `{title}`, `{days}`, `{lists}`, `{tasks}`, `{notes}`, + `{section}`) — zero drift. +- Basque is SOV; some glosses re-order naturally (e.g. + `tasks_completed_by`: `"{name}-(e)k osatua"` puts the actor before + the action, which is the natural Basque cleft). Where a literal + Spanish word-order would have been awkward, the translation favored + a Basque-natural construction over a calque. The review pass should + re-judge these line-by-line — that's exactly what the suffix marker + is for. +- `scripts/check-locales.mjs` is a dependency-free Node script that + diffs every non-source bundle's key set against `en.json` and fails + on any missing/extra keys. Stand-alone so it works at any tooling + level (CI, pre-commit hook, manual sanity check). Output today: + `✓ es: 362 keys match en` + `✓ eu: 362 keys match en`. + +### 20.3 — Wiring + +- `apps/web/project.inlang/settings.json` — `languageTags` widened to + `["en", "es", "eu"]`. The Paraglide Vite plugin (run on every dev / + build) re-emits `src/lib/paraglide/runtime.js` with + `availableLanguageTags = ["en","es","eu"]` and the per-locale + `messages/eu.js` module. Verified by manual `paraglide-js compile` + invocation; gitignored as before. +- `apps/web/src/lib/utils/accept-language.ts` — `AppLanguage` union + widened to `'en' | 'es' | 'eu'`. The parser gains an `eu` arm on the + same q ≥ 0.5 gate the existing `es` arm uses. `en` continues to + short-circuit unconditionally because it's the source language and + never less-correct than the user's first choice. +- `apps/web/src/routes/(app)/settings/+page.svelte` — language tray + gets a third button labelled `Euskera`. Each button now carries a + stable `data-testid="settings-language-{code}"` for the e2e hook. + The local `language` state union widened to match `LanguageCode`. + +### 20.4 — Test coverage + +- **Vitest unit** (`apps/web/src/lib/utils/accept-language.test.ts`): + LANG-U-EU-01 bare `eu`, LANG-U-EU-02 `eu-ES`, LANG-U-EU-03 + `eu-FR;q=0.5,en;q=0.4` (borderline q-score still wins), LANG-U-EU-04 + navigator.languages array form. 4 new specs. +- **Vitest integration** + (`packages/test-utils/tests/language-bootstrap.test.ts`): + LANG-INT-EU-01 user UPDATEs own `language='eu'` successfully, + LANG-INT-EU-02 user attempts `language='fr'` and gets the Postgres + `22P02` from the ENUM (cast `as unknown as` to escape the narrow + TypeScript type — the gate lives in Postgres, not TS). +- **Playwright** (`apps/web/tests/e2e/euskera.test.ts`): + - EU-01 — Ana clicks the eu button on `/settings`. We assert on the + button's selected-state class flip (`bg-slate-900`) and a DB poll + for `users.language='eu'`. We deliberately do **not** assert on + the sidebar nav re-rendering: ParaglideJS's `{#key lang}` re-render + is wired to URL routing, not the runtime `setLanguageTag` call, so + visible labels only flip on the next navigation. That's a known + cross-cutting limitation outside Fase 20's scope. + - EU-02 — Fresh browser context with `locale: 'eu-ES'` + + `extraHTTPHeaders: { Accept-Language: 'eu-ES,eu;q=0.9,en;q=0.5' }`. + Eva logs in fresh; the Fase 10.8 bootstrap path UPDATEs + `users.language` to `'eu'`, and `/onboarding` renders the Basque + heading `Ongi etorri Colectivo-ra [needs review]`. Cleanup + restores Ana to seed `language='es'`. + +### 20.5 — Review checklist (post-release) + +The plan calls for a post-translation review by a Euskera-speaking +person before / after v1.0.0 ships: + +- Walk `apps/web/messages/eu.json`, fix any wrong translation, strip + the trailing ` [needs review]` suffix from each value that passes + review. +- Re-judge SOV-word-order on the 13 keys with ICU placeholders — + Basque's free word order means the machine seed sometimes produced + natural-but-stilted constructions where a different placement reads + better. +- Domain terms — agree the Basque rendering of `colectivo`, `lista`, + `tarea`, `nota`, `compra` with the client. The current seed is a + literal calque, not a domain-anchored term. + +The review is post-release and intentionally not a gate on Fase 20. + +## Test counts (final, after Fase 20) + +- **pgTAP**: 196 (was 191 → +5) +- **Vitest integration**: 187 (was 185 → +2) + 3 skipped (2 Realtime + presence; 1 rate-limit gated) +- **Vitest unit**: 89 (was 85 → +4) +- **Playwright**: 108 (was 106 → +2) +- **Gated rate-limit**: 1 (unchanged) + +Total: **581 + 3 skipped** (was 568 + 3). Net +9 unit / +5 pgTAP / ++2 integration / +2 e2e. + +Note: an existing flake in `supabase/tests/010_sync_conflicts.sql` — +`Borja sees only the row attributed to them` — fails when +`offline.test.ts` runs first in the same DB session and leaks +`sync_conflicts` rows for Borja. The test passes against a clean DB +(`DELETE FROM sync_conflicts;` between runs). Not caused by Fase 20; +not fixed here. The 196 pgTAP count is the all-pass total. + +## Decisions worth remembering + +- **ENUM over CHECK.** The plan's CHECK-swap was a category error + against the actual data model. ENUM ADD VALUE is the right primitive + here: idempotent, smaller diff, no downstream casts to refactor, + cleaner error surface. The CHECK approach would have required + rebuilding the column type if you ever wanted to remove a label. + Either way `users.language` stays the same shape. +- **Machine seed + explicit marker over partial hand-translation.** + Plan §20.2.1 option B. The `[needs review]` suffix is visible during + QA — anyone reviewing the app surface gets a constant reminder that + the strings are not native-reviewed yet. Removing the suffix is a + trivial sed / find-replace per key when the review lands. +- **Locale parity check is dependency-free Node, not part of any + bundler step.** A 60-line script is cheaper than introducing + another devDep, and runs in any environment that has node. The + check is the gate, not a pre-commit hook by default — the team + runs it manually before opening locale-touching PRs. +- **No URL-prefixed locale strategy (e.g. `/eu/lists`).** Out of + scope for Fase 20 and would have required cross-cutting changes to + every link in the app. The current strategy (per-user persistence + via `users.language`) is intentional. + +## Out of scope (deliberately) + +- Galego, Català, Português, Francés — single-locale release. +- Pluralization beyond simple {n}-substitution. Basque pluralizes + simply; the existing `pwa_pending_ops` / `pwa_pending_ops_plural` + pair is the only place we exercise plural variants and both Basque + forms read the same. +- Keycloak login-screen localisation — that's the external Keycloak + operator's concern (Keycloak ships a Basque resource bundle out of + the box; enabling it is a realm setting). +- Localised CHANGELOG. English only. +- Emoji codepoint names. The aria-label on each `EmojiPicker` cell is + the CLDR English name; localising those would expand the catalog + payload by ~2.5x for negligible benefit. +- Fixing the `setLanguageTag → ParaglideJS re-render` mismatch. The + visible label flip on language-switch-without-navigation is a known + upstream limitation in `@inlang/paraglide-sveltekit` 0.16.x; the + user's choice persists across navigations, which is the contract + the existing es/en path has shipped under for months. + +## Files indexed + +- `supabase/migrations/028_users_language_eu.sql` +- `supabase/tests/020_users_language_eu.sql` +- `packages/types/src/database.ts` (LanguageCode widened) +- `apps/web/messages/eu.json` (new) +- `apps/web/project.inlang/settings.json` (eu added) +- `apps/web/src/lib/utils/accept-language.ts` +- `apps/web/src/lib/utils/accept-language.test.ts` +- `packages/test-utils/tests/language-bootstrap.test.ts` +- `apps/web/src/routes/(app)/settings/+page.svelte` (Euskera option) +- `apps/web/tests/e2e/euskera.test.ts` (new) +- `scripts/check-locales.mjs` (new)