468 lines
16 KiB
Markdown
468 lines
16 KiB
Markdown
---
|
|
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>
|