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 |
|
|
true |
|
|
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";
}
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
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
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
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> |
<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>