diff --git a/.claude/rules/antipatterns.md b/.claude/rules/antipatterns.md index 1d6655ad..150b3901 100644 --- a/.claude/rules/antipatterns.md +++ b/.claude/rules/antipatterns.md @@ -11,7 +11,7 @@ ## Code antipatterns - **Direct imports from sibling module internals** — only through `index.ts`. Enforce with linter. -- **Files over 400 lines** — bad for AI context. Split into smaller units. +- **Files over 200 lines** — bad for AI context. Split into smaller units. ## UI antipatterns diff --git a/.github/workflows/deploy-qa.yml b/.github/workflows/deploy-qa.yml index ddfe49a9..074fe0f6 100644 --- a/.github/workflows/deploy-qa.yml +++ b/.github/workflows/deploy-qa.yml @@ -50,6 +50,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check i18n + run: pnpm i18n:check + - name: Lint run: pnpm lint diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bd39933c..f17ef882 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -46,6 +46,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Check i18n + run: pnpm i18n:check + - name: Build client run: pnpm --filter @clear-progress/client... build env: diff --git a/README.md b/README.md index 04a3ecb9..ac2c0cfa 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,12 @@ # Clear Progress -[![deploy](https://img.shields.io/github/actions/workflow/status/oinsio/clear-progress/deploy.yml?branch=main&label=deploy)](https://github.com/oinsio/clear-progress/actions/workflows/deploy.yml) [![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/License-PolyForm%20Noncommercial%201.0.0-purple)](LICENSE) [![en](https://img.shields.io/badge/lang-en-blue.svg)](README.md) [![ru](https://img.shields.io/badge/lang-ru-red.svg)](README.ru.md) +[![deploy](https://img.shields.io/github/actions/workflow/status/oinsio/clear-progress/deploy.yml?branch=main&label=deploy)](https://github.com/oinsio/clear-progress/actions/workflows/deploy.yml) + Personal task, goal, and idea manager. Built for GTD and Jedi Empty Inbox techniques. Client-first PWA that works offline — your data stays on your device, with optional sync via your own Supabase server. **[Open the App](https://oinsio.github.io/clear-progress/)** diff --git a/README.ru.md b/README.ru.md index bec57f67..76590dc5 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,11 +1,12 @@ # Clear Progress -[![deploy](https://img.shields.io/github/actions/workflow/status/oinsio/clear-progress/deploy.yml?branch=main&label=deploy)](https://github.com/oinsio/clear-progress/actions/workflows/deploy.yml) [![License: PolyForm Noncommercial 1.0.0](https://img.shields.io/badge/License-PolyForm%20Noncommercial%201.0.0-purple)](LICENSE) [![en](https://img.shields.io/badge/lang-en-blue.svg)](README.md) [![ru](https://img.shields.io/badge/lang-ru-red.svg)](README.ru.md) +[![deploy](https://img.shields.io/github/actions/workflow/status/oinsio/clear-progress/deploy.yml?branch=main&label=deploy)](https://github.com/oinsio/clear-progress/actions/workflows/deploy.yml) + Персональное приложение для управления задачами, целями и идеями. Создано для GTD и Джедайских техник пустого инбокса. Client-first PWA, работающее офлайн — данные хранятся на устройстве, с опциональной синхронизацией через собственный сервер Supabase. **[Открыть приложение](https://oinsio.github.io/clear-progress/)** diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/.openspec.yaml new file mode 100644 index 00000000..8803b473 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/design.md b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/design.md new file mode 100644 index 00000000..3f6e6baa --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/design.md @@ -0,0 +1,87 @@ +## Context + +The project uses i18next with JSON locale files (`src/locales/{en,ru,house}.json`). Keys are referenced in source code via `t("key")` calls, dynamic template literals (`` t(`prefix.${var}`) ``), and indirect `messageKey` patterns. There is no tooling to detect drift between locale files and code usage. + +Project-specific concerns: +- `_meta.*` block in each locale is service metadata (read by `localeRegistry.ts`), not a translation key +- `house.json` is a partial override of `ru` (incompleteness is normal; only orphan keys are errors) +- Plural suffixes (`_one|_two|_few|_many|_other|_zero`) and ordinal suffixes (`_ordinal_*`) differ legitimately between en/ru — comparisons must use normalized base keys +- Some keys are constructed dynamically: `` t(`repeat.month${m}`) ``, `` t(`goal.status.${status}`) ``, `messageKey` in `healingRules.ts` + +## Goals / Non-Goals + +**Goals:** +- Statically validate all classes of i18n inconsistency (FR1–FR10) +- Integrate into existing test suite without new runtime dependencies +- Keep maintenance burden minimal (only `whitelist.ts` requires manual updates) + +**Non-Goals:** +- Runtime checking or type generation (NG1, NG3) +- Auto-removal of dead keys (NG2 — separate change) + +## Decisions + +### D1: Script location — `packages/client/scripts/i18n-check/` + +**Rationale**: The script operates exclusively on client package files. Placing it inside `packages/client` keeps paths simple and avoids cross-package imports. The `scripts/` directory separates tooling from application source. + +**Alternatives considered**: Root-level `tools/` directory — rejected because the script reads `src/locales` and `src/**/*.{ts,tsx}` relative to the client package. + +### D2: Module structure — 8 files with `index.ts` facade + +``` +scripts/i18n-check/ +├── index.ts # public facade (project invariant) +├── types.ts # shared types +├── flatten.ts # JSON flattening + plural normalization +├── scan.ts # source file scanning for key references +├── whitelist.ts # explicit patterns for undetectable dynamic keys +├── checks.ts # 4 check functions (undefined/unused/parity/orphans) +├── duplicates.ts # report-only duplicate detection +├── run.ts # orchestrator (no process.exit) +└── cli.ts # entrypoint with process.exit and CLI flags +``` + +**Rationale**: Separation of `run.ts` (pure logic) from `cli.ts` (side effects) enables Vitest to import check logic without triggering `process.exit`. Each file stays under 200 lines (NFR-M1). + +### D3: Key detection strategy — regex-based static scan + +Two regex patterns extract keys from source: +1. **Literal keys**: dot-separated identifiers in quotes — filtered by top-level namespace membership (from `en.json` root keys) to reject false matches like `"package.json"` or URLs +2. **Dynamic prefixes**: template literal prefix before `${...}` — matches keys where the remainder after prefix contains no dots (for `.`-terminated prefixes) or is digits-only (for non-`.` prefixes like `repeat.month`) + +**Rationale**: No AST parsing needed — regex is fast (<2s) and sufficient for the project's patterns. The namespace filter eliminates most false positives without maintaining a stop-list. + +**Trade-off**: String concatenation (`"repeat." + x`) is not detected. This is acceptable given the project convention of using template literals; edge cases go into the whitelist. + +### D4: Whitelist design — explicit `RegExp` patterns with self-validation + +Each whitelist entry is a `{ pattern: RegExp; reason: string }`. The script validates that every pattern matches at least one key in `en.json` — stale entries become errors automatically. + +**Rationale**: Patterns are more expressive than simple prefix lists (can express numeric ranges like `repeat.weekday1..7`). Self-validation prevents whitelist rot. + +### D5: Dynamic prefix matching — conservative rest-character check + +A dynamic prefix `P` matches key `K` only if: +- `P` ends with `.` AND the rest (`K` minus `P`) contains no dots, OR +- `P` does not end with `.` AND the rest is digits-only + +**Rationale**: Without this restriction, prefix `repeat.month` would falsely cover `repeat.monthAndDay` or `repeat.monthGenitive1`. The conservative check ensures only intended suffixes are matched. + +### D6: Execution — `tsx` runner via `pnpm i18n:check` + +The script uses TypeScript directly via `tsx` (already a dev dependency). No compilation step needed. + +**Alternative**: Run only via Vitest integration test — rejected because a standalone CLI command is useful for local debugging and CI readability. + +### D7: Test file separation — keys in test files reported differently + +Keys found exclusively in test files (`*.test.*`, `*.spec.*`, `__mocks__/`, `test/`) do not trigger `undefined` errors (test fixtures may use non-existent keys). However, they are flagged in the `unused` report with a "test-only" marker. + +**Rationale**: Prevents test helper strings (like `"task.name"` as entity field names) from blocking CI, while still surfacing potentially dead keys. + +## Risks / Trade-offs + +- **[Risk]** New dynamic patterns in future code may not be auto-detected → **Mitigation**: Developer guide documents the two-step process: (1) use template literal with dot prefix (auto-detected), (2) if impossible, add to `whitelist.ts` +- **[Risk]** False positives from non-i18n strings matching namespace filter (e.g., `"task.name"` as DB field) → **Mitigation**: Such strings are counted as "used", which only causes false negatives in `unused` (acceptable) — never false `undefined` errors +- **[Risk]** Script reads all source files synchronously — could slow down on very large codebases → **Mitigation**: Current codebase is ~150 files; synchronous I/O is simpler and fast enough (NFR-P1) diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/proposal.md b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/proposal.md new file mode 100644 index 00000000..54cca449 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/proposal.md @@ -0,0 +1,83 @@ +# add-i18n-check-script + +## Why + +Locale files (`src/locales/*.json`) and source code (`src/**/*.{ts,tsx}`) drift apart silently: keys get added to code but not to locale files (runtime crashes), keys accumulate in locale files after code removal (~90 unused keys today), and override locales (`house.json`) retain orphan keys for deleted base keys. There is no automated check — issues are discovered manually or by users. A static analysis script will catch all classes of i18n inconsistency on CI before merge. + +## What Changes + +- **ADDED**: Static i18n consistency check script (`packages/client/scripts/i18n-check/`) that validates locale files against source code usage +- **ADDED**: `pnpm i18n:check` command in `packages/client/package.json` +- **ADDED**: Vitest integration test that runs the check as part of the test suite + +## Capabilities + +### New Capabilities + +- `i18n-check`: Static analysis script that detects undefined keys, unused keys, locale parity violations, override orphans, and duplicate values across locale files + +### Modified Capabilities + +- `i18n`: Adds automated consistency enforcement to the existing i18n infrastructure + +## Goals + +- G1: Zero undefined/unused/parity/orphan errors pass CI undetected +- G2: Script runs in < 2s on the current codebase +- G3: False positive rate is zero for the current codebase (no suppressions needed for legitimate patterns) + +## Non-Goals + +- NG1: Type-safe `t()` calls via generated TypeScript declarations (future change) +- NG2: Auto-fixing detected issues (separate `i18n-cleanup` change) +- NG3: Runtime i18n validation or browser-side checks +- NG4: Checking interpolation parameter consistency between locales + +## Users & Scenarios + +- U1: Developer adds a new UI string — script catches missing locale key before merge +- U2: Developer removes a feature — script reports unused keys for cleanup +- U3: Developer adds keys to `en.json` but forgets `ru.json` — parity check fails +- U4: Developer edits `house.json` override with a key that no longer exists in `ru.json` — orphan check fails + +## Requirements + +### Functional + +- FR1: Detect keys used in production code (`src/**/*.{ts,tsx}`) that are absent from `en.json` (undefined check) +- FR2: Detect keys present in `en.json` that are not referenced by any source file, dynamic prefix, or whitelist pattern (unused check) +- FR3: Detect base-key set differences between `en.json` and `ru.json` after normalizing plural/ordinal suffixes (parity check) +- FR4: Detect keys in override locales (identified by `_meta.baseLanguage`) that do not exist in their base locale (override-orphans check) +- FR5: Report groups of keys with identical values in both `en` and `ru` as candidates for deduplication (always printed, non-blocking) +- FR6: Recognize i18next plural suffixes (`_one|_two|_few|_many|_other|_zero`) and ordinal suffixes (`_ordinal_*`) — compare base keys only, since en/ru have different plural forms +- FR7: Recognize dynamic key patterns from template literals (`` `prefix.${var}` ``) and exclude matched keys from unused report +- FR8: Support an explicit whitelist for keys that cannot be detected statically (numeric suffixes, `messageKey` indirection) — each whitelist entry must match at least one existing key (stale entries are errors) +- FR9: Exclude `_meta.*` keys from all checks (service metadata read by `localeRegistry.ts`) +- FR10: Keys found only in test files do not trigger `undefined` errors but are flagged in `unused` report + +### Non-Functional + +#### Performance + +- NFR-P1: Full check completes in under 2 seconds on the current codebase (~150 source files, 4 locale files) + +#### Maintainability + +- NFR-M1: Each script module file stays under 200 lines +- NFR-M2: Script is importable by Vitest without `process.exit` side effects (separation of logic and CLI) + +## UX Acceptance Criteria + +- UX1: On failure, output lists each error as `[kind] key — detail` with a summary count +- UX2: On success, output is a single line: `i18n-check: OK` +- UX3: Exit code is 1 on errors, 0 on success + +## Success Metrics + +- M1: Script detects all currently known issues (≥90 unused keys, ≥16 override orphans) when run against the unmodified codebase +- M2: After `i18n-cleanup` change removes dead keys, script reports 0 errors on CI +- M3: Mutation testing score ≥ 95% on flatten/scan/checks modules + +## Open Questions + +None — all resolved. diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n-check/spec.md b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n-check/spec.md new file mode 100644 index 00000000..52a65dd3 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n-check/spec.md @@ -0,0 +1,142 @@ +## ADDED Requirements + +### Requirement: Flatten locale JSON and normalize plural keys + +The system SHALL flatten nested locale JSON objects into dot-separated key-value maps. The system SHALL normalize i18next plural suffixes (`_one`, `_two`, `_few`, `_many`, `_other`, `_zero`) and ordinal suffixes (`_ordinal_one`, etc.) to produce base keys for comparison. + +#### Scenario: Nested JSON is flattened correctly +- **WHEN** a locale JSON contains `{ "task": { "cancel": "Cancel" } }` +- **THEN** the flat map contains key `"task.cancel"` with value `"Cancel"` + +#### Scenario: Plural suffix is stripped to produce base key +- **WHEN** a flat key is `"repeat.everyNDays_few"` +- **THEN** the base key is `"repeat.everyNDays"` + +#### Scenario: Ordinal suffix is stripped to produce base key +- **WHEN** a flat key is `"repeat.yearlyDate_ordinal_two"` +- **THEN** the base key is `"repeat.yearlyDate"` + +#### Scenario: Underscore in key name is preserved +- **WHEN** a flat key is `"filter.focused_goals"` +- **THEN** the base key is `"filter.focused_goals"` (unchanged, since `_goals` is not a CLDR plural form) + +### Requirement: Scan source files for literal keys and dynamic prefixes + +The system SHALL scan TypeScript/TSX source files and extract: +1. Literal keys: dot-separated identifiers in quotes/backticks that match a top-level namespace from `en.json` +2. Dynamic prefixes: template literal content before `${...}` that contains at least one dot and matches a top-level namespace + +The system SHALL classify keys by file type (production vs test) based on path patterns. + +#### Scenario: Literal key in production file is detected +- **WHEN** a production file contains `t("task.cancel")` +- **THEN** `"task.cancel"` appears in `literalKeys` and NOT in `literalKeysTestOnly` + +#### Scenario: Literal key only in test file is flagged as test-only +- **WHEN** a test file contains `"task.cancel"` and no production file references it +- **THEN** `"task.cancel"` appears in `literalKeysTestOnly` + +#### Scenario: Dynamic prefix is extracted from template literal +- **WHEN** a source file contains `` t(`goal.status.${status}`) `` +- **THEN** `"goal.status."` appears in `dynamicPrefixes` + +#### Scenario: Non-i18n dotted string is filtered out by namespace check +- **WHEN** a source file contains `"package.json"` and `"package"` is not a top-level namespace in `en.json` +- **THEN** `"package.json"` is NOT included in scan results + +### Requirement: Detect undefined keys (FR1) + +The system SHALL report an error of kind `undefined` for every literal key found in production source code that does not exist as a base key in `en.json` and is not covered by a whitelist pattern. + +#### Scenario: Key used in code but missing from en.json +- **WHEN** production code references `"sync.alert.repeat_rule_reset"` and this base key is absent from `en.json` +- **THEN** an error `{ kind: "undefined", key: "sync.alert.repeat_rule_reset" }` is reported + +#### Scenario: Key only in test file does not trigger undefined error +- **WHEN** `"fake.test.key"` appears only in a test file and is absent from `en.json` +- **THEN** no `undefined` error is reported for this key + +### Requirement: Detect unused keys (FR2) + +The system SHALL report an error of kind `unused` for every base key in `en.json` that is: +- Not found as a literal key in any source file +- Not matched by any dynamic prefix (with conservative rest-character validation) +- Not matched by any whitelist pattern + +#### Scenario: Key in en.json not referenced anywhere +- **WHEN** `en.json` contains key `"nav.oldFeature"` and no source file references it +- **THEN** an error `{ kind: "unused", key: "nav.oldFeature" }` is reported + +#### Scenario: Key matched by dynamic prefix is not reported unused +- **WHEN** `en.json` contains `"goal.status.in_progress"` and source has `` `goal.status.${status}` `` +- **THEN** no `unused` error is reported (prefix `"goal.status."` matches, rest `"in_progress"` has no dots) + +#### Scenario: Key with shared prefix but non-matching rest is still reported +- **WHEN** `en.json` contains `"repeat.monthAndDay"` and prefix `"repeat.month"` exists from `` `repeat.month${m}` `` +- **THEN** an `unused` error IS reported (rest `"AndDay"` is not digits-only) + +#### Scenario: Whitelisted key is not reported unused +- **WHEN** `en.json` contains `"repeat.weekday3"` and whitelist has a pattern matching `repeat.weekday1..7` +- **THEN** no `unused` error is reported + +### Requirement: Detect locale parity violations (FR3) + +The system SHALL report an error of kind `parity` for every base key that exists in `en.json` but not in `ru.json`, or vice versa. + +#### Scenario: Key in en missing from ru +- **WHEN** base key `"feature.new"` exists in `en.json` but not in `ru.json` +- **THEN** an error `{ kind: "parity", key: "feature.new", detail: "present in en, missing in ru" }` is reported + +#### Scenario: Key in ru missing from en +- **WHEN** base key `"legacy.removed"` exists in `ru.json` but not in `en.json` +- **THEN** an error `{ kind: "parity", key: "legacy.removed", detail: "present in ru, missing in en" }` is reported + +### Requirement: Detect override orphan keys (FR4) + +The system SHALL report an error of kind `override-orphans` for every base key in an override locale (where `_meta.baseLanguage` differs from locale code) that does not exist in its base locale, excluding `_meta.*` keys. + +#### Scenario: Override key absent from base locale +- **WHEN** `house.json` has `_meta.baseLanguage: "ru"` and contains key `"repeat.day1"` which is absent from `ru.json` +- **THEN** an error `{ kind: "override-orphans", key: "repeat.day1" }` is reported + +#### Scenario: _meta keys are excluded from orphan check +- **WHEN** `house.json` contains `"_meta.baseLanguage"` and `"_meta.name"` +- **THEN** no `override-orphans` error is reported for these keys + +### Requirement: Report duplicate values (FR5) + +The system SHALL always report groups of keys that have identical values in both `en.json` and `ru.json` as candidates for deduplication. This report is printed on every run (informational, does not cause a non-zero exit code). + +#### Scenario: Keys with same value in both locales are grouped +- **WHEN** `en.json` has `"task.cancel": "Cancel"` and `"goal.cancel": "Cancel"`, and `ru.json` has both as `"Отмена"` +- **THEN** a duplicate group `["task.cancel", "goal.cancel"]` is reported + +#### Scenario: Keys with same en value but different ru values are not grouped +- **WHEN** `en.json` has `"repeat.month4": "April"` and `"repeat.monthGenitive4": "April"`, but `ru.json` has `"апрель"` and `"апреля"` +- **THEN** no duplicate group is reported for these keys + +### Requirement: Whitelist self-validation (FR8) + +The system SHALL verify that every whitelist pattern matches at least one existing key in `en.json`. A pattern that matches zero keys SHALL be reported as an error. + +#### Scenario: Stale whitelist pattern triggers error +- **WHEN** a whitelist pattern `/^legacy\./` matches zero keys in `en.json` +- **THEN** an error is reported indicating the whitelist entry is stale + +### Requirement: CLI interface (UX1–UX3) + +The system SHALL provide a CLI entrypoint that: +- Exits with code 1 when any check error is found +- Exits with code 0 when no errors are found +- Prints each error as `[kind] key — detail` +- Prints summary count on failure +- Prints `i18n-check: OK` on success +- Always prints the duplicates report (informational section after errors) + +#### Scenario: Errors found — exit code 1 +- **WHEN** the check finds 3 errors +- **THEN** CLI prints 3 error lines, a summary `i18n-check: 3 errors`, and exits with code 1 + +#### Scenario: No errors — exit code 0 +- **WHEN** the check finds 0 errors +- **THEN** CLI prints `i18n-check: OK` and exits with code 0 diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n/spec.md b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n/spec.md new file mode 100644 index 00000000..76c55dfb --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/specs/i18n/spec.md @@ -0,0 +1,13 @@ +## MODIFIED Requirements + +### Requirement: Locale consistency is enforced automatically + +The i18n system SHALL include an automated consistency check that runs as part of the test suite (`pnpm test`) and detects undefined keys, unused keys, parity violations, and override orphans. The check SHALL be available as a standalone command (`pnpm i18n:check`). + +#### Scenario: CI catches locale drift +- **WHEN** a developer pushes code that references a key not present in `en.json` +- **THEN** the test suite fails with a descriptive error identifying the missing key + +#### Scenario: Standalone check command +- **WHEN** a developer runs `pnpm i18n:check` in the client package +- **THEN** the script validates locale consistency and reports results diff --git a/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/tasks.md b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/tasks.md new file mode 100644 index 00000000..3bc8d699 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-add-i18n-check-script/tasks.md @@ -0,0 +1,50 @@ +## 1. Module scaffold and types + +- [x] 1.1 Create directory `packages/client/scripts/i18n-check/` with `index.ts` and `types.ts` (FR1–FR10) +- [x] 1.2 Define types: `FlatMap`, `LocaleData`, `ScanResult`, `CheckKind`, `CheckError` (FR1–FR10) + +## 2. Flatten module (TDD) + +- [x] 2.1 Write failing unit tests for `flatten()`: nested objects, empty values, single-level (FR6) +- [x] 2.2 Write failing unit tests for `toBaseKey()`: plural suffixes, ordinal suffixes, underscore preservation (FR6) +- [x] 2.3 Implement `flatten.ts` — make all tests pass (FR6) + +## 3. Scan module (TDD) + +- [x] 3.1 Write failing unit tests for `scanSources()`: literal key detection, namespace filtering, dynamic prefix extraction, test-file classification (FR1, FR2, FR7, FR10) +- [x] 3.2 Implement `scan.ts` — make all tests pass (FR1, FR2, FR7, FR10) + +## 4. Whitelist module + +- [x] 4.1 Implement `whitelist.ts` with patterns for: `repeat.{daily,weekly,monthly,yearly}`, `repeat.weekday1..7`, `repeat.month1..12`, `repeat.monthGenitive1..12` (FR8, FR9) +- [x] 4.2 Write unit test: `isWhitelisted()` matches expected keys and rejects non-matching keys (FR8) + +## 5. Checks module (TDD) + +- [x] 5.1 Write failing unit tests for `checkUndefined()`: missing key triggers error, whitelisted key passes, test-only key passes (FR1, FR10) +- [x] 5.2 Write failing unit tests for `checkUnused()`: unreferenced key errors, dynamic-prefix match passes, whitelist match passes, `repeat.monthAndDay` NOT covered by `repeat.month` prefix (FR2, FR7, FR8) +- [x] 5.3 Write failing unit tests for `checkParity()`: missing in ru, missing in en (FR3) +- [x] 5.4 Write failing unit tests for `checkOverrideOrphans()`: orphan key errors, `_meta` excluded (FR4, FR9) +- [x] 5.5 Implement `checks.ts` — make all tests pass (FR1–FR4, FR7–FR10) + +## 6. Duplicates module (TDD) + +- [x] 6.1 Write failing unit tests for `findDuplicateGroups()`: same-value grouping, month/monthGenitive not grouped (FR5) +- [x] 6.2 Implement `duplicates.ts` — make all tests pass (FR5) + +## 7. Orchestrator and CLI + +- [x] 7.1 Implement `run.ts`: `loadLocale()`, `collectSourceFiles()`, `runAllChecks()` with whitelist self-validation (FR8, NFR-M2) +- [x] 7.2 Implement `cli.ts`: error formatting, exit codes, always print duplicates report (UX1–UX3, FR5) +- [x] 7.3 Add `"i18n:check": "npx tsx scripts/i18n-check/cli.ts"` to `packages/client/package.json` + +## 8. Integration test + +- [x] 8.1 Write Vitest integration test `src/test/i18n-check/i18n-check.project.test.ts` that runs `runAllChecks()` against real project files (FR1–FR4) +- [x] 8.2 Test skipped with TODO referencing `i18n-cleanup` change — codebase has 122 known issues (pre-cleanup) + +## 9. Verification + +- [x] 9.1 Run `pnpm run build` to verify no type/build errors (NFR-M2) +- [x] 9.2 Run scoped mutation testing on `flatten.ts`, `scan.ts`, `checks.ts` — target ≥95% score (M3) +- [x] 9.3 Run `pnpm i18n:check` standalone and verify output format matches UX1–UX3 diff --git a/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/.openspec.yaml new file mode 100644 index 00000000..8803b473 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/design.md b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/design.md new file mode 100644 index 00000000..83ac7a5b --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/design.md @@ -0,0 +1,77 @@ +## Context + +The `i18n-check` tool scans source files for translation key usage and compares against locale JSON files. Three gaps were found during review: + +1. **Umbrella prefixes**: `matchesDynamicPrefix` accepts any dotted prefix (e.g., `repeat.`) extracted from template literals like `` t(`repeat.${freq}`) ``. This hides all keys under that namespace, including dead ones. +2. **Unreachable isTestOnly branch**: In `checkUnused`, `literalKeysTestOnly` is a subset of `literalKeys`, so the `literalKeys.has(baseKey)` check fires first, making the test-only detection dead code. Additionally, fixture strings in the script's own tests (e.g., `"repeat.monthAndDay"`) are picked up by the scanner as real usages. +3. **Disabled integration test**: `i18n-check.project.test.ts` uses `it.skip` with a TODO that references work already completed. + +Current state: 7 dead keys survive in locale files undetected. + +## Goals / Non-Goals + +**Goals:** +- Fix prefix matching to catch dead keys in single-segment namespaces (FR1) +- Make test-only detection work correctly (FR4) +- Compensate with whitelist entries for legitimate enum keys (FR6) +- Remove dead keys and enable the integration gate (FR7-FR9) + +**Non-Goals:** +- Redesigning the whitelist DSL or scan architecture (NG1) +- Adding new check kinds beyond existing ones (NG2) + +## Decisions + +### D1: ">=2 named segments" rule for dotted prefixes + +**Decision**: A dotted prefix (ending with `.`) participates in auto-matching only if the part before the trailing dot contains at least one dot itself. `goal.status.` passes (2 segments: `goal`, `status`). `repeat.` fails (1 segment: `repeat`). + +**Alternatives considered**: +- (a) >=2 segments rule — **chosen**. Simple, deterministic, covers the gap. +- (b) Full whitelist-only approach — too much manual overhead for safe two-segment prefixes. +- (c) Heuristics (no literals in namespace → prefix valid) — fragile, rejected. + +**Rationale**: Single-segment namespaces are the most dangerous umbrellas because they cover an entire namespace. Two-segment prefixes are narrower and less likely to hide unrelated dead keys. Enum values for single-segment namespaces are small, bounded sets — explicit whitelist is appropriate. + +### D2: Fix isTestOnly by reordering conditions + +**Decision**: Change the condition order in `checkUnused`: +``` +if (scan.literalKeys.has(baseKey) && !scan.literalKeysTestOnly.has(baseKey)) continue; +``` +This allows the loop to proceed for test-only keys, reaching the `isTestOnly` reporting branch. + +**Rationale**: Minimal code change, preserves existing structure, makes the existing branch reachable. + +### D3: Synthetic namespace for test fixtures + +**Decision**: Rename fixture keys in `src/test/i18n-check/*.test.ts` from real namespaces (e.g., `repeat.monthAndDay`) to a synthetic `fx.` namespace (e.g., `fx.monthAndDay`). The scanner's namespace filter will exclude `fx.*` since it doesn't exist in `en.json`. + +**Rationale**: Prevents test fixtures from accidentally masking real dead keys. The `fx.` prefix is short, obviously synthetic, and will never appear in production locales. + +### D4: Whitelist entries for single-segment enum namespaces + +**Decision**: Add explicit `oneOf` whitelist entries for: +- `theme.` → `[light, dark, system]` +- `color.` → `[blue, coral, green, indigo, orange, purple, yellow]` +- `goalFilter.` → `[all, active, paused, finished]` + +The existing `repeat.` whitelist entry already covers `[daily, weekly, monthly, yearly]`. `box.*` keys are all used as literals in `constants/index.ts`, so no whitelist needed. + +**Rationale**: These are legitimate runtime-dynamic keys. The whitelist self-validation (FR8 in spec) ensures they stay fresh. + +### D5: Semantic fixture isolation instead of blanket namespace ban + +**Decision**: The fixture-isolation requirement forbids only fixtures that coincide (after `toBaseKey` normalization) with keys actually present in `en.json`. Fixtures under real namespaces that don't match any live key (e.g., `"repeat.frequency"` after it was deleted) are permitted. An automated test enforces this, with an explicit allow-list for fixtures that must use live keys to verify the real WHITELIST. + +**Alternatives considered**: +- (a) Blanket ban on real namespaces in all fixtures — too strict, forces renaming negative-test fixtures like `"repeat.frequency"` which are more readable with realistic names, and breaks whitelist verification tests. +- (b) Semantic isolation via `toBaseKey` collision check — **chosen**. Precisely targets the dangerous case (fixture masking a live key) while allowing harmless realistic fixtures. + +**Rationale**: The structural guarantee (after the `checkUnused` fix, test-only keys are reported as unused) means realistic-looking fixtures that don't match live keys cannot cause false negatives. The automated test prevents drift as new keys are added to `en.json`. + +## Risks / Trade-offs + +- **[Risk] False positives after tightening** — If undiscovered single-segment dynamic namespaces exist in code, they will trigger spurious `unused` errors. → **Mitigation**: Grep for `` `[a-zA-Z]+\.\$\{` `` patterns in `src/` before removing keys. The table of namespaces was verified on the branch. +- **[Risk] Fixture rename breaks tests** — Renaming fixture keys might break test assertions. → **Mitigation**: Update both the fixture strings AND the synthetic locale maps in the same commit; run tests immediately. +- **[Trade-off]** Whitelist grows slightly (3 new entries) — acceptable given they are self-validating and well-commented. diff --git a/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/proposal.md b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/proposal.md new file mode 100644 index 00000000..f5c7ddb3 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/proposal.md @@ -0,0 +1,79 @@ +# fix-i18n-check-dynamic-prefix-gaps + +## Why + +The i18n-check tool has three gaps discovered during code review of the `i18n-cleanup` branch: (1) single-segment dynamic prefixes like `repeat.` act as umbrellas that hide dead keys, (2) the `isTestOnly` branch in `checkUnused` is unreachable dead code allowing test fixtures to mask dead keys, and (3) the integration test is disabled despite the cleanup being complete. These gaps mean 7 dead locale keys remain undetected. + +## What Changes + +- **MODIFIED**: Dynamic prefix matching rule — dotted prefixes with only one named segment (e.g., `repeat.`) no longer protect keys from being reported as unused. Only prefixes with >= 2 named segments (e.g., `goal.status.`) participate in auto-matching. Single-segment namespaces must use explicit whitelist entries. +- **MODIFIED**: `checkUnused` logic — keys found ONLY in test files are now correctly reported as unused (with appropriate detail message), fixing unreachable `isTestOnly` branch. +- **ADDED**: Whitelist entries for `theme.*`, `color.*`, `goalFilter.*` enum values that were previously hidden by umbrella prefixes. +- **REMOVED**: 7 dead keys from `en.json`/`ru.json` (`repeat.frequency`, `repeat.interval`, `repeat.weeklyDays`, `repeat.monthAndDay`, `repeat.invalidRulePullAlertTitle`, `repeat.invalidRulePullAlertMessage`, `repeat.invalidRulePullAlertFix`) and 1 override from `house.json` (`repeat.weeklyDays`). +- **MODIFIED**: Test fixtures in `src/test/i18n-check/` — rename fixture keys that collide with real namespaces to use a synthetic `fx.` prefix. +- **ENABLED**: Integration test `i18n-check.project.test.ts` (remove `.skip`). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `i18n-check`: Dynamic prefix matching now requires >= 2 named segments for dotted prefixes; test-only key detection is functional; whitelist covers single-segment enum namespaces. + +## Goals + +- G1: Eliminate false negatives in unused key detection caused by umbrella dynamic prefixes +- G2: Make test-only key detection functional so test fixtures cannot mask dead keys +- G3: Enable the integration test as a Vitest gate + +## Non-Goals + +- NG1: Redesigning the whitelist format or dynamic prefix discovery mechanism +- NG2: Adding new check types to i18n-check +- NG3: Changing the CLI output format + +## Requirements + +### Functional + +- FR1: `matchesDynamicPrefix` SHALL reject dotted prefixes with only one named segment (no dot before trailing dot). Example: `repeat.` does NOT protect `repeat.frequency`. +- FR2: `matchesDynamicPrefix` SHALL accept dotted prefixes with >= 2 named segments. Example: `goal.status.` protects `goal.status.paused`. +- FR3: `matchesDynamicPrefix` SHALL continue to accept non-dotted prefixes with digit-only rest. Example: `repeat.month` protects `repeat.month7`. +- FR4: `checkUnused` SHALL report keys found ONLY in test files as `unused` with detail "found ONLY in tests — likely a dead key". +- FR5: Test fixture keys in `src/test/i18n-check/` SHALL NOT collide with real top-level namespaces from `en.json`. +- FR6: Whitelist SHALL contain explicit `oneOf` entries for `theme.`, `color.`, and `goalFilter.` enum values. +- FR7: Dead keys (`repeat.frequency`, `repeat.interval`, `repeat.weeklyDays`, `repeat.monthAndDay`, `repeat.invalidRulePullAlertTitle`, `repeat.invalidRulePullAlertMessage`, `repeat.invalidRulePullAlertFix`) SHALL be removed from `en.json` and `ru.json`. +- FR8: Override orphan `repeat.weeklyDays` SHALL be removed from `house.json`. +- FR9: Integration test `i18n-check.project.test.ts` SHALL run without `.skip`. + +### Non-Functional + +#### Performance + +- NFR-P1: No measurable performance regression in `pnpm i18n:check` execution time. + +## UX Acceptance Criteria + +- UX1: `pnpm i18n:check` exits with code 0 after all fixes are applied. +- UX2: Adding a synthetic dead key (e.g., `repeat.zzzDead`) to `en.json` causes `pnpm i18n:check` to report it as unused. + +## Success Metrics + +- M1: 0 errors from `pnpm i18n:check` on clean codebase +- M2: 7 dead keys removed from locale files +- M3: Integration test passes without `.skip` +- M4: Mutation score >= 95% on `checks.ts` changes (minimum >= 90%) + +## Impact + +- `packages/client/scripts/i18n-check/checks.ts` — core logic changes +- `packages/client/scripts/i18n-check/whitelist.ts` — new entries +- `packages/client/src/test/i18n-check/*.test.ts` — fixture renames +- `packages/client/src/test/i18n-check/i18n-check.project.test.ts` — enable test +- `packages/client/public/locales/en.json` — remove 7 keys +- `packages/client/public/locales/ru.json` — remove 7 keys +- `packages/client/public/locales/house.json` — remove 1 key +- `openspec/specs/i18n-check/spec.md` — delta spec updates diff --git a/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/specs/i18n-check/spec.md b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/specs/i18n-check/spec.md new file mode 100644 index 00000000..d24eb1fb --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/specs/i18n-check/spec.md @@ -0,0 +1,97 @@ +## MODIFIED Requirements + +### Requirement: Scan source files for literal keys and dynamic prefixes + +The system SHALL scan TypeScript/TSX source files and extract: +1. Literal keys: dot-separated identifiers in quotes/backticks that match a top-level namespace from `en.json` +2. Dynamic prefixes: template literal content before `${...}` that contains at least one dot and matches a top-level namespace + +The system SHALL classify keys by file type (production vs test) based on path patterns. + +The system SHALL NOT include keys from the i18n-check tool's own test fixtures in production scan results. Test fixtures SHALL use synthetic namespaces (e.g., `fx.`) that do not exist in `en.json`. + +#### Scenario: Literal key in production file is detected +- **WHEN** a production file contains `t("task.cancel")` +- **THEN** `"task.cancel"` appears in `literalKeys` and NOT in `literalKeysTestOnly` + +#### Scenario: Literal key only in test file is flagged as test-only +- **WHEN** a test file contains `"task.cancel"` and no production file references it +- **THEN** `"task.cancel"` appears in `literalKeysTestOnly` + +#### Scenario: Dynamic prefix is extracted from template literal +- **WHEN** a source file contains `` t(`goal.status.${status}`) `` +- **THEN** `"goal.status."` appears in `dynamicPrefixes` + +#### Scenario: Non-i18n dotted string is filtered out by namespace check +- **WHEN** a source file contains `"package.json"` and `"package"` is not a top-level namespace in `en.json` +- **THEN** `"package.json"` is NOT included in scan results + +#### Scenario: Test fixture with synthetic namespace is excluded +- **WHEN** a test file contains `"fx.monthAndDay"` and `"fx"` is not a top-level namespace in `en.json` +- **THEN** `"fx.monthAndDay"` is NOT included in scan results + +### Requirement: Detect unused keys (FR2) + +The system SHALL report an error of kind `unused` for every base key in `en.json` that is: +- Not found as a literal key in any production source file (keys found ONLY in test files do not count as "found") +- Not matched by any dynamic prefix (with conservative rest-character validation) +- Not matched by any whitelist pattern + +Dynamic prefix matching for dotted prefixes (ending with `.`) SHALL require the prefix to contain >= 2 named segments (i.e., at least one dot before the trailing dot). Single-segment dotted prefixes (e.g., `repeat.`) SHALL NOT protect keys from being reported as unused — their enum values must be covered by explicit whitelist entries instead. + +Non-dotted prefixes SHALL continue to match when the rest is digits-only (e.g., `repeat.month` protects `repeat.month7`). + +Keys found ONLY in test files SHALL be reported as unused with detail "found ONLY in tests — likely a dead key". + +#### Scenario: Key in en.json not referenced anywhere +- **WHEN** `en.json` contains key `"nav.oldFeature"` and no source file references it +- **THEN** an error `{ kind: "unused", key: "nav.oldFeature" }` is reported + +#### Scenario: Key matched by multi-segment dynamic prefix is not reported unused +- **WHEN** `en.json` contains `"goal.status.in_progress"` and source has `` `goal.status.${status}` `` +- **THEN** no `unused` error is reported (prefix `"goal.status."` has 2 segments, rest `"in_progress"` has no dots) + +#### Scenario: Single-segment dotted prefix does NOT protect keys +- **WHEN** `en.json` contains `"repeat.frequency"` and source has `` `repeat.${freq}` `` producing prefix `"repeat."` +- **THEN** an `unused` error IS reported for `"repeat.frequency"` (prefix has only 1 named segment) + +#### Scenario: Key with shared non-dotted prefix but non-digit rest is still reported +- **WHEN** `en.json` contains `"repeat.monthAndDay"` and prefix `"repeat.month"` exists from `` `repeat.month${m}` `` +- **THEN** an `unused` error IS reported (rest `"AndDay"` is not digits-only) + +#### Scenario: Whitelisted key is not reported unused regardless of prefix rules +- **WHEN** `en.json` contains `"repeat.daily"` and whitelist has a `oneOf("repeat.", ["daily", ...])` pattern +- **THEN** no `unused` error is reported + +#### Scenario: Key found only in test files is reported as unused +- **WHEN** `en.json` contains `"repeat.monthAndDay"` and it appears only in test files (not in production code) +- **THEN** an error `{ kind: "unused", key: "repeat.monthAndDay", detail: "found ONLY in tests — likely a dead key" }` is reported + +## ADDED Requirements + +### Requirement: Test fixture isolation from production scan + +Fixture strings in the i18n-check tool's own test files SHALL NOT be able to +affect real-project check results. Specifically, no string fixture in +`src/test/i18n-check/*.test.ts` may coincide (after plural/ordinal +normalization via `toBaseKey`) with a key present in `en.json`. Fixture +strings under real namespaces that do NOT correspond to existing keys are +permitted (e.g., negative-test fixtures like `"repeat.frequency"` for a +deleted key), because `checkUnused` treats test-only literals as unused +candidates and `checkUndefined` ignores test-only literals — such fixtures +cannot shield or falsely flag anything. + +Exception: fixtures that verify real WHITELIST behavior (e.g., `isWhitelisted("repeat.daily")`) +are permitted to use live keys, documented in an explicit allow-list with justification. + +#### Scenario: Fixture coinciding with a live key is forbidden +- **WHEN** `en.json` contains `task.cancel` and a tool test file contains the string `"task.cancel"` +- **THEN** the fixture-isolation test fails, naming the file and the offending string + +#### Scenario: Fixture under a real namespace but absent from en.json is allowed +- **WHEN** `en.json` does not contain `repeat.frequency` and a tool test file contains the string `"repeat.frequency"` +- **THEN** the fixture-isolation test passes + +#### Scenario: Whitelisted fixture for WHITELIST verification is allowed +- **WHEN** a test verifies `isWhitelisted("repeat.daily")` against the real WHITELIST +- **THEN** `"repeat.daily"` is permitted via the explicit allow-list with a comment justifying its presence diff --git a/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/tasks.md b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/tasks.md new file mode 100644 index 00000000..7aa5adb7 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-fix-i18n-check-dynamic-prefix-gaps/tasks.md @@ -0,0 +1,47 @@ +## 1. Tighten dynamic prefix matching (FR1, FR2, FR3) + +- [x] 1.1 RED: Add tests in `i18n-check.checks.test.ts` — single-segment dotted prefix `repeat.` does NOT protect `repeat.frequency`; two-segment `goal.status.` still protects `goal.status.paused`; non-dotted prefix `repeat.month` + digits still works; whitelist `repeat.daily` is not reported unused +- [x] 1.2 GREEN: Update `matchesDynamicPrefix` in `checks.ts` — reject dotted prefixes where the part before trailing dot has no internal dots +- [x] 1.3 Verify tests pass with `pnpm vitest run` scoped to checks test file + +## 2. Fix test-only key detection (FR4, FR5) + +- [x] 2.1 RED: Add test — key existing only in test files is reported as `unused` with detail "found ONLY in tests — likely a dead key" +- [x] 2.2 GREEN: Fix condition order in `checkUnused` — `if (scan.literalKeys.has(baseKey) && !scan.literalKeysTestOnly.has(baseKey)) continue;` +- [x] 2.3 Rename fixture keys in `src/test/i18n-check/*.test.ts` from real namespaces (e.g., `repeat.monthAndDay`) to synthetic `fx.` namespace; update corresponding synthetic locale maps +- [x] 2.4 Verify all i18n-check unit tests pass + +## 3. Add whitelist entries for single-segment enum namespaces (FR6) + +- [x] 3.1 Grep source for dynamic patterns: `` `theme.${` ``, `` `color.${` ``, `` `goalFilter.${` `` — note exact file locations for comments +- [x] 3.2 Add `oneOf` whitelist entries in `whitelist.ts` for `theme.[light,dark,system]`, `color.[blue,coral,green,indigo,orange,purple,yellow]`, `goalFilter.[all,active,paused,finished]` with `biome-ignore` and location comments +- [x] 3.3 Run `pnpm i18n:check` — expect failures only for the 7 dead keys (confirms whitelist + prefix rule work together) + +## 4. Remove dead keys from locales (FR7, FR8) + +- [x] 4.1 Remove 7 keys from `en.json`: `repeat.frequency`, `repeat.interval`, `repeat.weeklyDays`, `repeat.monthAndDay`, `repeat.invalidRulePullAlertTitle`, `repeat.invalidRulePullAlertMessage`, `repeat.invalidRulePullAlertFix` +- [x] 4.2 Remove same 7 keys from `ru.json` +- [x] 4.3 Run `pnpm i18n:check` — expect `override-orphans` error for `repeat.weeklyDays` in `house.json` +- [x] 4.4 Remove `repeat.weeklyDays` from `house.json` +- [x] 4.5 Run `pnpm i18n:check` — expect 0 errors (UX1) + +## 5. Enable integration test (FR9) + +- [x] 5.1 Remove `.skip` and TODO comment from `i18n-check.project.test.ts` +- [x] 5.2 Run `pnpm vitest run` scoped to `i18n-check.project.test` — verify it passes (not skipped) + +## 6. Fixture isolation hardening (FR5 refinement) + +- [x] 6.1 Reword fixture-isolation requirement in delta spec: forbid only keys coinciding with live en.json (not entire namespaces); allow WHITELIST-verifying fixtures via explicit allow-list +- [x] 6.2 Add automated fixture-isolation test (`i18n-check.fixtures.test.ts`): scans tool test files, asserts no fixture string matches a live base key unless in ALLOWED_LIVE_FIXTURES +- [x] 6.3 Rename remaining live-key fixtures (~43 occurrences) to `fx.*`; keep only `repeat.daily`, `repeat.weekday3`, `repeat.month12`, `repeat.monthGenitive1` (verify real WHITELIST) +- [x] 6.4 Verify all i18n-check tests pass (80/80 green) + +## 7. Verification + +- [x] 7.1 Run `pnpm test` — all green +- [x] 7.2 Run `pnpm lint` and `pnpm typecheck` — all green +- [x] 7.3 Run `pnpm build` — successful +- [x] 7.4 Smoke test: add `"repeat.zzzDead": "test"` to `en.json` and `ru.json`, run `pnpm i18n:check` — expect unused error, then revert (UX2) +- [x] 7.5 Grep `src/test/i18n-check/*.test.ts` for fixture keys matching real namespaces — expect none (FR5) +- [x] 7.6 Mutation testing: run Stryker scoped to `scripts/i18n-check/checks.ts` — target >= 95% (M4) diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/.openspec.yaml new file mode 100644 index 00000000..8803b473 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/design.md b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/design.md new file mode 100644 index 00000000..d0d9456a --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/design.md @@ -0,0 +1,60 @@ +## Context + +The i18n-check script (`packages/client/scripts/i18n-check/`) already exists and detects `undefined`, `unused`, `parity`, and `override-orphans` errors. It currently reports 121 errors: 6 undefined keys (3 `sync.alert.*` + 3 `*.attachments` prefix issues), ~90 unused keys, and 12 orphan overrides in `house.json`. The script runs locally via `pnpm i18n:check` and as a Vitest test, but is not enforced in CI. + +The deployment workflow (`.github/workflows/deploy.yml`) only builds — no tests, linting, or i18n checks. The QA workflow (`deploy-qa.yml`) is similar. + +## Goals / Non-Goals + +**Goals:** +- Fix the `sync.alert.*` bug (FR1-FR3) — highest priority, user-facing +- Clean up dead keys so `i18n:check` passes clean (FR4-FR5) +- Gate CI on `i18n:check` (FR8) to prevent regression + +**Non-Goals:** +- Changing the i18n-check script logic (it works correctly) +- Deduplicating shared values (separate change) +- Adding TypeScript type safety for `t()` calls + +## Decisions + +### D1: Translation text for `sync.alert.*` keys + +Derive from the existing (now-unused) predecessor keys rather than inventing new copy: +- `sync.alert.repeat_rule_reset` — based on `sync.repeatRuleCorrupted` + `sync.repeatRuleCorruptedAdvice` +- `sync.alert.name_set_untitled` — based on `sync.nameUntitled` + `sync.nameUntitledAdvice` +- `sync.alert.checklist_item_deleted` — based on `sync.checklistOrphaned` + `sync.checklistOrphanedAdvice` + +Each key combines the "what happened" and "what to do" into a single message, since `AlertOverlay` renders `t(alert.messageKey)` as a single `

` element — there's no separate advice field in the `SyncAlert` type. + +### D2: Whitelist update for `sync.alert.*` + +These keys are referenced via `messageKey` variable in `healingRules.ts`, not via literal `t()` calls. The scanner doesn't see them as used. Add a whitelist entry `^sync\.alert\.` with reason pointing to `healingRules.ts`. + +### D3: Order of operations + +1. Add `sync.alert.*` keys first (fix the bug) +2. Update whitelist (so new keys don't show as unused) +3. Update test fixtures that use dead keys +4. Delete unused keys (bulk cleanup) +5. Delete orphan overrides from `house.json` +6. Add `i18n:check` to CI + +This order ensures the bug fix lands first and the i18n-check script validates each subsequent step. + +### D4: CI integration approach + +Add a `check` job to `deploy.yml` that runs before `build`: +- `pnpm i18n:check` (the CLI script, exits non-zero on errors) + +This is simpler than adding a separate workflow file. The existing Vitest test for i18n-check also runs if tests are added to CI later. + +### D5: Verification of unused keys before deletion + +Before deleting each group, re-verify with `grep -rn` across `src/` (including `.feature` files and tests) to guard against false positives from the scanner. Keys used only in tests should be replaced with valid keys, not kept as dead locale entries. + +## Risks / Trade-offs + +- **[False unused]** A key could be used via a dynamic pattern the scanner doesn't detect → Mitigated by re-grep before deletion and running all tests after each group removal +- **[house.json sync]** Deleting keys from base locales requires mirroring in `house.json` → The `override-orphans` check catches any missed keys +- **[Test fixture breakage]** Tests using dead keys as fixtures will fail after key removal → Update fixtures before or alongside key deletion diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/proposal.md b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/proposal.md new file mode 100644 index 00000000..ab54f40b --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/proposal.md @@ -0,0 +1,100 @@ +# i18n-cleanup-and-lint + +## Why + +The push self-healing system (`healingRules.ts`) references three i18n keys (`sync.alert.repeat_rule_reset`, `sync.alert.name_set_untitled`, `sync.alert.checklist_item_deleted`) that do not exist in any locale file. When self-healing triggers, users see raw keys instead of human-readable messages in the AlertOverlay. Additionally, static analysis found ~90 unused keys accumulated across `en.json`/`ru.json` and 12 orphan overrides in `house.json`, producing 121 errors from the existing `i18n:check` script. The i18n-check gate is not yet enforced in CI, so locale drift keeps accumulating. + +## What Changes + +- **ADDED**: Three missing `sync.alert.*` translation keys in `en.json`, `ru.json`, and optionally `house.json` — fixes the broken self-healing alerts +- **REMOVED**: ~90 unused keys from `en.json`/`ru.json` and ~12 orphan overrides from `house.json` +- **MODIFIED**: i18n-check whitelist to cover `sync.alert.*` keys (used via `messageKey` pattern, not literal `t()` calls) +- **MODIFIED**: CI workflow to run `i18n:check` as a gate, preventing future locale drift +- **MODIFIED**: Tests that use dead keys as fixtures (`AlertOverlay.test.tsx`, `AlertProvider.test.tsx`) — update to use valid keys + +## Goals + +- **G1**: Zero `undefined` errors from `i18n:check` — all keys referenced in production code exist in locale files +- **G2**: Zero `unused` errors — no dead keys in locale files +- **G3**: Zero `override-orphans` errors — `house.json` only overrides keys that exist in `ru.json` +- **G4**: CI enforcement — locale drift detected and blocked before merge + +## Non-Goals + +- **NG1**: Deduplicating keys with identical values (e.g., "Cancel" x13) — separate change `i18n-dedupe-common-keys` +- **NG2**: Adding TypeScript type safety for `t()` calls (e.g., `i18next-cli types`) — separate initiative +- **NG3**: Changing the i18n-check script's core logic — it already works, just needs whitelist update and CI integration + +## Users & Scenarios + +- **U1**: User triggers push self-healing (e.g., corrupted repeat rule) — sees a localized, understandable alert message instead of a raw key +- **U2**: Developer adds a new i18n key — CI catches if the key is missing from any required locale +- **U3**: Developer removes a feature — CI catches if orphan keys are left behind + +## Requirements + +### Functional + +- **FR1**: Add `sync.alert.repeat_rule_reset` key to `en.json` and `ru.json` with text describing that a corrupted repeat rule was reset +- **FR2**: Add `sync.alert.name_set_untitled` key to `en.json` and `ru.json` with text describing that a missing name was set to "(untitled)" +- **FR3**: Add `sync.alert.checklist_item_deleted` key to `en.json` and `ru.json` with text describing that a checklist item was deleted because its parent task couldn't sync +- **FR4**: Remove all keys reported as `unused` by `i18n:check` from `en.json` and `ru.json` (full list in section below) +- **FR5**: Remove all keys reported as `override-orphans` from `house.json` +- **FR6**: Add `sync.alert.*` pattern to the i18n-check whitelist (these keys are referenced via `messageKey` variable, not literal `t()` calls) +- **FR7**: Update test fixtures in `AlertOverlay.test.tsx` and `AlertProvider.test.tsx` that reference dead keys to use valid keys +- **FR8**: Add `i18n:check` step to CI workflow so locale errors block merge + +### Unused keys to remove (FR4) + +From `en.json` and `ru.json`: +- `auth.*`: `accountSection`, `errorSignIn`, `sessionExpired`, `signInRequired`, `signOutButton`, `signedInAs` +- `category.*`: `add`, `addTask`, `nameLabel`, `namePlaceholder`, `taskPlaceholder` +- `context.*`: `add`, `addTask`, `nameLabel`, `namePlaceholder`, `taskPlaceholder` +- `deleted.*`: `purgeSuccess`, `restore` +- `filter.*`: `close`, `showHidden` +- `goal.*`: `add`, `addTask`, `close`, `collapseDescription`, `expandDescription`, `taskPlaceholder`, `cover.closeLightbox`, `cover.uploadError` +- `goal.attachments.*`: `empty`, `confirmDelete`, `confirmDeleteMessage`, `confirmDeleteButton` +- `idea.*`: `add`, `addTask`, `cancel`, `cancelLabel`, `delete`, `editName`, `save`, `saveLabel`, `taskPlaceholder` +- `idea.attachments.*`: `empty`, `confirmDelete`, `confirmDeleteMessage`, `confirmDeleteButton` +- `nav.*`: entire block (`inbox`, `today`, `tasks`, `goals`, `ideas`, `search`, `ariaLabel`) +- `pwa.*`: `appUpdated`, `ok` +- `settings.*`: `detailPanelPinned`, `focusStronger`, `focusWeaker`, `fullSync`, `panelAlwaysOpen`, `syncConfigure`, `syncConnect`, `syncConnected`, `syncDisconnect`, `syncNoAuth`, `syncNotConnected`, `syncSection`, `server.initError`, `server.initializing`, `server.timeoutError` +- `share.button` +- `sync.*`: `checklistOrphaned`, `checklistOrphanedAdvice`, `fkCleared`, `fkClearedAdvice`, `nameUntitled`, `nameUntitledAdvice`, `rejectedBox`, `rejectedBoxAdvice`, `rejectedCorrupted`, `rejectedCorruptedAdvice`, `rejectedEntityType`, `rejectedEntityTypeAdvice`, `rejectedStatus`, `rejectedStatusAdvice`, `repeatRuleCorrupted`, `repeatRuleCorruptedAdvice`, `unauthorized` +- `task.*`: `add`, `addPlaceholder`, `create`, `createLabel`, `newName`, `searchPlaceholder` +- `task.attachments.*`: `confirmDelete`, `confirmDeleteMessage`, `confirmDeleteButton` +- `taskDetail.*`: `empty`, `emptyHint` +- `taskEdit.*`: `cancel`, `cancelLabel`, `close`, `deleteLabel`, `name`, `save`, `saveLabel` + +### Non-Functional + +#### Reliability — NFR-R1 +After cleanup, `pnpm i18n:check` exits with code 0 (zero errors). + +## UX Acceptance Criteria + +- **UX1**: When self-healing resets a repeat rule, the AlertOverlay shows a human-readable message in the user's language explaining what happened +- **UX2**: When self-healing sets a name to "(untitled)", the AlertOverlay explains why +- **UX3**: When self-healing deletes a checklist item, the AlertOverlay explains why + +## Behavior + +No new Gherkin features required. Existing `i18n-check` tests and `AlertOverlay` tests will be updated. + +## Visual Reference + +No visual changes — alert overlay design unchanged, only translation content changes. + +## Affected IA + +No changes. + +## Success Metrics + +- **M1**: `pnpm i18n:check` reports 0 errors (currently 121) +- **M2**: CI blocks PRs that introduce locale drift +- **M3**: All three `sync.alert.*` message keys render localized text in AlertOverlay + +## Open Questions + +None. `house.json` styled overrides for `sync.alert.*` are deferred — `house.json` will be fully rewritten in a separate change. diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n-check/spec.md b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n-check/spec.md new file mode 100644 index 00000000..7b709ec0 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n-check/spec.md @@ -0,0 +1,45 @@ +## MODIFIED Requirements + +### Requirement: Detect undefined keys (FR1) + +The system SHALL report an error of kind `undefined` for every literal key found in production source code that does not exist as a base key in `en.json` and is not covered by a whitelist pattern. + +#### Scenario: Key used in code but missing from en.json +- **WHEN** production code references `"sync.alert.repeat_rule_reset"` and this base key is absent from `en.json` +- **THEN** an error `{ kind: "undefined", key: "sync.alert.repeat_rule_reset" }` is reported + +#### Scenario: Key only in test file does not trigger undefined error +- **WHEN** `"fake.test.key"` appears only in a test file and is absent from `en.json` +- **THEN** no `undefined` error is reported for this key + +#### Scenario: Key referenced via messageKey variable is covered by whitelist +- **WHEN** `healingRules.ts` assigns `messageKey: "sync.alert.repeat_rule_reset"` and the whitelist contains a pattern matching `sync.alert.*` +- **THEN** no `undefined` error is reported for this key (assuming the key exists in `en.json`) + +### Requirement: Whitelist self-validation (FR8) + +The system SHALL verify that every whitelist pattern matches at least one existing key in `en.json`. A pattern that matches zero keys SHALL be reported as an error. + +#### Scenario: Stale whitelist pattern triggers error +- **WHEN** a whitelist pattern `/^legacy\./` matches zero keys in `en.json` +- **THEN** an error is reported indicating the whitelist entry is stale + +#### Scenario: sync.alert whitelist pattern is valid +- **WHEN** the whitelist contains pattern `^sync\.alert\.` and `en.json` contains `sync.alert.repeat_rule_reset` +- **THEN** no stale whitelist error is reported + +## ADDED Requirements + +### Requirement: CI enforcement of locale consistency + +The CI pipeline SHALL run `i18n:check` before building. Any `undefined`, `unused`, `parity`, or `override-orphans` error SHALL cause the CI job to fail, preventing deployment of code with locale drift. + +#### Scenario: CI blocks deploy when undefined key exists +- **WHEN** a PR introduces code referencing a key not present in `en.json` +- **AND** CI runs `i18n:check` +- **THEN** the CI job exits with code 1 and the PR cannot be merged + +#### Scenario: CI passes when all locale checks are clean +- **WHEN** all locale keys are consistent +- **AND** CI runs `i18n:check` +- **THEN** the CI job exits with code 0 diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n/spec.md b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n/spec.md new file mode 100644 index 00000000..bdf4de09 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/specs/i18n/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Translation files are complete + +Every translation key present in any locale file MUST exist in all other locale files (excluding `_meta`). Nested keys are compared structurally. Additionally, every key referenced by production code via `t()` or `messageKey` MUST exist in all locale files. + +#### Scenario: All keys present in both locales +- **GIVEN** ru.json has keys "nav.inbox", "nav.goals" +- **AND** en.json has keys "nav.inbox", "nav.goals" +- **THEN** translation completeness check passes + +#### Scenario: Key missing in one locale +- **GIVEN** ru.json has key "nav.inbox" +- **AND** en.json does not have key "nav.inbox" +- **THEN** translation completeness check fails listing "nav.inbox" as missing in en + +#### Scenario: Self-healing alert keys exist in all locales +- **WHEN** `healingRules.ts` references `sync.alert.repeat_rule_reset`, `sync.alert.name_set_untitled`, `sync.alert.checklist_item_deleted` +- **THEN** all three keys SHALL exist in `en.json` and `ru.json` +- **AND** each key SHALL contain a human-readable message describing what the self-healing action did + +#### Scenario: No unused keys remain in locale files +- **WHEN** running `i18n:check` +- **THEN** zero `unused` errors SHALL be reported +- **AND** zero `override-orphans` errors SHALL be reported for `house.json` diff --git a/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/tasks.md b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/tasks.md new file mode 100644 index 00000000..0b1a4ee1 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-12-i18n-cleanup-and-lint/tasks.md @@ -0,0 +1,46 @@ +## 1. Fix sync.alert.* bug (FR1, FR2, FR3) + +- [x] 1.1 Add `sync.alert.repeat_rule_reset` key to `en.json` and `ru.json` — text derived from existing `sync.repeatRuleCorrupted` + `sync.repeatRuleCorruptedAdvice`, combined into one message +- [x] 1.2 Add `sync.alert.name_set_untitled` key to `en.json` and `ru.json` — text derived from `sync.nameUntitled` + `sync.nameUntitledAdvice` +- [x] 1.3 Add `sync.alert.checklist_item_deleted` key to `en.json` and `ru.json` — text derived from `sync.checklistOrphaned` + `sync.checklistOrphanedAdvice` +- [x] 1.4 Add `sync.alert.*` whitelist entry to `scripts/i18n-check/whitelist.ts` with reason pointing to `healingRules.ts` (FR6) +- [x] 1.5 Verify: `pnpm i18n:check` reports 0 `undefined` errors + +## 2. Update test fixtures (FR7) + +- [x] 2.1 Update `AlertOverlay.test.tsx` — replace any fixtures using dead keys (`sync.fkCleared`, `sync.nameUntitled`, etc.) with valid `sync.alert.*` keys +- [x] 2.2 Update `AlertProvider.test.tsx` — replace fixtures using `sync.error`/`sync.conflict` or other dead keys with valid existing keys +- [x] 2.3 Verify: `pnpm vitest run src/components/alerts` and `pnpm vitest run src/app/providers/AlertProvider` pass + +## 3. Remove unused keys from en.json and ru.json (FR4) + +- [x] 3.1 Remove `auth.*` dead keys: `accountSection`, `errorSignIn`, `sessionExpired`, `signInRequired`, `signOutButton`, `signedInAs` +- [x] 3.2 Remove `nav.*` entire block: `inbox`, `today`, `tasks`, `goals`, `ideas`, `search`, `ariaLabel` +- [x] 3.3 Remove `category.*` dead keys: `add`, `addTask`, `nameLabel`, `namePlaceholder`, `taskPlaceholder` +- [x] 3.4 Remove `context.*` dead keys: `add`, `addTask`, `nameLabel`, `namePlaceholder`, `taskPlaceholder` +- [x] 3.5 Remove `goal.*` dead keys: `add`, `addTask`, `close`, `collapseDescription`, `expandDescription`, `taskPlaceholder`, `cover.closeLightbox`, `cover.uploadError`, `attachments.empty`, `attachments.confirmDelete`, `attachments.confirmDeleteMessage`, `attachments.confirmDeleteButton` +- [x] 3.6 Remove `idea.*` dead keys: `add`, `addTask`, `cancel`, `cancelLabel`, `delete`, `editName`, `save`, `saveLabel`, `taskPlaceholder`, `attachments.empty`, `attachments.confirmDelete`, `attachments.confirmDeleteMessage`, `attachments.confirmDeleteButton` +- [x] 3.7 Remove `task.*` dead keys: `add`, `addPlaceholder`, `create`, `createLabel`, `newName`, `searchPlaceholder`, `attachments.confirmDelete`, `attachments.confirmDeleteMessage`, `attachments.confirmDeleteButton` +- [x] 3.8 Remove `taskEdit.*` dead keys: `cancel`, `cancelLabel`, `close`, `deleteLabel`, `name`, `save`, `saveLabel` +- [x] 3.9 Remove `taskDetail.*` dead keys: `empty`, `emptyHint` +- [x] 3.10 Remove `settings.*` dead keys: `detailPanelPinned`, `focusStronger`, `focusWeaker`, `fullSync`, `panelAlwaysOpen`, `syncConfigure`, `syncConnect`, `syncConnected`, `syncDisconnect`, `syncNoAuth`, `syncNotConnected`, `syncSection`, `server.initError`, `server.initializing`, `server.timeoutError` +- [x] 3.11 Remove `sync.*` dead keys: `unauthorized`, `fkCleared`, `fkClearedAdvice`, `checklistOrphaned`, `checklistOrphanedAdvice`, `nameUntitled`, `nameUntitledAdvice`, `repeatRuleCorrupted`, `repeatRuleCorruptedAdvice`, `rejectedBox`, `rejectedBoxAdvice`, `rejectedCorrupted`, `rejectedCorruptedAdvice`, `rejectedEntityType`, `rejectedEntityTypeAdvice`, `rejectedStatus`, `rejectedStatusAdvice` +- [x] 3.12 Remove remaining dead keys: `deleted.restore`, `deleted.purgeSuccess`, `filter.close`, `filter.showHidden`, `share.button`, `pwa.appUpdated`, `pwa.ok` +- [x] 3.13 Verify: `pnpm i18n:check` reports 0 `unused` errors + +## 4. Remove orphan overrides from house.json (FR5) + +- [x] 4.1 Remove orphan keys: `goal.newName`, `goal.create`, `repeat.intervalLabel`, `repeat.day1`–`repeat.day7`, `repeat.applyWeekly`, `repeat.applyInterval` +- [x] 4.2 Remove any keys from `house.json` that were deleted from `ru.json` in phase 3 (if overrides exist) +- [x] 4.3 Verify: `pnpm i18n:check` reports 0 `override-orphans` errors + +## 5. CI integration (FR8) + +- [x] 5.1 Add `i18n:check` step to `.github/workflows/deploy.yml` before the build step +- [x] 5.2 Add `i18n:check` step to `.github/workflows/deploy-qa.yml` before the build step + +## 6. Final verification (NFR-R1) + +- [x] 6.1 Run `pnpm i18n:check` — must report 0 errors (M1) +- [x] 6.2 Run `pnpm test` — all tests pass (1 fix: isWhitelisted test updated for sync.alert.* whitelist) +- [x] 6.3 Run `pnpm build` — build succeeds diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/.openspec.yaml new file mode 100644 index 00000000..8803b473 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-12 diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/design.md b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/design.md new file mode 100644 index 00000000..1ba14871 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/design.md @@ -0,0 +1,82 @@ +## Context + +The `i18n:check` script reports ~30 duplicate value groups across `en.json` and `ru.json`. Many are common UI labels (Cancel, Back, Delete) repeated under different domain namespaces. This noise masks real issues and increases maintenance cost. + +Current structure: each domain namespace (task, goal, idea, settings, etc.) defines its own copy of shared labels. All locale files use a flat namespace-based approach via i18next. + +Driven by G1, G2 from proposal. + +## Goals / Non-Goals + +**Goals:** +- Introduce a `common` namespace for shared UI labels (FR1) +- Clean up i18n-check output by whitelisting intentional duplicates (FR4, FR5) +- Remove dead keys (FR6) + +**Non-Goals:** +- Changing i18next configuration or initialization +- Altering how `t()` is called (just changing key strings) +- Modifying the duplicate detection algorithm itself + +## Decisions + +### D1: `common` namespace as a top-level key in locale JSON + +**Decision**: Add `"common": { ... }` as a top-level key in `en.json` and `ru.json`, alongside existing namespaces like `task`, `goal`, etc. + +**Alternatives considered**: +- Separate `common.json` file — adds complexity to locale loading, i18next config changes needed. Rejected per NG3/NG4. +- i18next `defaultNS` or `fallbackNS` — would require config changes and could mask missing keys. Rejected. + +**Rationale**: Simplest approach. No config changes. Same `t("common.cancel")` pattern as all other keys. + +### D2: Which keys go into `common` + +**Decision**: Only keys where the value is identical across ALL current locales (en, ru) AND the semantic meaning is truly generic (UI action labels, field labels). Specifically: + +| Key | EN | RU | Replaces | +|--------------------|-------------|--------------------|----------| +| common.cancel | Cancel | Отмена | 10 keys | +| common.back | Back | Назад | 7 keys | +| common.delete | Delete | Удалить | 5 keys | +| common.close | Close | Закрыть | 4 keys | +| common.next | Next | Далее | 3 keys | +| common.save | Save | Сохранить | 2 keys | +| common.loading | Loading... | Загрузка... | 2 keys | +| common.name | Name | Название | 3 keys | +| common.taskCount | Tasks: | Задач: | 3 keys | +| common.details | Details | Детали | 2 keys | +| common.attachments | Attachments | Вложения | 2 keys | +| common.saveName | Save name | Сохранить название | 2 keys | + +**Rationale**: These are context-independent UI labels. Adding a 3rd language won't require diverging "Cancel" in a dialog vs "Cancel" in a button. + +### D3: Domain terms stay separate, get whitelisted + +**Decision**: Navigation/filter/section terms (inbox, today, later, tasks, goals, ideas, etc.) remain as separate keys. The whitelist gets new entries to suppress duplicate warnings. + +**Rationale**: In languages with grammatical cases (German, Finnish), "Inbox" as a page title may differ from "Inbox" as a filter label. Keeping them separate preserves this flexibility. + +### D4: Semantic pairs stay separate, get whitelisted + +**Decision**: Pairs like `settings.name` / `settings.settingsAriaLabel` (display vs a11y), `settings.pinDetailPanel` / `taskDetail.pin` (settings toggle vs panel button) remain separate. + +**Rationale**: Different semantic roles. The a11y label might diverge from the display label for screen reader clarity. + +### D5: Whitelist structure — group by category + +**Decision**: Add whitelist entries grouped by reason: +1. Domain navigation terms (pattern matching multiple namespaces) +2. Semantic display/a11y pairs (explicit key lists) + +Use `Set`-based lookup for exact keys, and existing `RegExp` patterns for prefix-based groups. + +### D6: saveName keys are alive + +**Decision**: `context.saveName` and `category.saveName` are alive — passed dynamically via `i18nKeys.saveName` object in `EntityDetailLayout` from `ContextDetailPage` and `CategoryDetailPage`. They are extracted into `common.saveName` (same value in both languages). + +## Risks / Trade-offs + +- **[Risk] Missed dynamic `t()` usage** → Before removing old keys, grep for all dynamic patterns that could construct the old key path. The i18n-check script already catches undefined keys, so CI will fail if we miss one. +- **[Risk] Future language needs divergent translations** → Can always split a `common.*` key back into domain-specific keys. The migration is straightforward (add domain key, update `t()` call, remove from common). +- **[Trade-off] Whitelist growth** → Adding ~15 whitelist entries is acceptable given they are well-documented and self-validating (stale entries cause errors per FR8 of i18n-check spec). diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/proposal.md b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/proposal.md new file mode 100644 index 00000000..f5c62a89 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/proposal.md @@ -0,0 +1,94 @@ +# Deduplicate i18n Common Keys + +## Why + +The `i18n:check` script reports ~30 groups of duplicate values across `en.json` and `ru.json`. Many are genuinely shared UI labels (Cancel, Back, Delete, Close) repeated 5-10 times under different namespaces. This increases maintenance cost when adding new languages or updating wording, and creates noise in the i18n check output, masking real issues. + +## What Changes + +- **ADDED**: `common` namespace in `en.json` / `ru.json` for shared UI labels +- **MODIFIED**: ~20 components to reference `common.*` keys instead of domain-specific duplicates +- **REMOVED**: ~35 redundant i18n keys replaced by `common.*` +- **MODIFIED**: `i18n-check` script whitelist to accept intentional domain-term duplicates +- **REMOVED**: Dead keys (`context.saveName`, `category.saveName`) if confirmed unused + +## Capabilities + +### New Capabilities + +_None_ — this is a refactoring of existing i18n infrastructure. + +### Modified Capabilities + +- `i18n`: Adding `common` namespace convention; removing dead keys +- `i18n-check`: Extending whitelist with intentional duplicate patterns + +## Goals + +- **G1**: Eliminate unintentional i18n key duplication — shared UI labels exist in one place +- **G2**: Clean `i18n:check` output — zero informational duplicates reported + +## Non-Goals + +- **NG1**: Changing any user-visible text or translations +- **NG2**: Adding new languages +- **NG3**: Refactoring the i18n-check script beyond whitelist changes +- **NG4**: Changing how i18next is configured or initialized + +## Users & Scenarios + +- **U1**: Developer adding a new dialog — uses `t("common.cancel")` instead of creating yet another `*.cancel` key +- **U2**: Developer adding a 3rd language — translates `common.cancel` once instead of 10 times +- **U3**: Developer running `i18n:check` — sees clean output without noise from known duplicates + +## Requirements + +### Functional + +- **FR1**: Create `common` namespace in both `en.json` and `ru.json` with shared keys: `cancel`, `back`, `delete`, `close`, `next`, `save`, `loading`, `name`, `taskCount`, `details`, `attachments`, `saveName` +- **FR2**: Remove the original domain-specific keys that are replaced by `common.*` +- **FR3**: Update all `t()` call sites in components to use `common.*` keys +- **FR4**: Add whitelist entries in `i18n-check` for intentional domain-term duplicates (inbox, today, later, all, yesterday, tasks, goals, ideas, contexts, categories, memos, deleted) +- **FR5**: Add whitelist entries for intentional semantic-pair duplicates (label vs ariaLabel, indicator vs legend, button vs type) +- **FR6**: Extract `context.saveName` and `category.saveName` into `common.saveName` (confirmed alive — used dynamically via `i18nKeys` in `EntityDetailLayout`) +- **FR7**: `pnpm run i18n:check` produces zero duplicate warnings after changes + +### Non-Functional + +#### Performance + +- **NFR-P1**: No runtime performance impact — i18next key lookup is O(1) regardless of namespace + +#### Accessibility + +- **NFR-A1**: No a11y impact — translations remain identical, only key paths change + +## UX Acceptance Criteria + +- **UX1**: All user-facing text remains exactly the same before and after the change +- **UX2**: No missing translations — every `t()` call resolves to a valid key + +## Behavior + +No new Gherkin scenarios needed — this is a structural refactoring with no behavior change. + +Existing i18n BDD tests in `src/test/features/i18n/` must continue to pass. + +## Visual Reference + +No visual changes. + +## Affected IA + +No changes. + +## Success Metrics + +- **M1**: `pnpm run i18n:check` reports 0 duplicate groups (currently ~30) +- **M2**: Net reduction of ~35 i18n keys across locale files +- **M3**: All existing tests pass (unit, BDD, i18n-check) +- **M4**: Build succeeds (`pnpm run build`) + +## Open Questions + +- ~~**Q1**: Are `context.saveName` and `category.saveName` truly dead keys?~~ **Resolved**: alive — passed dynamically via `i18nKeys.saveName` in `EntityDetailLayout` from `ContextDetailPage` and `CategoryDetailPage`. diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n-check/spec.md b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n-check/spec.md new file mode 100644 index 00000000..3a7577e6 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n-check/spec.md @@ -0,0 +1,51 @@ +## MODIFIED Requirements + +### Requirement: Report duplicate values (FR5) + +The system SHALL always report groups of keys that have identical values in both `en.json` and `ru.json` as candidates for deduplication, UNLESS all keys in the group are covered by a duplicate-whitelist entry. This report is printed on every run (informational, does not cause a non-zero exit code). + +Groups where every key matches a duplicate-whitelist pattern SHALL be suppressed from the output. + +#### Scenario: Keys with same value in both locales are grouped +- **WHEN** `en.json` has `"task.cancel": "Cancel"` and `"goal.cancel": "Cancel"`, and `ru.json` has both as `"Отмена"` +- **AND** neither key is in the duplicate whitelist +- **THEN** a duplicate group `["task.cancel", "goal.cancel"]` is reported + +#### Scenario: Keys with same en value but different ru values are not grouped +- **WHEN** `en.json` has `"repeat.month4": "April"` and `"repeat.monthGenitive4": "April"`, but `ru.json` has `"апрель"` and `"апреля"` +- **THEN** no duplicate group is reported for these keys + +#### Scenario: Whitelisted duplicate group is suppressed +- **WHEN** `en.json` has `"box.inbox": "Inbox"` and `"filter.inbox": "Inbox"` with identical RU values +- **AND** both keys match a duplicate-whitelist pattern +- **THEN** no duplicate group is reported for these keys + +## ADDED Requirements + +### Requirement: Duplicate whitelist for intentional duplicates + +The system SHALL support a duplicate-value whitelist — a set of key patterns that are expected to have duplicate values across namespaces. The whitelist SHALL be defined in `whitelist.ts` alongside the existing unused-key whitelist. + +Whitelisted categories: +1. **Domain navigation terms**: keys across `box`, `section`, `filter`, `search`, `deleted`, `task`, `repeat`, `goalFilter`, `idea.pageName`, `memo.pageName`, `settings.sections` that share values for the same domain concept +2. **Semantic pairs**: keys with different UI roles (display label vs ariaLabel, indicator vs legend, button label vs type label) that happen to share the same text + +#### Scenario: Domain term duplicate is whitelisted +- **WHEN** `box.inbox` and `filter.inbox` both have value "Inbox"/"Входящие" +- **THEN** both keys match a duplicate-whitelist pattern and the group is suppressed + +#### Scenario: Semantic pair duplicate is whitelisted +- **WHEN** `settings.name` and `settings.settingsAriaLabel` both have value "Settings"/"Настройки" +- **THEN** both keys match a duplicate-whitelist pattern and the group is suppressed + +#### Scenario: Non-whitelisted duplicate is still reported +- **WHEN** two keys have identical values but are not in the duplicate whitelist +- **THEN** the duplicate group is reported as before + +### Requirement: Duplicate whitelist self-validation + +The system SHALL verify that every duplicate-whitelist pattern matches at least one existing key in `en.json`. A pattern that matches zero keys SHALL be reported as an error, consistent with the existing unused-key whitelist validation (FR8). + +#### Scenario: Stale duplicate-whitelist pattern triggers error +- **WHEN** a duplicate-whitelist pattern matches zero keys in `en.json` +- **THEN** an error is reported indicating the whitelist entry is stale diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n/spec.md b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n/spec.md new file mode 100644 index 00000000..457d164b --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/specs/i18n/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: Common namespace for shared UI labels + +Locale files SHALL contain a `common` top-level namespace with shared UI labels that are used identically across multiple domain namespaces. Components SHALL reference `common.*` keys instead of duplicating the same label under each domain namespace. + +The `common` namespace SHALL include at minimum: `cancel`, `back`, `delete`, `close`, `next`, `save`, `loading`, `name`, `taskCount`, `details`, `attachments`. + +#### Scenario: Component uses common.cancel instead of domain-specific key +- **WHEN** a component needs a "Cancel" button label +- **THEN** it SHALL use `t("common.cancel")` instead of a domain-specific key like `t("goal.cancel")` + +#### Scenario: Common keys exist in all locale files +- **WHEN** `en.json` contains `common.cancel` +- **THEN** `ru.json` SHALL also contain `common.cancel` with the corresponding translation + +#### Scenario: Domain-specific keys replaced by common keys are removed +- **WHEN** `common.cancel` is defined +- **THEN** keys `task.cancel`, `goal.cancel`, `focusGoalReplacementDialog.cancel`, `idea.deleteConfirmCancel`, `settings.fullSyncCancel`, `settings.disconnectCancel`, `settings.server.cancel`, `taskEdit.deleteConfirmCancel`, `deleted.purgeCancel`, `confirmDialog.cancel` SHALL NOT exist in locale files + +#### Scenario: saveName keys replaced by common.saveName +- **WHEN** `common.saveName` is defined +- **THEN** keys `context.saveName` and `category.saveName` SHALL NOT exist in locale files +- **AND** `EntityDetailLayout` consumers (`ContextDetailPage`, `CategoryDetailPage`) SHALL pass `"common.saveName"` via `i18nKeys.saveName` diff --git a/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/tasks.md b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/tasks.md new file mode 100644 index 00000000..1944cbd1 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-deduplicate-i18n-common-keys/tasks.md @@ -0,0 +1,36 @@ +## 1. Preparation (FR6) + +- [x] 1.1 Update `ContextDetailPage` and `CategoryDetailPage` to pass `"common.saveName"` instead of `"context.saveName"` / `"category.saveName"` via `i18nKeys.saveName` (confirmed alive — used dynamically in `EntityDetailLayout`) + +## 2. Add `common` namespace to locale files (FR1, FR2) + +- [x] 2.1 Add `common` section to `en.json` with keys: cancel, back, delete, close, next, save, loading, name, taskCount, details, attachments (and saveName if not dead) +- [x] 2.2 Add matching `common` section to `ru.json` +- [x] 2.3 Remove the ~35 domain-specific keys replaced by `common.*` from `en.json` +- [x] 2.4 Remove the matching domain-specific keys from `ru.json` + +## 3. Update component references (FR3) + +- [x] 3.1 Update cancel-related `t()` calls in ~13 components to use `common.cancel` +- [x] 3.2 Update back-related `t()` calls in ~6 components to use `common.back` +- [x] 3.3 Update delete-related `t()` calls in ~5 components to use `common.delete` +- [x] 3.4 Update close-related `t()` calls in ~5 components to use `common.close` +- [x] 3.5 Update next-related `t()` calls in ~3 components to use `common.next` +- [x] 3.6 Update save-related `t()` calls in ~2 components to use `common.save` +- [x] 3.7 Update loading-related `t()` calls in ~4 components to use `common.loading` +- [x] 3.8 Update name/taskCount/details/attachments `t()` calls in ~5 components to use `common.*` + +## 4. Extend i18n-check whitelist (FR4, FR5) + +- [x] 4.1 Add duplicate whitelist data structure and filtering logic to `whitelist.ts` and `duplicates.ts` — groups where all keys match a duplicate-whitelist pattern are suppressed from output +- [x] 4.2 Add domain navigation term patterns to duplicate whitelist (inbox, today, later, all, yesterday, tasks, goals, ideas, contexts, categories, memos, deleted) +- [x] 4.3 Add semantic pair patterns to duplicate whitelist (settings.name/settingsAriaLabel, login/loginAriaLabel, pin/unpin, syncIndicator/syncLegend, connectSupabase/typeSupabase, disconnectConfirm/disconnect, status.paused/goalFilter.paused) +- [x] 4.4 Add `common` to the i18n-check scanner's namespace list so `common.*` keys are recognized in source code +- [x] 4.5 Add duplicate whitelist self-validation — stale patterns trigger errors (consistent with existing FR8) + +## 5. Verification (FR7, M1–M4) + +- [x] 5.1 Run `pnpm run i18n:check` — verify 0 duplicate groups and 0 errors (3 pre-existing undefined-key errors remain, not from this change) +- [x] 5.2 Run existing i18n BDD tests — verify all pass (73/73) +- [x] 5.3 Run i18n-check unit tests — verify all pass (79/80 pass; 1 project-consistency test fails due to 3 pre-existing undefined keys, not from this change) +- [x] 5.4 Run `pnpm run build` — verify build succeeds diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/.openspec.yaml new file mode 100644 index 00000000..b119b635 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-13 diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/design.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/design.md new file mode 100644 index 00000000..b0f31d4b --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/design.md @@ -0,0 +1,85 @@ +# Design: rework-house-locale + +## Context + +`house.json` is a dialect locale (`_meta.baseLanguage: "ru"`) that overrides `ru.json`. Since deduplicate-i18n-common-keys, missing keys fall back to the base locale, and `i18n-check` reports override keys absent from base as `override-orphans` errors. The current file predates fallback: it carries ~150 keys, many identical to base, and its terminology drifted across namespaces (4 synonyms for "task", 3 for "goal", 3 deletion verbs). + +Decisions below were made with the user during the explore session (2026-07-13). Context: driven by FR1–FR8 from proposal. + +## Goals / Non-Goals + +**Goals:** +- A single glossary (Princeton-Plainsboro hospital metaphor) every current and future key must follow. +- Minimal file: themed overrides only, everything else falls back. +- Codified no-theming zones so humor never costs operability. + +**Non-Goals:** +- No i18n runtime or tooling changes; no base-locale changes; no `en`-based House locale. + +## Decisions + +### D1: Entity glossary + +| App entity | House term | Rationale / alternatives rejected | +|--------------------|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Task | пациент | Full lifecycle maps to canon (admit → quarantine → discharge → relapse → morgue → resuscitate). Rejected: «назначение» (dry everyday strings), «страдание» (no lifecycle) | +| Goal | диагноз | Achieving = confirming («Подтверждён. Я был прав»), abandoning = «Опровергнут». Rejected: «загадка» (weaker medical verbs), «случай» (forces task rename), «курс лечения» (clunky) | +| Task→Goal link | поставить диагноз | Falls out of the two rows above for free | +| Idea | озарение | House's epiphany moments; matches "capture fleeting thought". Rejected: «гипотеза» (drier), «доска» (whiteboard isn't memorable in RU dub), «версия» (collides with app version) | +| Checklist item | назначение | Doctor's orders: «МРТ, пункция, посев» | +| Category | отделение | Hospital departments = life spheres | +| Context | — (fallback) | User's contexts are time/state-based; «кабинет» would lie. Deliberate neutral zone | +| Box (generic) | палата | «Палата по умолчанию»; «отделение» taken, «крыло» colder | +| Inbox | Приёмный покой | Entry point for the unsorted | +| Today | Критические | Rejected: «Реанимация» (collides with restore = «реанимировать») | +| Week | Под наблюдением | Rejected: «Стационар» (no "soon" semantics) | +| Later | Лист ожидания | Comprehensible to non-viewers. Rejected: «Клиника» (opaque fan-service) | +| All | Обход | Rounds = seeing every patient; a view name, not a place | +| Hidden task | карантин | Hide until date = isolate | +| Attachment / cover | снимок | MRI/X-ray; uploads «проявляются» | +| Memo | советы Уилсона | Wilson is the advice-giver; rhymes with «озарения» (his remarks trigger them) | +| Deleted | морг | Kept from the old file — its best find | +| Purge | кремация | Irreversibility is in the word itself | +| Search | диагностика | Symptoms in → matches out; `noResults` hosts «Это никогда не волчанка» | +| Sync (background) | сверка карт | Routine record reconciliation | +| Full sync | консилиум | The ceremonial gathering; disconnect = «распустить консилиум» | +| Settings | администрация | Cuddy's domain; theme values: «Плацебо» / «Ночная смена» / «Как решит Кадди» | + +### D2: Verb system (consistency rule) + +- Living entities (patients, diagnoses, epiphanies) go «в морг»; diagnoses are «сняты», epiphanies «забыты» — but all land in the morgue. +- Structural entities (отделения) are «закрыты», never sent to the morgue. +- restore = «реанимировать», complete = «выписать», uncomplete = «рецидив», move = «перевести», recurring = «хроническое» / «без рецидивов». + +### D3: No-theming zones + +Fallback to base is mandatory for: +1. Accessibility-only strings: aria-labels, alt texts, SR announcements (`alert.*`, `attachment.list.*`, drag handles, checkbox marks). Exception: when the glossary verb IS the clearest label (`deleted.restoreAriaLabel` «Реанимировать {{name}}»). +2. Data-repair and config instructions: `sync.alert.*`, `settings.server.*`, `projectPausedDialog.*`, invalid-repeat-rule strings. +3. Navigation/confirmation buttons where instant readability matters: `common.cancel/save/close/back/next`, `settings.disconnectConfirm`, `commandBar.create`. +4. Functional settings labels (language, handedness, scale, panels) and the repeat-rule configurator (all of `repeat.*` except `none`). + +Rule of thumb fixed with the user: jokes live in statuses, empty states, and confirmations; instructions for fixing problems stay literal. + +### D4: Register + +Direct address uses capitalized «Вы»/«Ваш» (formal address to the user). Ellipses in placeholders follow base style (`...`). + +### D5: Per-entity plural keys for purge counts (FR9) + +The old `deleted.purgeConfirmCount` interpolated six counts into one string, so i18next could not pluralize any of them («1 озарений»). Chosen fix: six per-entity keys (`deleted.purgeCountTasks`…`deleted.purgeCountIdeas`) with CLDR plural forms; `DeletedPage` composes them with `join(", ")` and passes the list as `{{items}}` into `deleted.purgeConfirmCount`. Alternatives rejected: i18next `intervalPlural` plugin (new dependency for one string), formatting in code without i18n keys (breaks locale theming — house needs «пациентов», not «задач»). + +### D6: Dialect plural rules resolve through baseLanguage (FR10) + +`Intl.PluralRules("house")` silently resolves to root rules (every count → `other`), so i18next never looks up `_one`/`_few`/`_many` keys for the house code and drops to the ru fallback with ru words. Fix: after `i18n.init()`, wrap `services.pluralResolver.getRule` to map the code through `getLocaleByCode(code)?.baseLanguage`. Alternatives rejected: renaming the locale to a valid tag `ru-house` (breaks the stored language preference, filename/`_meta.code` contract, and every "house" reference), dialect files using only `_other` forms (cannot decline at all). + +### D7: Phrase inventory location + +The normative key-by-key inventory lives in `specs/house-locale/spec.md` (single source of truth for implementation and tests). design.md holds only the rules that generated it, so future keys can be derived without re-deciding. + +## Risks / Trade-offs + +- [Humor ages or annoys daily] → Jokes concentrate in low-frequency surfaces (empty states, confirmations, sync statuses); high-frequency labels are terse glossary nouns. +- [Non-viewers miss references] → Every phrase still states its function literally («Это никогда не волчанка» still means "nothing found"); box names chosen for comprehension over fan-service (D1: Лист ожидания). +- [Glossary drift returns with future keys] → Glossary + zones are spec'd and covered by content tests; adding an off-glossary key fails review against this design. +- [Removed keys regress to base unintentionally] → BDD content test asserts no override equals its base value, and i18n-check guards orphans; visual diff of the locale is part of tasks. diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/proposal.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/proposal.md new file mode 100644 index 00000000..b563f5e9 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/proposal.md @@ -0,0 +1,96 @@ +# rework-house-locale + +## Why + +The Dr. House dialect locale (`house.json`) grew organically and suffers from inconsistent terminology: tasks are called «страдание», «пациент», «жалоба», or «случай» depending on the screen; goals are «иллюзии» in one place and «навязчивые идеи» in another; deletion is «ампутировать», «ликвидировать», or «удалить». Since locale fallback to the base language was implemented (deduplicate-i18n-common-keys), the file can also shed every key that merely duplicates `ru.json`. This change rebuilds the locale around a single coherent glossary (the Princeton-Plainsboro hospital metaphor) and trims it to themed-only overrides. + +## What Changes + +- **MODIFIED**: `packages/client/src/locales/house.json` is rewritten from scratch: new glossary-driven phrases (in Russian), removal of all keys identical to the `ru.json` base, removal of themed overrides for accessibility and data-repair strings. +- **ADDED**: a documented glossary and theming rules for the House locale, enforced by automated content tests. +- **MODIFIED**: the purge confirmation count message gains grammatical number agreement. The old single key interpolated six counts and produced «1 озарений»/«1 задач»; it is split into per-entity plural keys (`_one`/`_few`/`_many` in ru, `_one`/`_other` in en) composed into a list by `DeletedPage`. +- The i18n mechanism and i18n-check tooling stay as-is. + +## Capabilities + +### New Capabilities + +- `house-locale`: content rules for the Dr. House dialect locale — fixed entity glossary, minimal-override principle, no-theming zones (accessibility, data-repair instructions, server configuration), placeholder and plural-structure parity with the base locale. + +### Modified Capabilities + +- `deleted-entities`: the purge confirmation dialog SHALL show grammatically agreed per-entity counts (new requirement). +- `i18n`: dialect locales SHALL resolve plural rules through their base language (new requirement; without it, plural overrides in dialect files are unreachable — `Intl.PluralRules("house")` yields root rules where every count maps to `other`). `i18n-check` is untouched. + +## Goals + +- G1: One entity — one term across the entire locale (0 synonym drift for tasks, goals, ideas, boxes, deletion verbs). +- G2: `house.json` contains only keys whose values differ from `ru.json` (0 redundant overrides). +- G3: Themed strings never degrade accessibility or error recovery (aria/alt and repair instructions always fall back to base). + +## Non-Goals + +- NG1: No changes to i18n runtime, locale registry, or fallback mechanics — except dialect plural-rule inheritance (FR10): dialect locale codes are not BCP 47 languages, so `Intl.PluralRules` degrades them to root rules; plural rules now resolve through `_meta.baseLanguage`. +- NG2: No changes to `ru.json` / `en.json` base locales beyond the purge-count pluralization keys of FR9. +- NG3: No new i18n-check rules or CLI features. +- NG4: No theming of the `context` namespace (deliberate fallback — contexts stay neutral). + +## Users & Scenarios + +- U1: A Russian-speaking user who selected the Dr. House locale sees a consistent hospital narrative: tasks are patients, goals are diagnoses, ideas are epiphanies, deleted items live in the morgue. +- U2: A screen-reader user on the House locale hears functional labels (from base fallback) for drag handles, checkboxes, and file actions — jokes never replace operability. +- U3: A user hitting a sync data-repair alert gets the plain base instruction, not a joke, so they can fix the problem. + +## Requirements + +### Functional + +- FR1: `house.json` SHALL contain only keys whose values differ from the corresponding `ru.json` values; keys identical to base SHALL be removed (fallback covers them). +- FR2: All themed strings SHALL use the fixed glossary terms for app entities (see design.md): task = «пациент», goal = «диагноз», idea = «озарение», category = «отделение», checklist = «назначения», attachment = «снимок», memo = «советы Уилсона», deleted = «морг», box = «палата», inbox/today/week/later/all = «Приёмный покой»/«Критические»/«Под наблюдением»/«Лист ожидания»/«Обход». +- FR3: Action verbs SHALL be consistent: delete living entities = «в морг», delete structural entities = «закрыть», restore = «реанимировать», complete task = «выписать», hide = «карантин», purge = «кремация», move = «перевести». +- FR4: `house.json` SHALL NOT override accessibility-only strings (aria-labels, alt texts, screen-reader position announcements), except where the glossary term itself is the clearest action name (e.g. `deleted.restoreAriaLabel` «Реанимировать {{name}}»). +- FR5: `house.json` SHALL NOT override data-repair and configuration instruction strings: `sync.alert.*`, `settings.server.*`, `projectPausedDialog.*`, `repeat.ruleNotRecognized`, `repeat.invalidRuleAlert*`. +- FR6: Every overridden key SHALL preserve the interpolation placeholders (`{{count}}`, `{{date}}`, `{{name}}`, …) and plural/ordinal suffix structure of its base key. +- FR7: `_meta` SHALL be preserved with `code: "house"`, `baseLanguage: "ru"` and remain the only namespace allowed to be absent from base. +- FR8: All direct addresses to the user SHALL use capitalized «Вы»/«Ваш» forms. +- FR9: The purge confirmation count message SHALL agree entity names with their counts in every locale (ru: «1 задача / 2 задачи / 5 задач»; house: «1 озарение / 2 озарения / 5 озарений»; en: «1 task / 2 tasks») via per-entity plural keys composed into a list; the house locale SHALL override the plural keys of themed entities only (contexts fall back). +- FR10: Dialect locales (where `_meta.baseLanguage` differs from the locale code) SHALL select CLDR plural forms using the plural rules of their base language, both for keys overridden in the dialect file and for keys served via fallback. + +### Non-Functional + +#### Accessibility + +- NFR-A1: With the House locale active, every interactive element SHALL still expose a meaningful accessible name (via base fallback for non-themed aria keys); axe-core checks pass unchanged. + +## UX Acceptance Criteria + +- UX1: Empty states, confirmations, and statuses read as one coherent hospital story; no screen mixes two different terms for the same entity. +- UX2: Destructive confirmation buttons (`disconnectConfirm`, `purgeConfirm`, common `cancel`/`save`) remain instantly comprehensible: either base fallback or a glossary verb whose meaning is unambiguous («Кремировать»). + +## UI States Matrix + +Not applicable — content-only change; no components or states are added or modified. + +## Behavior + +- `packages/client/src/test/features/i18n/house-locale.feature` — BDD unit scenarios (tagged `@rework-house-locale`) validating FR1–FR8 against the JSON content: no redundant overrides, no orphan keys, forbidden-zone keys absent, placeholder parity, glossary conformance for entity page names. + +## Visual Reference + +Not applicable — no visual changes; design tokens untouched. + +## Affected IA + +No changes. + +## Success Metrics + +- M1: `i18n-check` exits 0 — no `override-orphans`, no `undefined` keys. +- M2: 0 keys in `house.json` with a value identical to `ru.json` (verified by automated test). +- M3: 0 overridden keys inside the no-theming zones of FR4/FR5 (verified by automated test). +- M4: 100% of overridden keys preserve base placeholders (verified by automated test). +- M5: purge counts 0, 1, 2, 5, 21 render grammatically agreed entity names in ru, en, and house locales (verified by automated test). + +## Open Questions + +None — glossary and phrase inventory were settled during the explore session (2026-07-13). diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/deleted-entities/spec.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/deleted-entities/spec.md new file mode 100644 index 00000000..29664869 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/deleted-entities/spec.md @@ -0,0 +1,23 @@ +# Capability: deleted-entities (delta) + +## ADDED Requirements + +### Requirement: Purge confirmation shows grammatically agreed counts + +The purge confirmation dialog SHALL display per-entity counts with entity names grammatically agreed to the number in the active locale. Each entity count SHALL use a dedicated plural i18n key (`deleted.purgeCountTasks`, `deleted.purgeCountGoals`, `deleted.purgeCountContexts`, `deleted.purgeCountCategories`, `deleted.purgeCountChecklistItems`, `deleted.purgeCountIdeas`) with CLDR plural forms (`_one`/`_few`/`_many` for ru-based locales, `_one`/`_other` for en). The dialog SHALL compose the six rendered counts into a comma-separated list interpolated into `deleted.purgeConfirmCount` as `{{items}}`. # implements FR9 of rework-house-locale + +#### Scenario: Russian counts agree with numbers +- **WHEN** the purge dialog renders in locale "ru" with 1 task, 2 goals, and 5 ideas +- **THEN** the message contains «1 задача», «2 цели», and «5 идей» + +#### Scenario: House locale counts agree with numbers +- **WHEN** the purge dialog renders in locale "house" with 1 idea and 21 tasks +- **THEN** the message contains «1 озарение» and «21 пациент» + +#### Scenario: Zero counts use the many form in Russian +- **WHEN** the purge dialog renders in locale "ru" with 0 contexts +- **THEN** the message contains «0 контекстов» + +#### Scenario: English counts agree with numbers +- **WHEN** the purge dialog renders in locale "en" with 1 task and 2 goals +- **THEN** the message contains "1 task" and "2 goals" diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/house-locale/spec.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/house-locale/spec.md new file mode 100644 index 00000000..f63b4626 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/house-locale/spec.md @@ -0,0 +1,305 @@ +# Capability: house-locale + +Content rules for the Dr. House dialect locale (`packages/client/src/locales/house.json`, base `ru`). + +## ADDED Requirements + +### Requirement: Locale contains only differing overrides (FR1) + +`house.json` SHALL contain only keys whose values differ from the corresponding `ru.json` values. Keys with values identical to base SHALL be absent (covered by fallback). Every non-`_meta` key SHALL exist in `ru.json` (no orphans). + +#### Scenario: No redundant overrides +- **WHEN** every non-`_meta` key of `house.json` is compared with `ru.json` +- **THEN** no key has a value identical to its base value + +#### Scenario: No orphan keys +- **WHEN** `i18n-check` runs +- **THEN** no `override-orphans` errors are reported for `house.json` + +### Requirement: Themed strings follow the entity glossary (FR2, FR3) + +All themed strings SHALL use the glossary of design.md D1 (task = «пациент», goal = «диагноз», idea = «озарение», category = «отделение», checklist item = «назначение», attachment = «снимок», deleted = «морг», box = «палата») and the verb system of D2 (delete living = «в морг», delete structural = «закрыть», restore = «реанимировать», complete = «выписать», hide = «карантин», purge = «кремация»). + +#### Scenario: Entity page names match glossary +- **WHEN** reading `goal.pageName`, `idea.pageName`, `memo.pageName`, `deleted.pageName` +- **THEN** the values are «Диагнозы», «Озарения», «Советы Уилсона», «Морг» + +#### Scenario: Deletion verbs are consistent +- **WHEN** reading `common.delete`, `taskDetail.delete`, `category.deleteLabel` +- **THEN** the values are «В морг», «В морг», «Закрыть отделение» + +### Requirement: Accessibility strings are not themed (FR4) + +`house.json` SHALL NOT override accessibility-only keys: `alert.*`, `attachment.list.*`, `attachment.lightbox.dialogLabel`, `attachment.lightbox.close`, `taskEdit.checkItemMark`, `taskEdit.checkItemUnmark`, `taskEdit.checkItemDelete`, `taskEdit.dragChecklist`, `taskEdit.checklistBadgeAriaLabel`, `taskEdit.attachmentsBadgeAriaLabel`, `settings.menuOrderDragHandle`, `settings.menuOrderToggle`, `settings.settingsAriaLabel`, `settings.loginAriaLabel`, `settings.avatarAlt`, `sync.ariaLabel`, `filter.closeSidebar`. Exception: `deleted.restoreAriaLabel` MAY be themed because the glossary verb is the clearest action name. + +#### Scenario: Aria keys absent from locale +- **WHEN** `house.json` is flattened +- **THEN** none of the listed accessibility keys are present + +### Requirement: Data-repair and configuration strings are not themed (FR5) + +`house.json` SHALL NOT override: `sync.alert.*`, `settings.server.*`, `projectPausedDialog.*`, `repeat.ruleNotRecognized`, `repeat.invalidRuleAlertTitle`, `repeat.invalidRuleAlertMessage`, `repeat.invalidRuleAlertFix`, `auth.*`, `sidebar.*`. + +#### Scenario: Repair-instruction keys absent from locale +- **WHEN** `house.json` is flattened +- **THEN** no key from the listed namespaces is present + +### Requirement: Placeholder and plural parity with base (FR6) + +Every overridden key SHALL contain exactly the same set of `{{placeholder}}` names as its base key, and plural/ordinal suffix variants SHALL only exist where the base key has them. + +#### Scenario: Placeholders preserved +- **WHEN** an overridden key's base value contains placeholders (e.g. `deleted.purgeConfirmCount`) +- **THEN** the override contains the identical placeholder set + +### Requirement: Locale metadata preserved (FR7) + +`_meta` SHALL remain `{ code: "house", name: "Dr. House", nativeName: "Доктор Хаус", baseLanguage: "ru", emoji: "🏥" }`. + +#### Scenario: Metadata intact +- **WHEN** `house.json` is loaded +- **THEN** `_meta.code` is `house` and `_meta.baseLanguage` is `ru` + +### Requirement: Formal capitalized address (FR8) + +Strings addressing the user directly SHALL use capitalized «Вы»/«Ваш» forms (e.g. «Что Вас осенило?..», «останутся у Вас на устройстве»). + +#### Scenario: Capitalized address in user-facing phrases +- **WHEN** any override contains a direct address pronoun +- **THEN** it is written as «Вы»/«Вас»/«Ваш» with a capital letter + +### Requirement: Normative phrase inventory (FR1–FR3) + +`house.json` SHALL consist of exactly the `_meta` object and the following overrides: + +| Key | Value | +|-----------------------------------------|----------------------------------------------------------------------------------------------------------------| +| common.delete | В морг | +| common.loading | Ждём анализы... | +| common.taskCount | Пациентов: | +| common.attachments | Снимки | +| box.inbox | Приёмный покой | +| box.today | Критические | +| box.week | Под наблюдением | +| box.later | Лист ожидания | +| box.all | Обход | +| task.empty | Пациентов нет. Скука. | +| task.emptyCompleted | Ни одной выписки. Плохая статистика. | +| task.emptyInbox | В приёмном пусто. Все лгут, что здоровы. | +| task.emptyToday | Критических нет. Либо все здоровы, либо уже в морге. | +| task.noTasksPrompt | Пациентов нет. Примите кого-нибудь. | +| task.editDescription | Дополнить анамнез | +| task.hide | В карантин | +| task.hideTask | Пациента в карантин | +| task.hideUntil | В карантине до | +| task.unhide | Выпустить | +| task.unhideTask | Выпустить из карантина | +| task.selectGoal | Поставить диагноз | +| task.moveToBox | Перевести в другую палату | +| task.selectCategory | Направить в отделение | +| task.fullEdit | Полный осмотр | +| task.complete | Выписать | +| task.noncomplete | Рецидив | +| task.restore | Реанимировать | +| task.restoreConfirm | Реанимировать пациента? | +| task.selectDate | Назначить дату | +| task.drag | Перевести пациента | +| task.appearDate | Вернётся {{date}} | +| task.namePlaceholder | Симптомы пациента... | +| task.completedToday | Выписан: сегодня {{time}} | +| task.completedYesterday | Выписан: вчера {{time}} | +| task.completedDate | Выписан: {{date}} {{time}} | +| section.inbox | Приёмный покой | +| section.today | Критические | +| section.week | Под наблюдением | +| section.later | Лист ожидания | +| section.completedToday | Выписаны сегодня | +| section.completedYesterday | Выписаны вчера | +| section.completedWeek | Выписки за 7 дней | +| section.completedMonth | Выписки за месяц | +| section.completedEarlier | Давно выписаны | +| goal.pageName | Диагнозы | +| goal.empty | Диагнозов нет. Все здоровы? Все лгут. | +| goal.namePlaceholder | Ваш диагноз?.. | +| goal.descriptionPlaceholder | Клиническая картина... | +| goal.descriptionLabel | Клиническая картина | +| goal.statusLabel | Стадия | +| goal.drag | Перетащить диагноз | +| goal.completedSection | Закрытые ({{count}}) | +| goal.showCompleted | Показать закрытые | +| goal.hideCompleted | Скрыть закрытые | +| goal.status.planning | Дифференциальный диагноз | +| goal.status.in_progress | Лечим — увидим | +| goal.status.paused | Ждём анализы | +| goal.status.completed | Подтверждён. Я был прав. | +| goal.status.cancelled | Опровергнут | +| goal.cover.choose | Снимок на обложку | +| goal.cover.remove | Убрать снимок | +| goal.cover.uploading | Проявляю снимок... | +| goal.cover.errorSize | Снимок слишком тяжёлый (макс. 2 МБ) | +| goal.cover.errorType | Это не снимок | +| goal.cover.errorUnrecognized | Не могу разобрать, что на снимке | +| goal.cover.errorNetwork | Связь оборвалась. Снимок запомнил, отправлю, когда наладится. | +| goal.cover.viewFull | Рассмотреть снимок | +| goal.editName | Уточнить диагноз | +| goal.notFound | Диагноз не найден. Мы что-то упускаем. | +| goal.deleteConfirmName | Снять диагноз? | +| goal.addToFocus | Взять в работу | +| goal.removeFromFocus | Сбросить Форману | +| goal.emptyActive | Активных диагнозов нет. Скука. | +| goal.emptyPaused | Никто не ждёт анализов. | +| goal.emptyFinished | Ни одного закрытого диагноза. | +| goalFilter.active | В работе | +| goalFilter.paused | Ждут анализов | +| goalFilter.finished | Закрытые | +| focusGoalReplacementDialog.title | Заменить диагноз в работе | +| focusGoalReplacementDialog.message | Вы уже ведёте два диагноза. Какой сбросить Форману? | +| idea.pageName | Озарения | +| idea.namePlaceholder | Что Вас осенило?.. | +| idea.descriptionPlaceholder | Записывайте, пока не забыли... | +| idea.empty | Озарений нет. Сходите поболтать с Уилсоном. | +| idea.drag | Перетащить озарение | +| idea.deleteLabel | Забыть озарение | +| idea.deleteConfirmName | Забыть озарение? | +| category.empty | Отделений нет. В больнице полный Хаос. | +| category.drag | Перенести отделение | +| category.notFound | Отделение не найдено. Кадди закрыла? | +| category.editName | Переименовать отделение | +| category.deleteLabel | Закрыть отделение | +| search.placeholder | Введите симптомы... | +| search.tasks | Пациенты | +| search.goals | Диагнозы | +| search.ideas | Озарения | +| search.noResults | Ничего. Это не волчанка. Это никогда не волчанка. | +| search.emptyQuery | Назовите хотя бы один симптом. | +| filter.inbox | Приёмный покой | +| filter.categories | Отделения | +| filter.goals | Диагнозы | +| filter.focused_goals | В работе | +| filter.ideas | Озарения | +| filter.tasks | Пациенты | +| filter.completed | Выписанные | +| filter.search | Диагностика | +| filter.memos | Советы Уилсона | +| filter.deleted | Морг | +| settings.name | Администрация | +| settings.defaultBox | Палата по умолчанию | +| settings.fullSyncConfirmName | Созвать консилиум? | +| settings.fullSyncConfirmDescription | Устройство и сервер сверят все карты пациентов. При расхождении прав тот, кто говорил последним. Обычно это я. | +| settings.fullSyncStepReuploadFiles | Заново отправляю снимки на сервер | +| settings.fullSyncStepUploadFiles | Отправляю новые снимки | +| settings.fullSyncStepPush | Докладываю серверу о пациентах | +| settings.fullSyncStepPull | Слушаю, что скажет сервер | +| settings.fullSyncStepDownloadFiles | Получаю снимки с сервера | +| settings.fullSyncSuccess | Консилиум окончен. Все со мной согласны. | +| settings.fullSyncError | Консилиум сорвался. Ничего нового. | +| settings.fullSyncStart | Созвать консилиум | +| settings.disconnectConfirmName | Распустить консилиум? | +| settings.disconnectConfirmDescription | Приложение перестанет сверяться с сервером. Карты пациентов останутся у Вас на устройстве. | +| settings.menuOrderHint | Тащите, чтобы изменить порядок. Выключенное исчезает из панели. Кадди не узнает. | +| settings.dayBoundary | Начало смены | +| settings.dayBoundaryDescription | Время начала Вашей смены. Отложенные на завтра пациенты поступают в этот час. | +| settings.sections.tasks | Пациенты | +| settings.sections.accountSync | Контракт с больницей | +| selector.goal | Диагноз | +| selector.category | Отделение | +| selector.hide | Карантин | +| selector.noGoal | Без диагноза | +| selector.noCategory | Вне отделений | +| repeat.none | Без рецидивов | +| taskEdit.tabChecklist | Назначения | +| taskEdit.tabChecklistProgress | Назначения ({{completed}}/{{total}}) | +| taskEdit.fieldDescription | Анамнез | +| taskEdit.fieldBox | Палата | +| taskEdit.fieldRepeat | Хроническое | +| taskEdit.descriptionPlaceholder | Дописать анамнез... | +| taskEdit.newChecklistItemPlaceholder | МРТ, пункция, посев... | +| taskEdit.activeSection | Назначено ({{count}}) | +| taskEdit.doneSection | Выполнено ({{count}}) | +| taskEdit.deleteConfirmName | Пациента в морг? | +| taskEdit.duplicateButton | Клонировать пациента | +| theme.system | Как решит Кадди | +| theme.light | Плацебо | +| theme.dark | Ночная смена | +| taskDetail.emptyState | Выберите пациента для осмотра | +| taskDetail.delete | В морг | +| memo.pageName | Советы Уилсона | +| memo.empty | Уилсон молчит. Плохой знак. | +| memo.notFound | Уилсон такого не говорил. | +| deleted.pageName | Морг | +| deleted.empty | Морг пуст. Все выжили. Подозрительно. | +| deleted.sectionEmpty | Пусто. Даже странно. | +| deleted.tasks | Пациенты | +| deleted.goals | Диагнозы | +| deleted.categories | Отделения | +| deleted.checklists | Назначения | +| deleted.ideas | Озарения | +| deleted.checklistParent | Пациент: {{task}} | +| deleted.restoreAriaLabel | Реанимировать {{name}} | +| deleted.purgeButton | Кремация | +| deleted.purgeConfirmName | Кремировать всё? | +| deleted.purgeConfirmMessage | Обратной дороги нет даже у Хауса. Записи исчезнут с сервера и со всех Ваших устройств. | +| deleted.purgeConfirmCount | Будет кремировано: {{items}} | +| deleted.purgeCountTasks_one | {{count}} пациент | +| deleted.purgeCountTasks_few | {{count}} пациента | +| deleted.purgeCountTasks_many | {{count}} пациентов | +| deleted.purgeCountGoals_one | {{count}} диагноз | +| deleted.purgeCountGoals_few | {{count}} диагноза | +| deleted.purgeCountGoals_many | {{count}} диагнозов | +| deleted.purgeCountCategories_one | {{count}} отделение | +| deleted.purgeCountCategories_few | {{count}} отделения | +| deleted.purgeCountCategories_many | {{count}} отделений | +| deleted.purgeCountChecklistItems_one | {{count}} назначение | +| deleted.purgeCountChecklistItems_few | {{count}} назначения | +| deleted.purgeCountChecklistItems_many | {{count}} назначений | +| deleted.purgeCountIdeas_one | {{count}} озарение | +| deleted.purgeCountIdeas_few | {{count}} озарения | +| deleted.purgeCountIdeas_many | {{count}} озарений | +| deleted.purgeConfirm | Кремировать | +| deleted.purgeError | Даже кремация не сработала. Впечатляет. | +| deleted.purgingInProgress | Кремация... | +| sync.syncing | Сверяю карты... | +| sync.synced | Сходится. Я прав. Как обычно. | +| sync.noConnection | Сервер молчит. Все лгут — этот хотя бы молчит. | +| sync.serverError | Сервер ошибся. С кем не бывает. Со мной — не бывает. | +| sync.projectPaused | Проект в коме | +| sync.alertTitle | Осложнения | +| error.title | Мы что-то упускаем | +| error.description | Непредвиденная ошибка. Дифференциальный диагноз короткий: перезагрузка. | +| error.reload | Разряд! | +| commandBar.placeholder.inbox | Кто поступил?.. | +| commandBar.placeholder.today | Что у нас критичного?.. | +| commandBar.placeholder.week | Под наблюдение... | +| commandBar.placeholder.later | В лист ожидания... | +| commandBar.placeholder.goal | Новый диагноз... | +| commandBar.placeholder.idea | Меня осенило... | +| commandBar.placeholder.category | Новое отделение... | +| commandBar.filterByBox | Фильтр по палате | +| commandBar.toggleHiddenItems | Показать/скрыть карантин | +| attachment.empty | Снимков нет. Даже рентгена пожалели. | +| attachment.confirmDelete | Выбросить снимок? | +| attachment.confirmDeleteMessage | Снимки не реанимируют. | +| attachment.lightbox.previewNotAvailable | На экран такое не выведешь. Скачивайте. | +| attachment.lightbox.loadError | Снимок не загрузился. Томограф барахлит. | +| attachment.attach.button | Прикрепить снимок | +| attachment.attach.errorSize | Снимок слишком тяжёлый (макс. 5 МБ) | +| attachment.attach.errorType | Такое наш томограф не выдаёт | +| attachment.attach.errorUnrecognized | Не могу разобрать, что на снимке | +| attachment.dropZone.hint | Бросайте снимки сюда | +| share.inviteMessage | Все лгут. Задачи — нет. Попробуй Clear Progress! | +| pwa.newVersionAvailable | Новая версия. Люди не меняются. Приложения — да. | +| onboarding.goalName | Пройти интернатуру | +| onboarding.goalDescription | Разберитесь с пациентами ниже. Когда освоитесь — снимите этот диагноз. | +| onboarding.task1Name | Проведите по пациенту вправо или отметьте галочкой, чтобы выписать | +| onboarding.task2Name | Нажмите на пациента для быстрого осмотра | +| onboarding.task3Name | Нажмите на пациента и держите — откроется полный осмотр | +| onboarding.task4Name | Загляните в администрацию в боковой панели | +| onboarding.task5Name | Включите нужные пункты меню в администрации | +| onboarding.dialogTitle | Добро пожаловать в Принстон-Плейнсборо | +| onboarding.dialogBody | Начнём с учебного случая? Создадим диагноз с пациентами, на которых можно освоить основные приёмы. | +| onboarding.dialogAccept | Взять случай | +| onboarding.dialogDecline | Скучно | + +#### Scenario: Inventory matches file exactly +- **WHEN** `house.json` is flattened (excluding `_meta.*`) +- **THEN** its key-value set equals this table exactly (no extra, no missing keys) diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/i18n/spec.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/i18n/spec.md new file mode 100644 index 00000000..a206e6c0 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/specs/i18n/spec.md @@ -0,0 +1,19 @@ +# Capability: i18n (delta) + +## ADDED Requirements + +### Requirement: Dialect locales inherit base language plural rules + +Dialect locales (where `_meta.baseLanguage` differs from the locale code) SHALL select CLDR plural forms using the plural rules of their base language. Dialect codes are not valid BCP 47 languages, so `Intl.PluralRules` would otherwise degrade them to root rules where every count maps to `other`, making plural overrides in dialect files unreachable. The rule applies both to keys overridden in the dialect file and to keys resolved via fallback. # implements FR10 of rework-house-locale + +#### Scenario: Dialect plural override uses base language forms +- **WHEN** translating a key overridden in "house" (baseLanguage "ru") with count 21 +- **THEN** the `_one` form of the house override is used (e.g. «21 пациент») + +#### Scenario: Dialect fallback keys keep base language plural forms +- **WHEN** translating a key absent from "house" (e.g. `repeat.intervalDays`) with count 3 in locale "house" +- **THEN** the Russian `_few` form is used («Интервал: 3 дня») + +#### Scenario: Base locales are unaffected +- **WHEN** translating a plural key in locale "ru" or "en" +- **THEN** plural forms are selected by that locale's own rules diff --git a/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/tasks.md b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/tasks.md new file mode 100644 index 00000000..11f259a0 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-13-rework-house-locale/tasks.md @@ -0,0 +1,31 @@ +# Tasks: rework-house-locale + +## 1. Behavior spec (RED) + +- [x] 1.1 Write `packages/client/src/test/features/i18n/house_locale.feature` — Gherkin scenarios tagged `@rework-house-locale` + `@FR1`…`@FR8`: no redundant overrides vs `ru.json` (FR1), no orphan keys (FR1), glossary page names and deletion verbs (FR2, FR3), accessibility keys absent (FR4), repair/config keys absent (FR5), placeholder parity for every override (FR6), `_meta` intact (FR7), capitalized «Вы» forms (FR8), flattened inventory equals the normative table from `specs/house-locale/spec.md` (no extra / no missing keys) +- [x] 1.2 Write step definitions `house_locale.steps.ts` (vitest-cucumber) that import `house.json` + `ru.json` directly with a local flatten helper (importing from `scripts/i18n-check` would cross module boundaries); encode the normative inventory as a fixture module derived from the spec table +- [x] 1.3 Run the new suite only (`npx vitest run src/test/features/i18n/house-locale`) — confirm scenarios FAIL against the current locale (red) + +## 2. Locale rewrite (GREEN) + +- [x] 2.1 Rewrite `packages/client/src/locales/house.json`: keep `_meta`, replace all overrides with the normative inventory from `specs/house-locale/spec.md`, delete every key not in the inventory (implements FR1–FR8) +- [x] 2.2 Run the new suite again — confirm all scenarios PASS (green) + +## 3. Verification + +- [x] 3.1 Run `i18n-check` from `packages/client` — 0 `override-orphans`, 0 `undefined` errors (M1) +- [x] 3.2 Run the existing i18n unit/BDD suite (one command, foreground) — locale registry still discovers `house` with valid `_meta` (NFR-A1 fallback path unchanged) +- [x] 3.3 Call `get_file_problems` for changed files and run `pnpm run build` — no errors +- [x] 3.4 Note: Stryker not applicable — no production TS changed (JSON content + tests only); do not launch mutation runs + +## 4. Purge count pluralization (FR9, added after smoke feedback) + +- [x] 4.1 RED: write `packages/client/src/test/features/i18n/purge_count_agreement.feature` + steps — scenarios for ru/house/en counts (0, 1, 2, 5, 21) via `i18n.t("deleted.purgeCount*", { count, lng })`, plus composed `purgeConfirmCount` with `{{items}}`; update `house_locale.inventory.ts` with new plural keys; run the two suites — confirm FAIL +- [x] 4.2 GREEN: add per-entity plural keys (`deleted.purgeCountTasks`…`purgeCountIdeas`) to `ru.json` (`_one`/`_few`/`_many`) and `en.json` (`_one`/`_other`), switch `purgeConfirmCount` to `{{items}}` in both; override themed entities in `house.json` (contexts fall back) +- [x] 4.3 GREEN: compose the six counts in `DeletedPage.tsx` and pass as `items` to `deleted.purgeConfirmCount` +- [x] 4.4 GREEN: dialect plural-rule inheritance (FR10) — wrap `services.pluralResolver.getRule` in `i18n.ts` to resolve rules via `_meta.baseLanguage` (without it `Intl.PluralRules("house")` gives root rules and house plural overrides are unreachable); add a fallback-key scenario to the feature +- [x] 4.5 Verify: both new suites green, full i18n suite green, `i18n:check` OK, `get_file_problems` clean, `pnpm run build` passes + +## 5. Manual smoke (single pass, not a test plan) + +- [x] 5.1 Switch app language to «Доктор Хаус» and walk boxes/goals/ideas/deleted/settings pages once to confirm the narrative reads coherently and nothing renders a raw key (incl. purge dialog counts) diff --git a/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/.openspec.yaml b/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/.openspec.yaml new file mode 100644 index 00000000..64105fc9 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-14 diff --git a/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/design.md b/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/design.md new file mode 100644 index 00000000..43c3c6e5 --- /dev/null +++ b/openspec/changes/archive/2026/07/2026-07-14-add-project-locales/design.md @@ -0,0 +1,49 @@ +# Design: add-project-locales + +## Context + +`en-project.json` and `ru-project.json` are the third and fourth dialect locales, and the first *terminology* dialects: unlike the themed house/startrek locales they carry no voice, no metaphors, no jokes — only a single-term substitution (goal → project / «цель» → «проект») for users accustomed to GTD vocabulary. The mechanics are unchanged and already proven: base-language fallback, dialect plural-rule inheritance (`applyDialectPluralRules`), auto-discovery by the locale registry. All decisions below were made with the user during the explore session (2026-07-14). Context: driven by FR1–FR7 from proposal. + +## Goals / Non-Goals + +**Goals:** +- A mechanically derivable override set: exactly the base keys whose value mentions the term — nothing editorial to maintain. +- Twin files governed by one rule set and one content-test suite. +- Zero ambiguity between GTD projects and the Supabase project in all locales. + +**Non-Goals:** +- No i18n runtime or tooling changes; no memo theming; no wider GTD glossary; no changes to house/startrek content. + +## Decisions + +### D1: One capability for both dialects + +Both files implement the same substitution rule and differ only in language; they are proposed, tested, and will evolve together. One capability spec (`project-locales`) with two normative inventories beats two near-identical specs. Rejected: `en-project-locale` + `ru-project-locale` as separate capabilities (duplicated rules, double maintenance for no benefit). + +### D2: Inventory derived mechanically from base values + +The override set is defined by a rule, not by taste: a base key belongs to the inventory iff its value matches the term regex — `/\bgoals?\b/i` for en, `/(?= 2 named segments (i.e., at least one dot before the trailing dot). Single-segment dotted prefixes (e.g., `repeat.`) SHALL NOT protect keys from being reported as unused — their enum values must be covered by explicit whitelist entries instead. + +Non-dotted prefixes SHALL continue to match when the rest is digits-only (e.g., `repeat.month` protects `repeat.month7`). + +Keys found ONLY in test files SHALL be reported as unused with detail "found ONLY in tests — likely a dead key". + +#### Scenario: Key in en.json not referenced anywhere +- **WHEN** `en.json` contains key `"nav.oldFeature"` and no source file references it +- **THEN** an error `{ kind: "unused", key: "nav.oldFeature" }` is reported + +#### Scenario: Key matched by multi-segment dynamic prefix is not reported unused +- **WHEN** `en.json` contains `"goal.status.in_progress"` and source has `` `goal.status.${status}` `` +- **THEN** no `unused` error is reported (prefix `"goal.status."` has 2 segments, rest `"in_progress"` has no dots) + +#### Scenario: Single-segment dotted prefix does NOT protect keys +- **WHEN** `en.json` contains `"repeat.frequency"` and source has `` `repeat.${freq}` `` producing prefix `"repeat."` +- **THEN** an `unused` error IS reported for `"repeat.frequency"` (prefix has only 1 named segment) + +#### Scenario: Key with shared non-dotted prefix but non-digit rest is still reported +- **WHEN** `en.json` contains `"repeat.monthAndDay"` and prefix `"repeat.month"` exists from `` `repeat.month${m}` `` +- **THEN** an `unused` error IS reported (rest `"AndDay"` is not digits-only) + +#### Scenario: Whitelisted key is not reported unused regardless of prefix rules +- **WHEN** `en.json` contains `"repeat.daily"` and whitelist has a `oneOf("repeat.", ["daily", ...])` pattern +- **THEN** no `unused` error is reported + +#### Scenario: Key found only in test files is reported as unused +- **WHEN** `en.json` contains `"repeat.monthAndDay"` and it appears only in test files (not in production code) +- **THEN** an error `{ kind: "unused", key: "repeat.monthAndDay", detail: "found ONLY in tests — likely a dead key" }` is reported + +### Requirement: Detect locale parity violations (FR3) + +The system SHALL report an error of kind `parity` for every base key that exists in `en.json` but not in `ru.json`, or vice versa. + +#### Scenario: Key in en missing from ru +- **WHEN** base key `"feature.new"` exists in `en.json` but not in `ru.json` +- **THEN** an error `{ kind: "parity", key: "feature.new", detail: "present in en, missing in ru" }` is reported + +#### Scenario: Key in ru missing from en +- **WHEN** base key `"legacy.removed"` exists in `ru.json` but not in `en.json` +- **THEN** an error `{ kind: "parity", key: "legacy.removed", detail: "present in ru, missing in en" }` is reported + +### Requirement: Detect override orphan keys (FR4) + +The system SHALL report an error of kind `override-orphans` for every base key in an override locale (where `_meta.baseLanguage` differs from locale code) that does not exist in its base locale, excluding `_meta.*` keys. + +#### Scenario: Override key absent from base locale +- **WHEN** `house.json` has `_meta.baseLanguage: "ru"` and contains key `"repeat.day1"` which is absent from `ru.json` +- **THEN** an error `{ kind: "override-orphans", key: "repeat.day1" }` is reported + +#### Scenario: _meta keys are excluded from orphan check +- **WHEN** `house.json` contains `"_meta.baseLanguage"` and `"_meta.name"` +- **THEN** no `override-orphans` error is reported for these keys + +### Requirement: Report duplicate values (FR5) + +The system SHALL always report groups of keys that have identical values in both `en.json` and `ru.json` as candidates for deduplication, UNLESS all keys in the group are covered by a duplicate-whitelist entry. This report is printed on every run (informational, does not cause a non-zero exit code). + +Groups where every key matches a duplicate-whitelist pattern SHALL be suppressed from the output. + +#### Scenario: Keys with same value in both locales are grouped +- **WHEN** `en.json` has `"task.cancel": "Cancel"` and `"goal.cancel": "Cancel"`, and `ru.json` has both as `"Отмена"` +- **AND** neither key is in the duplicate whitelist +- **THEN** a duplicate group `["task.cancel", "goal.cancel"]` is reported + +#### Scenario: Keys with same en value but different ru values are not grouped +- **WHEN** `en.json` has `"repeat.month4": "April"` and `"repeat.monthGenitive4": "April"`, but `ru.json` has `"апрель"` and `"апреля"` +- **THEN** no duplicate group is reported for these keys + +#### Scenario: Whitelisted duplicate group is suppressed +- **WHEN** `en.json` has `"box.inbox": "Inbox"` and `"filter.inbox": "Inbox"` with identical RU values +- **AND** both keys match a duplicate-whitelist pattern +- **THEN** no duplicate group is reported for these keys + +### Requirement: Duplicate whitelist for intentional duplicates + +The system SHALL support a duplicate-value whitelist — a set of key patterns that are expected to have duplicate values across namespaces. The whitelist SHALL be defined in `whitelist.ts` alongside the existing unused-key whitelist. + +Whitelisted categories: +1. **Domain navigation terms**: keys across `box`, `section`, `filter`, `search`, `deleted`, `task`, `repeat`, `goalFilter`, `idea.pageName`, `memo.pageName`, `settings.sections` that share values for the same domain concept +2. **Semantic pairs**: keys with different UI roles (display label vs ariaLabel, indicator vs legend, button label vs type label) that happen to share the same text + +#### Scenario: Domain term duplicate is whitelisted +- **WHEN** `box.inbox` and `filter.inbox` both have value "Inbox"/"Входящие" +- **THEN** both keys match a duplicate-whitelist pattern and the group is suppressed + +#### Scenario: Semantic pair duplicate is whitelisted +- **WHEN** `settings.name` and `settings.settingsAriaLabel` both have value "Settings"/"Настройки" +- **THEN** both keys match a duplicate-whitelist pattern and the group is suppressed + +#### Scenario: Non-whitelisted duplicate is still reported +- **WHEN** two keys have identical values but are not in the duplicate whitelist +- **THEN** the duplicate group is reported as before + +### Requirement: Duplicate whitelist self-validation + +The system SHALL verify that every duplicate-whitelist pattern matches at least one existing key in `en.json`. A pattern that matches zero keys SHALL be reported as an error, consistent with the existing unused-key whitelist validation (FR8). + +#### Scenario: Stale duplicate-whitelist pattern triggers error +- **WHEN** a duplicate-whitelist pattern matches zero keys in `en.json` +- **THEN** an error is reported indicating the whitelist entry is stale + +### Requirement: Whitelist self-validation (FR8) + +The system SHALL verify that every whitelist pattern matches at least one existing key in `en.json`. A pattern that matches zero keys SHALL be reported as an error. + +#### Scenario: Stale whitelist pattern triggers error +- **WHEN** a whitelist pattern `/^legacy\./` matches zero keys in `en.json` +- **THEN** an error is reported indicating the whitelist entry is stale + +#### Scenario: sync.alert whitelist pattern is valid +- **WHEN** the whitelist contains pattern `^sync\.alert\.` and `en.json` contains `sync.alert.repeat_rule_reset` +- **THEN** no stale whitelist error is reported + +### Requirement: CI enforcement of locale consistency + +The CI pipeline SHALL run `i18n:check` before building. Any `undefined`, `unused`, `parity`, or `override-orphans` error SHALL cause the CI job to fail, preventing deployment of code with locale drift. + +#### Scenario: CI blocks deploy when undefined key exists +- **WHEN** a PR introduces code referencing a key not present in `en.json` +- **AND** CI runs `i18n:check` +- **THEN** the CI job exits with code 1 and the PR cannot be merged + +#### Scenario: CI passes when all locale checks are clean +- **WHEN** all locale keys are consistent +- **AND** CI runs `i18n:check` +- **THEN** the CI job exits with code 0 + +### Requirement: CLI interface (UX1-UX3) + +The system SHALL provide a CLI entrypoint that: +- Exits with code 1 when any check error is found +- Exits with code 0 when no errors are found +- Prints each error as `[kind] key — detail` +- Prints summary count on failure +- Prints `i18n-check: OK` on success +- Always prints the duplicates report (informational section after errors) + +#### Scenario: Errors found — exit code 1 +- **WHEN** the check finds 3 errors +- **THEN** CLI prints 3 error lines, a summary `i18n-check: 3 errors`, and exits with code 1 + +#### Scenario: No errors — exit code 0 +- **WHEN** the check finds 0 errors +- **THEN** CLI prints `i18n-check: OK` and exits with code 0 + +### Requirement: Test fixture isolation from production scan + +Fixture strings in the i18n-check tool's own test files SHALL NOT be able to +affect real-project check results. Specifically, no string fixture in +`src/test/i18n-check/*.test.ts` may coincide (after plural/ordinal +normalization via `toBaseKey`) with a key present in `en.json`. Fixture +strings under real namespaces that do NOT correspond to existing keys are +permitted (e.g., negative-test fixtures like `"repeat.frequency"` for a +deleted key), because `checkUnused` treats test-only literals as unused +candidates and `checkUndefined` ignores test-only literals — such fixtures +cannot shield or falsely flag anything. + +Exception: fixtures that verify real WHITELIST behavior (e.g., `isWhitelisted("repeat.daily")`) +are permitted to use live keys, documented in an explicit allow-list with justification. + +#### Scenario: Fixture coinciding with a live key is forbidden +- **WHEN** `en.json` contains `task.cancel` and a tool test file contains the string `"task.cancel"` +- **THEN** the fixture-isolation test fails, naming the file and the offending string + +#### Scenario: Fixture under a real namespace but absent from en.json is allowed +- **WHEN** `en.json` does not contain `repeat.frequency` and a tool test file contains the string `"repeat.frequency"` +- **THEN** the fixture-isolation test passes + +#### Scenario: Whitelisted fixture for WHITELIST verification is allowed +- **WHEN** a test verifies `isWhitelisted("repeat.daily")` against the real WHITELIST +- **THEN** `"repeat.daily"` is permitted via the explicit allow-list with a comment justifying its presence diff --git a/openspec/specs/i18n/spec.md b/openspec/specs/i18n/spec.md index eb42fa98..47df74dd 100644 --- a/openspec/specs/i18n/spec.md +++ b/openspec/specs/i18n/spec.md @@ -114,7 +114,7 @@ When a locale has `baseLanguage` different from its `code`, i18next fallback SHA ### Requirement: Translation files are complete -Every translation key present in any locale file MUST exist in all other locale files (excluding `_meta`). Nested keys are compared structurally. +Every translation key present in any locale file MUST exist in all other locale files (excluding `_meta`). Nested keys are compared structurally. Additionally, every key referenced by production code via `t()` or `messageKey` MUST exist in all locale files. #### Scenario: All keys present in both locales - **GIVEN** ru.json has keys "nav.inbox", "nav.goals" @@ -126,6 +126,55 @@ Every translation key present in any locale file MUST exist in all other locale - **AND** en.json does not have key "nav.inbox" - **THEN** translation completeness check fails listing "nav.inbox" as missing in en +#### Scenario: Self-healing alert keys exist in all locales +- **WHEN** `healingRules.ts` references `sync.alert.repeat_rule_reset`, `sync.alert.name_set_untitled`, `sync.alert.checklist_item_deleted` +- **THEN** all three keys SHALL exist in `en.json` and `ru.json` +- **AND** each key SHALL contain a human-readable message describing what the self-healing action did + +#### Scenario: No unused keys remain in locale files +- **WHEN** running `i18n:check` +- **THEN** zero `unused` errors SHALL be reported +- **AND** zero `override-orphans` errors SHALL be reported for `house.json` + +### Requirement: Common namespace for shared UI labels + +Locale files SHALL contain a `common` top-level namespace with shared UI labels that are used identically across multiple domain namespaces. Components SHALL reference `common.*` keys instead of duplicating the same label under each domain namespace. + +The `common` namespace SHALL include at minimum: `cancel`, `back`, `delete`, `close`, `next`, `save`, `loading`, `name`, `taskCount`, `details`, `attachments`. + +#### Scenario: Component uses common.cancel instead of domain-specific key +- **WHEN** a component needs a "Cancel" button label +- **THEN** it SHALL use `t("common.cancel")` instead of a domain-specific key like `t("goal.cancel")` + +#### Scenario: Common keys exist in all locale files +- **WHEN** `en.json` contains `common.cancel` +- **THEN** `ru.json` SHALL also contain `common.cancel` with the corresponding translation + +#### Scenario: Domain-specific keys replaced by common keys are removed +- **WHEN** `common.cancel` is defined +- **THEN** keys `task.cancel`, `goal.cancel`, `focusGoalReplacementDialog.cancel`, `idea.deleteConfirmCancel`, `settings.fullSyncCancel`, `settings.disconnectCancel`, `settings.server.cancel`, `taskEdit.deleteConfirmCancel`, `deleted.purgeCancel`, `confirmDialog.cancel` SHALL NOT exist in locale files + +#### Scenario: saveName keys replaced by common.saveName +- **WHEN** `common.saveName` is defined +- **THEN** keys `context.saveName` and `category.saveName` SHALL NOT exist in locale files +- **AND** `EntityDetailLayout` consumers (`ContextDetailPage`, `CategoryDetailPage`) SHALL pass `"common.saveName"` via `i18nKeys.saveName` + +### Requirement: Dialect locales inherit base language plural rules + +Dialect locales (where `_meta.baseLanguage` differs from the locale code) SHALL select CLDR plural forms using the plural rules of their base language. Dialect codes are not valid BCP 47 languages, so `Intl.PluralRules` would otherwise degrade them to root rules where every count maps to `other`, making plural overrides in dialect files unreachable. The rule applies both to keys overridden in the dialect file and to keys resolved via fallback. # implements FR10 of rework-house-locale + +#### Scenario: Dialect plural override uses base language forms +- **WHEN** translating a key overridden in "house" (baseLanguage "ru") with count 21 +- **THEN** the `_one` form of the house override is used (e.g. «21 пациент») + +#### Scenario: Dialect fallback keys keep base language plural forms +- **WHEN** translating a key absent from "house" (e.g. `repeat.intervalDays`) with count 3 in locale "house" +- **THEN** the Russian `_few` form is used («Интервал: 3 дня») + +#### Scenario: Base locales are unaffected +- **WHEN** translating a plural key in locale "ru" or "en" +- **THEN** plural forms are selected by that locale's own rules + ### Requirement: Pluralization follows CLDR rules System SHALL support pluralization via `count` parameter. Russian uses `_one`, `_few`, `_many` suffixes. English uses `_one`, `_other` suffixes. i18next selects the correct suffix based on CLDR plural rules. @@ -166,3 +215,15 @@ System SHALL provide utility functions: `getLocaleByCode(code)` returns locale m - **GIVEN** locales with baseLanguage "en", "ru", "ru" - **WHEN** calling getBaseLanguageCodes - **THEN** returns ["en", "ru"] (deduplicated) + +### Requirement: Locale consistency is enforced automatically + +The i18n system SHALL include an automated consistency check that runs as part of the test suite (`pnpm test`) and detects undefined keys, unused keys, parity violations, and override orphans. The check SHALL be available as a standalone command (`pnpm i18n:check`). + +#### Scenario: CI catches locale drift +- **WHEN** a developer pushes code that references a key not present in `en.json` +- **THEN** the test suite fails with a descriptive error identifying the missing key + +#### Scenario: Standalone check command +- **WHEN** a developer runs `pnpm i18n:check` in the client package +- **THEN** the script validates locale consistency and reports results diff --git a/openspec/specs/project-locales/spec.md b/openspec/specs/project-locales/spec.md new file mode 100644 index 00000000..13eb7931 --- /dev/null +++ b/openspec/specs/project-locales/spec.md @@ -0,0 +1,150 @@ +# Capability: project-locales + +## Purpose + +Content rules for the twin GTD-terminology dialect locales `packages/client/src/locales/en-project.json` (base `en`) and `packages/client/src/locales/ru-project.json` (base `ru`): goal → project / «цель» → «проект», nothing else. + +## Requirements + +### Requirement: Locales contain only differing overrides (FR1) + +`en-project.json` and `ru-project.json` SHALL contain only keys whose values differ from the corresponding base (`en.json` / `ru.json`) values. Keys with values identical to base SHALL be absent (covered by fallback). Every non-`_meta` key SHALL exist in the base file (no orphans). + +#### Scenario: No redundant overrides +- **WHEN** every non-`_meta` key of each dialect file is compared with its base file +- **THEN** no key has a value identical to its base value + +#### Scenario: No orphan keys +- **WHEN** `i18n-check` runs +- **THEN** no `override-orphans` errors are reported for `en-project.json` or `ru-project.json` + +### Requirement: Override set is exactly the term-mentioning keys (FR2) + +The override set of each dialect SHALL be exactly the base keys whose value matches the term regex — `/\bgoals?\b/i` for `en.json`, `/(?): boolean { + for (const prefix of prefixes) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + if (prefix.endsWith(".")) { + // Umbrella prefix for an entire namespace ("repeat.") is dangerous: + // it hides dead single-segment keys. Require >= 2 named segments. + const named = prefix.slice(0, -1); + if (!named.includes(".")) continue; + if (rest.length > 0 && !rest.includes(".")) return true; + } else if (/^\d+$/.test(rest)) { + return true; + } + } + return false; +} + +export function checkUndefined( + enLocale: LocaleData, + scan: ScanResult, +): CheckError[] { + const errors: CheckError[] = []; + for (const key of scan.literalKeys) { + if (scan.literalKeysTestOnly.has(key)) continue; + const baseKey = toBaseKey(key); + if (enLocale.baseKeys.has(baseKey) || enLocale.baseKeys.has(key)) continue; + if (isWhitelisted(baseKey)) continue; + errors.push({ + kind: "undefined", + key, + detail: "used in production code but missing from en.json", + }); + } + return errors; +} + +export function checkUnused( + enLocale: LocaleData, + scan: ScanResult, +): CheckError[] { + const errors: CheckError[] = []; + for (const baseKey of enLocale.baseKeys) { + if (scan.literalKeys.has(baseKey) && !scan.literalKeysTestOnly.has(baseKey)) + continue; + if (isWhitelisted(baseKey)) continue; + if (matchesDynamicPrefix(baseKey, scan.dynamicPrefixes)) continue; + const isTestOnly = scan.literalKeysTestOnly.has(baseKey); + errors.push({ + kind: "unused", + key: baseKey, + detail: isTestOnly + ? "found ONLY in tests — likely a dead key" + : "not found by literal or dynamic patterns", + }); + } + return errors; +} + +export function checkParity( + enLocale: LocaleData, + ruLocale: LocaleData, +): CheckError[] { + const errors: CheckError[] = []; + for (const key of enLocale.baseKeys) { + if (!ruLocale.baseKeys.has(key)) + errors.push({ + kind: "parity", + key, + detail: "present in en, missing in ru", + }); + } + for (const key of ruLocale.baseKeys) { + if (!enLocale.baseKeys.has(key)) + errors.push({ + kind: "parity", + key, + detail: "present in ru, missing in en", + }); + } + return errors; +} + +export function checkOverrideOrphans( + override: LocaleData, + base: LocaleData, +): CheckError[] { + const errors: CheckError[] = []; + for (const key of override.baseKeys) { + if (key.startsWith("_meta")) continue; + if (!base.baseKeys.has(key)) { + errors.push({ + kind: "override-orphans", + key, + detail: `${override.code}.json overrides a key missing from ${base.code}.json`, + }); + } + } + return errors; +} diff --git a/packages/client/scripts/i18n-check/cli.ts b/packages/client/scripts/i18n-check/cli.ts new file mode 100644 index 00000000..15794cbe --- /dev/null +++ b/packages/client/scripts/i18n-check/cli.ts @@ -0,0 +1,21 @@ +import { runAllChecks } from "./run"; + +const { errors, duplicateGroups } = runAllChecks(); + +if (duplicateGroups.size > 0) { + console.log("\n--- Duplicate values (informational) ---"); + for (const [value, keys] of duplicateGroups) { + console.log(` "${value}": ${keys.join(", ")}`); + } + console.log(""); +} + +if (errors.length > 0) { + for (const error of errors) { + console.error(`[${error.kind}] ${error.key} — ${error.detail}`); + } + console.error(`\ni18n-check: ${errors.length} errors`); + process.exit(1); +} + +console.log("i18n-check: OK"); diff --git a/packages/client/scripts/i18n-check/duplicates.ts b/packages/client/scripts/i18n-check/duplicates.ts new file mode 100644 index 00000000..b715a2f0 --- /dev/null +++ b/packages/client/scripts/i18n-check/duplicates.ts @@ -0,0 +1,38 @@ +import type { LocaleData } from "./types"; +import { isDuplicateWhitelisted } from "./whitelist"; + +/** Groups of keys whose values match in both en and ru. */ +export function findDuplicateGroups( + en: LocaleData, + ru: LocaleData, +): Map { + const byEnValue = new Map(); + + for (const [key, value] of Object.entries(en.flat)) { + const normalizedValue = value.trim().toLowerCase(); + if (!normalizedValue) continue; + byEnValue.set(normalizedValue, [ + ...(byEnValue.get(normalizedValue) ?? []), + key, + ]); + } + + const result = new Map(); + + for (const [value, keys] of byEnValue) { + if (keys.length < 2) continue; + + const ruValues = new Set( + keys.map((key) => ru.flat[key]?.trim().toLowerCase()), + ); + + if (ruValues.size === 1 && !ruValues.has(undefined as unknown as string)) { + const isAllWhitelisted = keys.every(isDuplicateWhitelisted); + if (!isAllWhitelisted) { + result.set(value, keys); + } + } + } + + return result; +} diff --git a/packages/client/scripts/i18n-check/flatten.ts b/packages/client/scripts/i18n-check/flatten.ts new file mode 100644 index 00000000..fda6d14e --- /dev/null +++ b/packages/client/scripts/i18n-check/flatten.ts @@ -0,0 +1,27 @@ +import type { FlatMap } from "./types"; + +const PLURAL_RE = /_(one|two|few|many|other|zero)$/; +const ORDINAL_RE = /_ordinal_(one|two|few|many|other|zero)$/; + +export function flatten(obj: object, prefix = ""): FlatMap { + const out: FlatMap = {}; + for (const [key, value] of Object.entries(obj)) { + const flatKey = prefix ? `${prefix}.${key}` : key; + if (value !== null && typeof value === "object") { + Object.assign(out, flatten(value, flatKey)); + } else { + out[flatKey] = String(value); + } + } + return out; +} + +/** "repeat.everyNDays_few" -> "repeat.everyNDays"; + * "repeat.yearlyDate_ordinal_two" -> "repeat.yearlyDate" */ +export function toBaseKey(key: string): string { + return key.replace(ORDINAL_RE, "").replace(PLURAL_RE, ""); +} + +export function toBaseKeySet(flat: FlatMap): Set { + return new Set(Object.keys(flat).map(toBaseKey)); +} diff --git a/packages/client/scripts/i18n-check/index.ts b/packages/client/scripts/i18n-check/index.ts new file mode 100644 index 00000000..885555f9 --- /dev/null +++ b/packages/client/scripts/i18n-check/index.ts @@ -0,0 +1,24 @@ +export { + checkOverrideOrphans, + checkParity, + checkUndefined, + checkUnused, +} from "./checks"; +export { findDuplicateGroups } from "./duplicates"; +export { flatten, toBaseKey, toBaseKeySet } from "./flatten"; +export type { CheckResult } from "./run"; +export { loadLocale, runAllChecks } from "./run"; +export { isTestFile, scanSources } from "./scan"; +export type { + CheckError, + CheckKind, + FlatMap, + LocaleData, + ScanResult, +} from "./types"; +export { + DUPLICATE_WHITELIST, + isDuplicateWhitelisted, + isWhitelisted, + WHITELIST, +} from "./whitelist"; diff --git a/packages/client/scripts/i18n-check/run.ts b/packages/client/scripts/i18n-check/run.ts new file mode 100644 index 00000000..21bc0e6a --- /dev/null +++ b/packages/client/scripts/i18n-check/run.ts @@ -0,0 +1,127 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + checkOverrideOrphans, + checkParity, + checkUndefined, + checkUnused, +} from "./checks"; +import { findDuplicateGroups } from "./duplicates"; +import { flatten, toBaseKeySet } from "./flatten"; +import { scanSources } from "./scan"; +import type { CheckError, FlatMap, LocaleData } from "./types"; +import { DUPLICATE_WHITELIST, WHITELIST } from "./whitelist"; + +const CLIENT_ROOT = process.cwd(); +const LOCALES_DIR = join(CLIENT_ROOT, "src/locales"); +const SRC_DIR = join(CLIENT_ROOT, "src"); + +function removeMeta(flat: FlatMap): FlatMap { + const result: FlatMap = {}; + for (const [key, value] of Object.entries(flat)) { + if (!key.startsWith("_meta.") && key !== "_meta") { + result[key] = value; + } + } + return result; +} + +export function loadLocale(code: string): LocaleData { + const raw = JSON.parse( + readFileSync(join(LOCALES_DIR, `${code}.json`), "utf-8"), + ); + const baseLanguage: string = raw._meta?.baseLanguage ?? code; + const allFlat = flatten(raw); + const flat = removeMeta(allFlat); + return { code, baseLanguage, flat, baseKeys: toBaseKeySet(flat) }; +} + +function collectSourceFiles(directory: string): string[] { + const results: string[] = []; + const entries = readdirSync(directory, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(directory, entry.name); + if (entry.isDirectory()) { + if (fullPath === LOCALES_DIR) continue; + results.push(...collectSourceFiles(fullPath)); + } else if (/\.(ts|tsx)$/.test(entry.name)) { + results.push(fullPath); + } + } + return results; +} + +function validateWhitelistEntries( + entries: ReadonlyArray<{ pattern: RegExp; reason: string }>, + allKeys: string[], + label: string, +): CheckError[] { + const errors: CheckError[] = []; + for (const entry of entries) { + const hasMatch = allKeys.some((key) => entry.pattern.test(key)); + if (!hasMatch) { + errors.push({ + kind: "unused", + key: entry.pattern.source, + detail: `stale ${label} pattern: ${entry.reason}`, + }); + } + } + return errors; +} + +function validateWhitelists( + enBaseKeys: Set, + enFlat: FlatMap, +): CheckError[] { + const allKeys = [...enBaseKeys, ...Object.keys(enFlat)]; + return [ + ...validateWhitelistEntries(WHITELIST, allKeys, "whitelist"), + ...validateWhitelistEntries( + DUPLICATE_WHITELIST, + allKeys, + "duplicate whitelist", + ), + ]; +} + +export interface CheckResult { + errors: CheckError[]; + duplicateGroups: Map; +} + +export function runAllChecks(): CheckResult { + const codes = readdirSync(LOCALES_DIR) + .filter((file) => file.endsWith(".json")) + .map((file) => file.replace(".json", "")); + const locales = new Map(codes.map((code) => [code, loadLocale(code)])); + const enLocale = locales.get("en"); + const ruLocale = locales.get("ru"); + if (!enLocale || !ruLocale) throw new Error("en.json and ru.json must exist"); + + const enRaw = JSON.parse(readFileSync(join(LOCALES_DIR, "en.json"), "utf-8")); + const topLevelNamespaces = new Set( + Object.keys(enRaw).filter((key) => key !== "_meta"), + ); + + const sourceFiles = collectSourceFiles(SRC_DIR); + const scanResult = scanSources(sourceFiles, topLevelNamespaces); + + const errors: CheckError[] = [ + ...checkUndefined(enLocale, scanResult), + ...checkUnused(enLocale, scanResult), + ...checkParity(enLocale, ruLocale), + ...validateWhitelists(enLocale.baseKeys, enLocale.flat), + ]; + + for (const locale of locales.values()) { + if (locale.baseLanguage !== locale.code) { + const baseLocale = locales.get(locale.baseLanguage); + if (baseLocale) errors.push(...checkOverrideOrphans(locale, baseLocale)); + } + } + + const duplicateGroups = findDuplicateGroups(enLocale, ruLocale); + + return { errors, duplicateGroups }; +} diff --git a/packages/client/scripts/i18n-check/scan.ts b/packages/client/scripts/i18n-check/scan.ts new file mode 100644 index 00000000..7b5b49a4 --- /dev/null +++ b/packages/client/scripts/i18n-check/scan.ts @@ -0,0 +1,58 @@ +import { readFileSync } from "node:fs"; +import type { ScanResult } from "./types"; + +/** Literal key: two or more dot-separated segments of letters/digits/underscore */ +const LITERAL_KEY_RE = + /['"`]([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+)['"`]/g; + +/** + * Dynamic prefix: template literal like `goal.status.${s}` + * Captures the static part before `${`. Requires at least one dot. + */ +const DYNAMIC_PREFIX_RE = /`([A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]*)+)\$\{/g; + +const TEST_FILE_RE = /(\.test\.|\.spec\.|\/test\/|\/__mocks__\/)/; + +export function isTestFile(filePath: string): boolean { + return TEST_FILE_RE.test(filePath); +} + +export function scanSources( + files: string[], + topLevelNamespaces: Set, +): ScanResult { + const productionKeys = new Set(); + const testKeys = new Set(); + const dynamicPrefixes = new Set(); + + for (const file of files) { + const fileContent = readFileSync(file, "utf-8"); + const targetSet = isTestFile(file) ? testKeys : productionKeys; + + for (const match of fileContent.matchAll(LITERAL_KEY_RE)) { + const key = match[1] ?? ""; + const topLevelSegment = key.split(".")[0] ?? ""; + if (topLevelNamespaces.has(topLevelSegment)) { + targetSet.add(key); + } + } + + for (const match of fileContent.matchAll(DYNAMIC_PREFIX_RE)) { + const prefix = match[1] ?? ""; + const topLevelSegment = prefix.split(".")[0] ?? ""; + if (topLevelNamespaces.has(topLevelSegment)) { + dynamicPrefixes.add(prefix); + } + } + } + + const literalKeysTestOnly = new Set( + [...testKeys].filter((key) => !productionKeys.has(key)), + ); + + return { + literalKeys: new Set([...productionKeys, ...testKeys]), + dynamicPrefixes, + literalKeysTestOnly, + }; +} diff --git a/packages/client/scripts/i18n-check/types.ts b/packages/client/scripts/i18n-check/types.ts new file mode 100644 index 00000000..a93e0fcb --- /dev/null +++ b/packages/client/scripts/i18n-check/types.ts @@ -0,0 +1,22 @@ +export type FlatMap = Record; // "a.b.c" -> value + +export interface LocaleData { + code: string; // "en" | "ru" | "house" | ... + baseLanguage: string; // from _meta + flat: FlatMap; // without _meta + baseKeys: Set; // keys after plural suffix normalization +} + +export interface ScanResult { + literalKeys: Set; // "task.cancel", "goal.status.in_progress" + dynamicPrefixes: Set; // "repeat.month", "goal.status." — from `...${}` + literalKeysTestOnly: Set; // keys found ONLY in test files +} + +export type CheckKind = "undefined" | "unused" | "parity" | "override-orphans"; + +export interface CheckError { + kind: CheckKind; + key: string; + detail: string; // human-readable explanation +} diff --git a/packages/client/scripts/i18n-check/whitelist.ts b/packages/client/scripts/i18n-check/whitelist.ts new file mode 100644 index 00000000..730ccfeb --- /dev/null +++ b/packages/client/scripts/i18n-check/whitelist.ts @@ -0,0 +1,180 @@ +/** + * Dynamic keys that cannot be found by literal search. + * Each pattern MUST have a comment with the usage location in code. + * The script also verifies that each pattern matches at least one + * existing key — stale whitelist entries become errors themselves. + */ + +function escapeRe(source: string): string { + return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function range(prefix: string, from: number, to: number): RegExp { + const numbers = Array.from( + { length: to - from + 1 }, + (_, index) => index + from, + ); + return new RegExp(`^${escapeRe(prefix)}(${numbers.join("|")})$`); +} + +function oneOf(prefix: string, values: string[]): RegExp { + return new RegExp(`^${escapeRe(prefix)}(${values.join("|")})$`); +} + +export const WHITELIST: ReadonlyArray<{ pattern: RegExp; reason: string }> = [ + // RepeatRuleSelector.tsx — t(`repeat.${freq}`) + { + pattern: oneOf("repeat.", ["daily", "weekly", "monthly", "yearly"]), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`repeat.${freq}`)", + }, + // RepeatRuleSelector.tsx — t(`repeat.weekday${day}`) + { + pattern: range("repeat.weekday", 1, 7), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`repeat.weekday${day}`)", + }, + // RepeatRuleSelector.tsx — t(`repeat.month${m}`) + { + pattern: range("repeat.month", 1, 12), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`repeat.month${m}`)", + }, + // RepeatRuleSelector.tsx, utils.ts, repeatRule.ts + { + pattern: range("repeat.monthGenitive", 1, 12), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`repeat.monthGenitive${m}`)", + }, + // LookAndFeelSection.tsx — t(`theme.${scheme}`) + { + pattern: oneOf("theme.", ["light", "dark", "system"]), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`theme.${scheme}`)", + }, + // AccentColorSection.tsx — t(`color.${color}`) + { + pattern: oneOf("color.", [ + "blue", + "coral", + "green", + "indigo", + "orange", + "purple", + "yellow", + ]), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`color.${color}`)", + }, + // GoalsPage.tsx — t(`goalFilter.${filter}`) + { + pattern: oneOf("goalFilter.", ["all", "active", "paused", "finished"]), + // biome-ignore lint/suspicious/noTemplateCurlyInString: describes a template pattern, not a real template + reason: "t(`goalFilter.${filter}`)", + }, + // healingRules.ts — messageKey: "sync.alert.*" + { + pattern: /^sync\.alert\./, + reason: "healingRules.ts messageKey", + }, +]; + +export function isWhitelisted(baseKey: string): boolean { + return WHITELIST.some((entry) => entry.pattern.test(baseKey)); +} + +/** + * Duplicate groups where ALL keys match a pattern are suppressed from output. + * Implements FR4, FR5 of deduplicate-i18n-common-keys. + */ +export const DUPLICATE_WHITELIST: ReadonlyArray<{ + pattern: RegExp; + reason: string; +}> = [ + // FR4 of deduplicate-i18n-common-keys: domain navigation terms + { + pattern: /^(box|section|filter)\.inbox$/, + reason: "domain navigation term: inbox", + }, + { + pattern: /^(box|section|task|repeat)\.today$/, + reason: "domain navigation term: today", + }, + { + pattern: /^(box|section)\.later$/, + reason: "domain navigation term: later", + }, + { + pattern: /^(box|goalFilter)\.all$/, + reason: "domain navigation term: all", + }, + { + pattern: /^(task\.yesterday|section\.completedYesterday)$/, + reason: "domain navigation term: yesterday", + }, + { + pattern: /^((search|filter|deleted)\.tasks|settings\.sections\.tasks)$/, + reason: "domain navigation term: tasks", + }, + { + pattern: /^(search|filter|deleted)\.goals$/, + reason: "domain navigation term: goals", + }, + { + pattern: /^((search|filter|deleted)\.ideas|idea\.pageName)$/, + reason: "domain navigation term: ideas", + }, + { + pattern: /^(filter|deleted)\.contexts$/, + reason: "domain navigation term: contexts", + }, + { + pattern: /^(filter|deleted)\.categories$/, + reason: "domain navigation term: categories", + }, + { + pattern: /^(filter\.memos|memo\.pageName)$/, + reason: "domain navigation term: memos", + }, + { + pattern: /^(filter\.deleted|deleted\.pageName)$/, + reason: "domain navigation term: deleted", + }, + // FR5 of deduplicate-i18n-common-keys: semantic pair patterns + { + pattern: /^settings\.(name|settingsAriaLabel)$/, + reason: "semantic pair: display label vs aria label", + }, + { + pattern: /^settings\.(login|loginAriaLabel)$/, + reason: "semantic pair: display label vs aria label", + }, + { + pattern: /^(settings\.pinDetailPanel|taskDetail\.pin)$/, + reason: "semantic pair: settings toggle vs panel button", + }, + { + pattern: /^(settings\.unpinDetailPanel|taskDetail\.unpin)$/, + reason: "semantic pair: settings toggle vs panel button", + }, + { + pattern: /^settings\.(syncIndicator|syncLegend)$/, + reason: "semantic pair: status indicator vs legend label", + }, + { + pattern: /^settings\.server\.(connectSupabase|typeSupabase)$/, + reason: "semantic pair: connect button vs provider type label", + }, + { + pattern: /^(settings\.disconnectConfirm|settings\.server\.disconnect)$/, + reason: "semantic pair: confirm action vs server disconnect", + }, + { + pattern: /^(goal\.status|goalFilter)\.paused$/, + reason: "semantic pair: entity status vs filter label", + }, +]; + +export function isDuplicateWhitelisted(key: string): boolean { + return DUPLICATE_WHITELIST.some((entry) => entry.pattern.test(key)); +} diff --git a/packages/client/src/app/providers/AlertProvider.test.tsx b/packages/client/src/app/providers/AlertProvider.test.tsx index 1da64488..fbc1d441 100644 --- a/packages/client/src/app/providers/AlertProvider.test.tsx +++ b/packages/client/src/app/providers/AlertProvider.test.tsx @@ -10,7 +10,10 @@ function createWrapper() { }; } -const syncAlert: AppAlert = { type: "sync", messageKey: "sync.error" }; +const syncAlert: AppAlert = { + type: "sync", + messageKey: "sync.alert.repeat_rule_reset", +}; const repeatAlert: AppAlert = { type: "repeat_rule_invalid", taskNames: ["Task A"], @@ -68,7 +71,7 @@ describe("AlertProvider", () => { it("should maintain insertion order within same alert type", () => { const syncAlert2: AppAlert = { type: "sync", - messageKey: "sync.conflict", + messageKey: "sync.alert.name_set_untitled", }; const { result } = renderHook(() => useAlerts(), { wrapper: createWrapper(), diff --git a/packages/client/src/components/alerts/AlertOverlay.test.tsx b/packages/client/src/components/alerts/AlertOverlay.test.tsx index add93f66..ca3e9d6b 100644 --- a/packages/client/src/components/alerts/AlertOverlay.test.tsx +++ b/packages/client/src/components/alerts/AlertOverlay.test.tsx @@ -13,13 +13,12 @@ vi.mock("react-i18next", () => mockReactI18next()); const SYNC_ALERT: AppAlert = { type: "sync", - messageKey: "sync.fkCleared", - params: { entityType: "goal", name: "My Goal" }, + messageKey: "sync.alert.repeat_rule_reset", }; const SYNC_ALERT_2: AppAlert = { type: "sync", - messageKey: "sync.nameUntitled", + messageKey: "sync.alert.name_set_untitled", }; const REPEAT_ALERT: AppAlert = { @@ -264,7 +263,7 @@ describe("AlertOverlay", () => { it("should render sync alert message for sync type", () => { renderWithAlerts([SYNC_ALERT]); const message = screen.getByTestId("alert-message"); - expect(message).toHaveTextContent("sync.fkCleared"); + expect(message).toHaveTextContent("sync.alert.repeat_rule_reset"); }); it("should render title for repeat_rule_invalid type", () => { diff --git a/packages/client/src/components/alerts/AlertOverlay.tsx b/packages/client/src/components/alerts/AlertOverlay.tsx index 357be7ae..1354cdda 100644 --- a/packages/client/src/components/alerts/AlertOverlay.tsx +++ b/packages/client/src/components/alerts/AlertOverlay.tsx @@ -196,7 +196,7 @@ export function AlertOverlay() { })} className={BUTTON_STYLE} > - {t("alert.back")} + {t("common.back")} )} {!isLast && ( @@ -211,7 +211,7 @@ export function AlertOverlay() { })} className={BUTTON_STYLE} > - {t("alert.next")} + {t("common.next")} )} {isLast && ( diff --git a/packages/client/src/components/goals/FocusGoalReplacementDialog.tsx b/packages/client/src/components/goals/FocusGoalReplacementDialog.tsx index c4fd9df5..60395db6 100644 --- a/packages/client/src/components/goals/FocusGoalReplacementDialog.tsx +++ b/packages/client/src/components/goals/FocusGoalReplacementDialog.tsx @@ -139,7 +139,7 @@ export function FocusGoalReplacementDialog({ onClick={onClose} className="w-full rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 transition-colors" > - {t("focusGoalReplacementDialog.cancel")} + {t("common.cancel")} diff --git a/packages/client/src/components/goals/GoalAttachmentsTab.tsx b/packages/client/src/components/goals/GoalAttachmentsTab.tsx index 1b60c852..929740f0 100644 --- a/packages/client/src/components/goals/GoalAttachmentsTab.tsx +++ b/packages/client/src/components/goals/GoalAttachmentsTab.tsx @@ -2,8 +2,6 @@ import { ENTITY_TYPE } from "@/constants"; import { EntityAttachments } from "../shared/EntityAttachments"; -const I18N_PREFIX = "goal.attachments"; - interface GoalAttachmentsTabProps { goalId: string; } @@ -12,11 +10,7 @@ interface GoalAttachmentsTabProps { export function GoalAttachmentsTab({ goalId }: GoalAttachmentsTabProps) { return (

- +
); } diff --git a/packages/client/src/components/goals/GoalCardEditMode.test.tsx b/packages/client/src/components/goals/GoalCardEditMode.test.tsx index 9f576c2a..0c284848 100644 --- a/packages/client/src/components/goals/GoalCardEditMode.test.tsx +++ b/packages/client/src/components/goals/GoalCardEditMode.test.tsx @@ -97,7 +97,7 @@ describe("GoalCardEditMode compact tabs", () => { it("should show label text on the active details tab", () => { render(); const detailsTab = screen.getByTestId("goal-tab-details"); - expect(detailsTab).toHaveTextContent("goal.tabs.details"); + expect(detailsTab).toHaveTextContent("common.details"); }); // FR5: Inactive details tab hides label text @@ -105,7 +105,7 @@ describe("GoalCardEditMode compact tabs", () => { render(); fireEvent.click(screen.getByTestId("goal-tab-attachments")); const detailsTab = screen.getByTestId("goal-tab-details"); - expect(detailsTab).not.toHaveTextContent("goal.tabs.details"); + expect(detailsTab).not.toHaveTextContent("common.details"); }); // FR5: Active attachments tab shows label text @@ -113,14 +113,14 @@ describe("GoalCardEditMode compact tabs", () => { render(); fireEvent.click(screen.getByTestId("goal-tab-attachments")); const attachmentsTab = screen.getByTestId("goal-tab-attachments"); - expect(attachmentsTab).toHaveTextContent("goal.tabs.attachments"); + expect(attachmentsTab).toHaveTextContent("common.attachments"); }); // FR5: Inactive attachments tab hides label text it("should hide label text on inactive attachments tab", () => { render(); const attachmentsTab = screen.getByTestId("goal-tab-attachments"); - expect(attachmentsTab).not.toHaveTextContent("goal.tabs.attachments"); + expect(attachmentsTab).not.toHaveTextContent("common.attachments"); }); // FR4: Attachments tab shows count badge when count > 0 (both active and inactive) @@ -181,7 +181,7 @@ describe("GoalCardEditMode compact tabs", () => { render(); fireEvent.click(screen.getByTestId("goal-tab-attachments")); const attachmentsTab = screen.getByTestId("goal-tab-attachments"); - expect(attachmentsTab).toHaveTextContent("goal.tabs.attachments"); + expect(attachmentsTab).toHaveTextContent("common.attachments"); expect(attachmentsTab).toHaveTextContent("5"); const badge = attachmentsTab.querySelector("[aria-label]"); expect(badge).not.toBeNull(); diff --git a/packages/client/src/components/goals/GoalCardEditMode.tsx b/packages/client/src/components/goals/GoalCardEditMode.tsx index 3a31b3d2..68fb4eb5 100644 --- a/packages/client/src/components/goals/GoalCardEditMode.tsx +++ b/packages/client/src/components/goals/GoalCardEditMode.tsx @@ -84,7 +84,7 @@ export function GoalCardEditMode({ />