docs(34): create phase plans for i18n foundation

This commit is contained in:
2026-04-13 18:10:36 +02:00
parent e2127ebb84
commit 24304aa8aa
5 changed files with 1888 additions and 0 deletions

View File

@@ -0,0 +1,467 @@
---
phase: 34-i18n-foundation
plan: 03
type: execute
wave: 2
depends_on: [01, 02]
files_modified:
- src/client/lib/formatters.ts
- src/client/hooks/useFormatters.ts
- src/client/hooks/useLanguage.ts
- tests/formatters.test.ts
autonomous: true
requirements: [D-04, D-09, D-10]
must_haves:
truths:
- "formatPrice() uses Intl.NumberFormat with locale parameter for locale-aware currency display"
- "formatWeight() uses locale parameter for locale-aware number formatting"
- "useFormatters() hook returns locale-aware weight and price formatters"
- "useLanguage() hook reads language from settings and returns the current locale string"
- "German locale formats prices as '1.234,56 EUR' not '$1,234.56'"
- "English locale formats prices as '$1,234.56' not '1.234,56 EUR'"
artifacts:
- path: "src/client/lib/formatters.ts"
provides: "Locale-aware formatWeight and formatPrice functions"
contains: "Intl.NumberFormat"
- path: "src/client/hooks/useLanguage.ts"
provides: "Language preference hook"
exports: ["useLanguage"]
- path: "src/client/hooks/useFormatters.ts"
provides: "Extended formatters with locale"
contains: "useLanguage"
- path: "tests/formatters.test.ts"
provides: "Tests for locale-aware formatting"
min_lines: 30
key_links:
- from: "src/client/hooks/useFormatters.ts"
to: "src/client/hooks/useLanguage.ts"
via: "useLanguage() import"
pattern: "useLanguage"
---
<objective>
Make weight and price formatting locale-aware and create the useLanguage() hook.
Purpose: Formatting integration — numbers, currencies, and weights display according to the user's locale (e.g., German: "1.234,56 EUR" vs English: "$1,234.56").
Output: Locale-aware formatters, useLanguage hook, formatter tests.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/34-i18n-foundation/34-CONTEXT.md
@.planning/phases/34-i18n-foundation/34-RESEARCH.md
<interfaces>
Current formatters.ts:
```typescript
export type WeightUnit = "g" | "oz" | "lb" | "kg";
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string { ... }
export type Currency = "USD" | "EUR" | "GBP" | "JPY" | "CAD" | "AUD";
export function formatPrice(cents: number | null | undefined, currency: Currency = "USD"): string { ... }
```
Current useFormatters.ts:
```typescript
export function useFormatters() {
const unit = useWeightUnit();
const currency = useCurrency();
return {
weight: (grams: number | null) => formatWeight(grams, unit),
price: (cents: number | null) => formatPrice(cents, currency),
unit,
currency,
};
}
```
Current useWeightUnit.ts pattern:
```typescript
export function useWeightUnit(): WeightUnit {
const { data } = useSetting("weightUnit");
if (data && VALID_UNITS.includes(data as WeightUnit)) return data as WeightUnit;
return "g";
}
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Create useLanguage hook</name>
<files>src/client/hooks/useLanguage.ts</files>
<read_first>src/client/hooks/useWeightUnit.ts, src/client/hooks/useCurrency.ts, src/client/hooks/useSettings.ts</read_first>
<behavior>
- useLanguage() reads from useSetting("language")
- Returns "en" when setting is null, undefined, or invalid
- Returns "de" when setting value is "de"
- Validates against VALID_LANGUAGES array ["en", "de"]
- Exports VALID_LANGUAGES array
</behavior>
<action>
Create `src/client/hooks/useLanguage.ts`:
```typescript
import { useSetting } from "./useSettings";
export const VALID_LANGUAGES = ["en", "de"] as const;
export type Language = (typeof VALID_LANGUAGES)[number];
export function useLanguage(): Language {
const { data } = useSetting("language");
if (data && VALID_LANGUAGES.includes(data as Language)) {
return data as Language;
}
return "en";
}
```
This follows the exact same pattern as `useWeightUnit()` and `useCurrency()` per established project conventions.
</action>
<acceptance_criteria>
- src/client/hooks/useLanguage.ts exists
- File exports useLanguage function
- File exports VALID_LANGUAGES array containing "en" and "de"
- useLanguage returns "en" as default fallback
- Pattern matches useWeightUnit (useSetting, validation, default)
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|VALID_LANGUAGES\|useSetting" src/client/hooks/useLanguage.ts</automated>
</verify>
<done>useLanguage hook created following established settings hook pattern</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Make formatPrice locale-aware using Intl.NumberFormat</name>
<files>src/client/lib/formatters.ts</files>
<read_first>src/client/lib/formatters.ts</read_first>
<behavior>
- formatPrice gains a third parameter: locale (string, defaults to "en")
- formatPrice uses new Intl.NumberFormat(locale, { style: "currency", currency }) instead of manual symbol lookup
- formatPrice("en", "USD", 123456) returns "$1,234.56"
- formatPrice("de", "EUR", 123456) returns "1.234,56 €"
- formatPrice("en", "JPY", 10000) returns "¥100" (no decimals)
- formatPrice(null) still returns "--"
- CURRENCY_SYMBOLS constant can be removed (Intl handles symbols)
</behavior>
<action>
Update `src/client/lib/formatters.ts`:
Replace the `formatPrice` function with:
```typescript
export function formatPrice(
cents: number | null | undefined,
currency: Currency = "USD",
locale = "en",
): string {
if (cents == null) return "--";
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
minimumFractionDigits: currency === "JPY" ? 0 : 2,
maximumFractionDigits: currency === "JPY" ? 0 : 2,
}).format(cents / 100);
}
```
Remove the `CURRENCY_SYMBOLS` constant and its `Record<Currency, string>` type — they are replaced by `Intl.NumberFormat`.
Keep the `Currency` type export and the existing values ("USD", "EUR", "GBP", "JPY", "CAD", "AUD").
**NOTE:** The `locale` parameter defaults to `"en"` so existing callers that don't pass locale continue to work (backward compatible).
</action>
<acceptance_criteria>
- formatPrice function signature has 3 parameters: cents, currency, locale
- formatPrice contains `new Intl.NumberFormat(locale`
- CURRENCY_SYMBOLS constant is removed from the file
- formatPrice(null) returns "--"
- formatPrice(12345, "USD", "en") produces "$123.45"
- formatPrice(12345, "EUR", "de") produces a string containing "123,45" and "€"
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts && grep -c "CURRENCY_SYMBOLS" src/client/lib/formatters.ts</automated>
</verify>
<done>formatPrice uses Intl.NumberFormat for locale-aware currency formatting</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Make formatWeight locale-aware</name>
<files>src/client/lib/formatters.ts</files>
<read_first>src/client/lib/formatters.ts</read_first>
<behavior>
- formatWeight gains a third parameter: locale (string, defaults to "en")
- formatWeight uses Intl.NumberFormat for the number part, then appends the unit suffix
- formatWeight(1234, "g", "en") returns "1,234g" (with thousands separator)
- formatWeight(1234, "g", "de") returns "1.234g" (German thousands separator is period)
- formatWeight(null) still returns "--"
- Unit suffixes remain as-is (g, oz, lb, kg are universal abbreviations)
</behavior>
<action>
Update `formatWeight` in `src/client/lib/formatters.ts`:
```typescript
export function formatWeight(
grams: number | null | undefined,
unit: WeightUnit = "g",
locale = "en",
): string {
if (grams == null) return "--";
let value: number;
let fractionDigits: number;
switch (unit) {
case "g":
value = Math.round(grams);
fractionDigits = 0;
break;
case "oz":
value = grams / GRAMS_PER_OZ;
fractionDigits = 1;
break;
case "lb":
value = grams / GRAMS_PER_LB;
fractionDigits = 2;
break;
case "kg":
value = grams / GRAMS_PER_KG;
fractionDigits = 2;
break;
}
const formatted = new Intl.NumberFormat(locale, {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(value);
return unit === "g" ? `${formatted}g` : `${formatted} ${unit}`;
}
```
This preserves the existing behavior (unit conversion math, decimal places per unit) but adds locale-aware number formatting (thousands separators, decimal separators).
</action>
<acceptance_criteria>
- formatWeight function signature has 3 parameters: grams, unit, locale
- formatWeight contains Intl.NumberFormat usage
- formatWeight(null) returns "--"
- formatWeight(1234, "g", "en") produces a string ending with "g"
- formatWeight(1234.5, "kg", "de") uses comma as decimal separator
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts</automated>
</verify>
<done>formatWeight uses Intl.NumberFormat for locale-aware number display</done>
</task>
<task type="auto" tdd="true">
<name>Task 4: Update useFormatters hook to pass locale</name>
<files>src/client/hooks/useFormatters.ts</files>
<read_first>src/client/hooks/useFormatters.ts, src/client/hooks/useLanguage.ts, src/client/lib/formatters.ts</read_first>
<behavior>
- useFormatters imports useLanguage
- useFormatters calls useLanguage() to get current locale
- weight formatter passes locale to formatWeight
- price formatter passes locale to formatPrice
- useFormatters return object includes locale property
</behavior>
<action>
Update `src/client/hooks/useFormatters.ts`:
```typescript
import { formatPrice, formatWeight } from "../lib/formatters";
import { useCurrency } from "./useCurrency";
import { useLanguage } from "./useLanguage";
import { useWeightUnit } from "./useWeightUnit";
export function useFormatters() {
const unit = useWeightUnit();
const currency = useCurrency();
const locale = useLanguage();
return {
weight: (grams: number | null) => formatWeight(grams, unit, locale),
price: (cents: number | null) => formatPrice(cents, currency, locale),
unit,
currency,
locale,
};
}
```
This adds `useLanguage` import, passes `locale` to both formatters, and exposes `locale` in the return object for components that need it.
</action>
<acceptance_criteria>
- useFormatters.ts imports useLanguage from "./useLanguage"
- useFormatters calls useLanguage()
- formatWeight call passes locale as third argument
- formatPrice call passes locale as third argument
- Return object includes locale property
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|locale" src/client/hooks/useFormatters.ts</automated>
</verify>
<done>useFormatters hook passes locale to all formatters</done>
</task>
<task type="auto" tdd="true">
<name>Task 5: Write tests for locale-aware formatters</name>
<files>tests/formatters.test.ts</files>
<read_first>src/client/lib/formatters.ts, tests/services/item.service.test.ts</read_first>
<behavior>
- Tests verify formatPrice with "en" locale produces "$" prefix for USD
- Tests verify formatPrice with "de" locale produces "€" suffix for EUR
- Tests verify formatPrice handles null input
- Tests verify formatPrice handles JPY (no decimals)
- Tests verify formatWeight with "en" locale uses comma for thousands
- Tests verify formatWeight with "de" locale uses period for thousands
- Tests verify formatWeight handles null input
- Tests verify formatWeight unit conversions still correct
</behavior>
<action>
Create `tests/formatters.test.ts`:
```typescript
import { describe, expect, test } from "bun:test";
import { formatPrice, formatWeight } from "../src/client/lib/formatters";
describe("formatPrice", () => {
test("returns -- for null", () => {
expect(formatPrice(null)).toBe("--");
});
test("returns -- for undefined", () => {
expect(formatPrice(undefined)).toBe("--");
});
test("formats USD with en locale", () => {
const result = formatPrice(12345, "USD", "en");
expect(result).toContain("123.45");
expect(result).toContain("$");
});
test("formats EUR with de locale", () => {
const result = formatPrice(12345, "EUR", "de");
expect(result).toContain("123,45");
expect(result).toContain("€");
});
test("formats JPY with no decimals", () => {
const result = formatPrice(10000, "JPY", "en");
expect(result).toContain("100");
expect(result).toContain("¥");
expect(result).not.toContain(".");
});
test("formats large amounts with thousands separator en", () => {
const result = formatPrice(123456789, "USD", "en");
expect(result).toContain("1,234,567.89");
});
test("formats large amounts with thousands separator de", () => {
const result = formatPrice(123456789, "EUR", "de");
// German uses period for thousands and comma for decimal
expect(result).toContain("1.234.567,89");
});
test("defaults to en locale when no locale provided", () => {
const result = formatPrice(12345, "USD");
expect(result).toContain("$");
expect(result).toContain("123.45");
});
});
describe("formatWeight", () => {
test("returns -- for null", () => {
expect(formatWeight(null)).toBe("--");
});
test("returns -- for undefined", () => {
expect(formatWeight(undefined)).toBe("--");
});
test("formats grams with en locale", () => {
expect(formatWeight(1234, "g", "en")).toBe("1,234g");
});
test("formats grams with de locale", () => {
expect(formatWeight(1234, "g", "de")).toBe("1.234g");
});
test("formats ounces", () => {
const result = formatWeight(100, "oz", "en");
expect(result).toContain("oz");
expect(result).toContain("3.5");
});
test("formats kilograms", () => {
const result = formatWeight(1500, "kg", "en");
expect(result).toContain("1.50");
expect(result).toContain("kg");
});
test("formats pounds", () => {
const result = formatWeight(1000, "lb", "en");
expect(result).toContain("lb");
expect(result).toContain("2.2");
});
test("defaults to en locale when no locale provided", () => {
const result = formatWeight(1234, "g");
expect(result).toBe("1,234g");
});
});
```
**NOTE:** Intl.NumberFormat output may vary slightly between JS engines (Bun uses JavaScriptCore). The tests use `toContain` for flexible matching where exact format may vary, and `toBe` only where the format is deterministic.
</action>
<acceptance_criteria>
- tests/formatters.test.ts exists
- File contains at least 14 test cases (7 for formatPrice, 7 for formatWeight)
- Tests cover null input, en locale, de locale, default locale
- `bun test tests/formatters.test.ts` passes
</acceptance_criteria>
<verify>
<automated>cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/formatters.test.ts</automated>
</verify>
<done>Formatter tests pass for both locales</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| settings DB→useLanguage | Language preference from DB — validated against VALID_LANGUAGES |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-34-04 | Tampering | useLanguage | mitigate | Validates language value against VALID_LANGUAGES array before returning; invalid values fall back to "en" |
</threat_model>
<verification>
- `bun test tests/formatters.test.ts` passes
- `bun run build` succeeds
- formatPrice produces locale-appropriate output for en and de
- formatWeight produces locale-appropriate output for en and de
- useFormatters hook passes locale to both formatters
</verification>
<success_criteria>
- formatPrice uses Intl.NumberFormat for locale-aware formatting
- formatWeight uses Intl.NumberFormat for locale-aware number display
- useLanguage hook reads language from settings with "en" fallback
- useFormatters hook passes locale to formatters
- All formatter tests pass
</success_criteria>
<output>
After completion, create `.planning/phases/34-i18n-foundation/34-03-SUMMARY.md`
</output>