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>
This commit is contained in:
210
docs/history/fase-20-euskera-locale.md
Normal file
210
docs/history/fase-20-euskera-locale.md
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user