--- 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" --- 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. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.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 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"; } ``` Task 1: Create useLanguage hook src/client/hooks/useLanguage.ts src/client/hooks/useWeightUnit.ts, src/client/hooks/useCurrency.ts, src/client/hooks/useSettings.ts - 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 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. - 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) cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|VALID_LANGUAGES\|useSetting" src/client/hooks/useLanguage.ts useLanguage hook created following established settings hook pattern Task 2: Make formatPrice locale-aware using Intl.NumberFormat src/client/lib/formatters.ts src/client/lib/formatters.ts - 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) 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` 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). - 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 "€" cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts && grep -c "CURRENCY_SYMBOLS" src/client/lib/formatters.ts formatPrice uses Intl.NumberFormat for locale-aware currency formatting Task 3: Make formatWeight locale-aware src/client/lib/formatters.ts src/client/lib/formatters.ts - 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) 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). - 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 cd /home/jlmak/Projects/jlmak/GearBox && grep -c "Intl.NumberFormat" src/client/lib/formatters.ts formatWeight uses Intl.NumberFormat for locale-aware number display Task 4: Update useFormatters hook to pass locale src/client/hooks/useFormatters.ts src/client/hooks/useFormatters.ts, src/client/hooks/useLanguage.ts, src/client/lib/formatters.ts - 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 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. - 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 cd /home/jlmak/Projects/jlmak/GearBox && grep -c "useLanguage\|locale" src/client/hooks/useFormatters.ts useFormatters hook passes locale to all formatters Task 5: Write tests for locale-aware formatters tests/formatters.test.ts src/client/lib/formatters.ts, tests/services/item.service.test.ts - 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 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. - 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 cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/formatters.test.ts Formatter tests pass for both locales ## 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" | - `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 - 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 After completion, create `.planning/phases/34-i18n-foundation/34-03-SUMMARY.md`