239 lines
8.8 KiB
Markdown
239 lines
8.8 KiB
Markdown
---
|
|
phase: 07-weight-unit-selection
|
|
plan: 01
|
|
type: tdd
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- src/client/lib/formatters.ts
|
|
- src/client/hooks/useWeightUnit.ts
|
|
- tests/lib/formatters.test.ts
|
|
autonomous: true
|
|
requirements:
|
|
- UNIT-02
|
|
- UNIT-03
|
|
|
|
must_haves:
|
|
truths:
|
|
- "formatWeight converts grams to g, oz, lb, kg with correct precision"
|
|
- "formatWeight defaults to grams when no unit is specified (backward compatible)"
|
|
- "formatWeight handles null/undefined input for all units"
|
|
- "useWeightUnit hook returns a valid WeightUnit from settings, defaulting to 'g'"
|
|
artifacts:
|
|
- path: "src/client/lib/formatters.ts"
|
|
provides: "WeightUnit type export and parameterized formatWeight function"
|
|
exports: ["WeightUnit", "formatWeight", "formatPrice"]
|
|
contains: "WeightUnit"
|
|
- path: "src/client/hooks/useWeightUnit.ts"
|
|
provides: "Convenience hook wrapping useSetting for weight unit"
|
|
exports: ["useWeightUnit"]
|
|
- path: "tests/lib/formatters.test.ts"
|
|
provides: "Unit tests for formatWeight with all 4 units and edge cases"
|
|
min_lines: 30
|
|
key_links:
|
|
- from: "src/client/hooks/useWeightUnit.ts"
|
|
to: "src/client/hooks/useSettings.ts"
|
|
via: "useSetting('weightUnit')"
|
|
pattern: "useSetting.*weightUnit"
|
|
- from: "src/client/hooks/useWeightUnit.ts"
|
|
to: "src/client/lib/formatters.ts"
|
|
via: "imports WeightUnit type"
|
|
pattern: "import.*WeightUnit.*formatters"
|
|
---
|
|
|
|
<objective>
|
|
Create the weight unit conversion core: a parameterized `formatWeight` function with a `WeightUnit` type and a `useWeightUnit` convenience hook, all backed by tests.
|
|
|
|
Purpose: Establish the conversion contracts (type, function, hook) that Plan 02 will wire into every component. TDD approach ensures the conversion math is correct before any UI work.
|
|
Output: Working `formatWeight(grams, unit)` with tests green, `useWeightUnit()` hook ready for consumption.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md
|
|
@/home/jlmak/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/07-weight-unit-selection/07-RESEARCH.md
|
|
|
|
@src/client/lib/formatters.ts
|
|
@src/client/hooks/useSettings.ts
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
|
|
|
From src/client/lib/formatters.ts (current):
|
|
```typescript
|
|
export function formatWeight(grams: number | null | undefined): string {
|
|
if (grams == null) return "--";
|
|
return `${Math.round(grams)}g`;
|
|
}
|
|
|
|
export function formatPrice(cents: number | null | undefined): string {
|
|
if (cents == null) return "--";
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
```
|
|
|
|
From src/client/hooks/useSettings.ts:
|
|
```typescript
|
|
export function useSetting(key: string) {
|
|
return useQuery({
|
|
queryKey: ["settings", key],
|
|
queryFn: async () => {
|
|
try {
|
|
const result = await apiGet<Setting>(`/api/settings/${key}`);
|
|
return result.value;
|
|
} catch (err: any) {
|
|
if (err?.status === 404) return null;
|
|
throw err;
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
export function useUpdateSetting() {
|
|
const queryClient = useQueryClient();
|
|
return useMutation({
|
|
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
|
apiPut<Setting>(`/api/settings/${key}`, { value }),
|
|
onSuccess: (_data, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: ["settings", variables.key] });
|
|
},
|
|
});
|
|
}
|
|
```
|
|
</interfaces>
|
|
|
|
<feature>
|
|
<name>formatWeight unit conversion</name>
|
|
<files>src/client/lib/formatters.ts, tests/lib/formatters.test.ts</files>
|
|
<behavior>
|
|
Conversion constants: 1 oz = 28.3495g, 1 lb = 453.592g, 1 kg = 1000g
|
|
|
|
- formatWeight(100, "g") -> "100g"
|
|
- formatWeight(100, "oz") -> "3.5 oz"
|
|
- formatWeight(1000, "lb") -> "2.20 lb"
|
|
- formatWeight(1500, "kg") -> "1.50 kg"
|
|
- formatWeight(null, "oz") -> "--"
|
|
- formatWeight(undefined, "kg") -> "--"
|
|
- formatWeight(100) -> "100g" (default unit, backward compatible)
|
|
- formatWeight(0, "oz") -> "0.0 oz"
|
|
- formatWeight(5, "lb") -> "0.01 lb" (small weight precision)
|
|
- formatWeight(50000, "kg") -> "50.00 kg" (large weight)
|
|
</behavior>
|
|
<implementation>
|
|
1. Add `WeightUnit` type export: `"g" | "oz" | "lb" | "kg"`
|
|
2. Add conversion constants as module-level consts (not exported)
|
|
3. Modify `formatWeight` signature to `(grams: number | null | undefined, unit: WeightUnit = "g"): string`
|
|
4. Keep the null guard as-is at the top
|
|
5. Add switch statement for unit-specific formatting:
|
|
- g: `Math.round(grams)` + "g" (0 decimals, current behavior)
|
|
- oz: `.toFixed(1)` + " oz" (1 decimal)
|
|
- lb: `.toFixed(2)` + " lb" (2 decimals)
|
|
- kg: `.toFixed(2)` + " kg" (2 decimals)
|
|
6. Do NOT modify `formatPrice` — leave it untouched
|
|
</implementation>
|
|
</feature>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: TDD formatWeight with unit parameter</name>
|
|
<files>src/client/lib/formatters.ts, tests/lib/formatters.test.ts</files>
|
|
<behavior>
|
|
- formatWeight(100, "g") returns "100g"
|
|
- formatWeight(100, "oz") returns "3.5 oz"
|
|
- formatWeight(1000, "lb") returns "2.20 lb"
|
|
- formatWeight(1500, "kg") returns "1.50 kg"
|
|
- formatWeight(null) returns "--" for all units
|
|
- formatWeight(undefined, "kg") returns "--"
|
|
- formatWeight(100) returns "100g" (backward compatible, no second arg)
|
|
- formatWeight(0, "oz") returns "0.0 oz"
|
|
</behavior>
|
|
<action>
|
|
RED: Create `tests/lib/formatters.test.ts`. Import `formatWeight` from `@/client/lib/formatters`. Write tests for:
|
|
- All 4 units with a known gram value (e.g., 1000g = "1000g", "35.3 oz", "2.20 lb", "1.00 kg")
|
|
- Null and undefined input returning "--" for each unit
|
|
- Default parameter (no second arg) producing current "g" behavior
|
|
- Zero grams producing "0g", "0.0 oz", "0.00 lb", "0.00 kg"
|
|
- Precision edge cases (small values like 5g in lb = "0.01 lb")
|
|
|
|
Run tests — they should fail because formatWeight does not accept a unit parameter yet.
|
|
|
|
GREEN: Modify `src/client/lib/formatters.ts`:
|
|
- Export `type WeightUnit = "g" | "oz" | "lb" | "kg"`
|
|
- Add constants: `GRAMS_PER_OZ = 28.3495`, `GRAMS_PER_LB = 453.592`, `GRAMS_PER_KG = 1000`
|
|
- Change signature to `formatWeight(grams: number | null | undefined, unit: WeightUnit = "g")`
|
|
- Add switch statement after the null guard for unit-specific conversion and formatting
|
|
- Leave `formatPrice` completely untouched
|
|
|
|
Run tests — all should pass.
|
|
|
|
REFACTOR: None expected — the code is already minimal.
|
|
</action>
|
|
<verify>
|
|
<automated>bun test tests/lib/formatters.test.ts</automated>
|
|
</verify>
|
|
<done>formatWeight handles all 4 units with correct precision, null handling, and backward-compatible default. WeightUnit type is exported. All tests pass.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create useWeightUnit convenience hook</name>
|
|
<files>src/client/hooks/useWeightUnit.ts</files>
|
|
<action>
|
|
Create `src/client/hooks/useWeightUnit.ts`:
|
|
|
|
```typescript
|
|
import { useSetting } from "./useSettings";
|
|
import type { WeightUnit } from "../lib/formatters";
|
|
|
|
const VALID_UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];
|
|
|
|
export function useWeightUnit(): WeightUnit {
|
|
const { data } = useSetting("weightUnit");
|
|
if (data && VALID_UNITS.includes(data as WeightUnit)) {
|
|
return data as WeightUnit;
|
|
}
|
|
return "g";
|
|
}
|
|
```
|
|
|
|
This hook:
|
|
- Wraps `useSetting("weightUnit")` for a typed return value
|
|
- Validates the stored value is a known unit (protects against bad data)
|
|
- Defaults to "g" when no setting exists (backward compatible — UNIT-03 persistence works via existing settings API)
|
|
- Returns `WeightUnit` type so components can pass directly to `formatWeight`
|
|
</action>
|
|
<verify>
|
|
<automated>bun run lint</automated>
|
|
</verify>
|
|
<done>useWeightUnit hook exists, imports from useSettings and formatters, returns typed WeightUnit with "g" default. Lint passes.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
- `bun test tests/lib/formatters.test.ts` passes with all unit conversion tests green
|
|
- `bun run lint` passes with no errors
|
|
- `src/client/lib/formatters.ts` exports `WeightUnit` type and updated `formatWeight` function
|
|
- `src/client/hooks/useWeightUnit.ts` exists and exports `useWeightUnit`
|
|
- Existing tests still pass: `bun test` (full suite)
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- formatWeight("g") produces identical output to the old function (backward compatible)
|
|
- formatWeight with oz/lb/kg produces correct conversions with appropriate decimal precision
|
|
- WeightUnit type is exported for use by Plan 02 components
|
|
- useWeightUnit hook is ready for components to consume
|
|
- All existing tests remain green (no regressions)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/07-weight-unit-selection/07-01-SUMMARY.md`
|
|
</output>
|