docs(34): create phase plans for i18n foundation
This commit is contained in:
467
.planning/phases/34-i18n-foundation/34-03-PLAN.md
Normal file
467
.planning/phases/34-i18n-foundation/34-03-PLAN.md
Normal 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>
|
||||
Reference in New Issue
Block a user