Files
collective-lists/docs/history/fase-20-euskera-locale.md
Oier Bravo Urtasun d3b4187119 docs(fase-20): history doc + CHANGELOG [Unreleased] entry
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 <noreply@anthropic.com>
2026-05-19 04:16:19 +02:00

10 KiB

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.jsonlanguageTags 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.tsAppLanguage 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.sqlBorja 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)