Files
GearBox/.planning/phases/34-i18n-foundation/34-03-PLAN.md

16 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
34-i18n-foundation 03 execute 2
01
02
src/client/lib/formatters.ts
src/client/hooks/useFormatters.ts
src/client/hooks/useLanguage.ts
tests/formatters.test.ts
true
D-04
D-09
D-10
truths artifacts key_links
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'
path provides contains
src/client/lib/formatters.ts Locale-aware formatWeight and formatPrice functions Intl.NumberFormat
path provides exports
src/client/hooks/useLanguage.ts Language preference hook
useLanguage
path provides contains
src/client/hooks/useFormatters.ts Extended formatters with locale useLanguage
path provides min_lines
tests/formatters.test.ts Tests for locale-aware formatting 30
from to via pattern
src/client/hooks/useFormatters.ts src/client/hooks/useLanguage.ts useLanguage() import 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_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 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:

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:

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`:
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. <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> 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:

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). <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> 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`:
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). <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> 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`:
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. <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> 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`:
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. <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> cd /home/jlmak/Projects/jlmak/GearBox && bun test tests/formatters.test.ts Formatter tests pass for both locales

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

<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>
After completion, create `.planning/phases/34-i18n-foundation/34-03-SUMMARY.md`