---
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>
