# Phase 7: Weight Unit Selection - Research

**Researched:** 2026-03-16
**Domain:** Weight unit conversion, display formatting, settings persistence
**Confidence:** HIGH

## Summary

This phase is a display-only concern with a clean architecture. All weight data is already stored in grams (`weight_grams REAL` in SQLite). The task is to: (1) let the user pick a display unit, (2) persist that choice via the existing settings system, and (3) modify `formatWeight()` to convert grams to the selected unit before rendering. The existing `useSetting`/`useUpdateSetting` hooks and `/api/settings/:key` API handle persistence out of the box -- no schema changes or migrations needed.

The codebase has a single `formatWeight(grams)` function in `src/client/lib/formatters.ts` called from exactly 8 components. Every weight display flows through this function, so the conversion is a single-point change. The challenge is threading the unit preference to `formatWeight` -- currently a pure function with no access to React state. The cleanest approach is to add a `unit` parameter and create a `useWeightUnit()` hook that components use to get the current unit, then pass it to `formatWeight`.

**Primary recommendation:** Add a `unit` parameter to `formatWeight(grams, unit)`, create a `useWeightUnit()` convenience hook wrapping `useSetting("weightUnit")`, and place a small unit toggle in the TotalsBar. Keep weight input always in grams -- this is a display-only feature per the requirements and out-of-scope list.

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions
(No locked decisions -- all implementation details are at Claude's discretion)

### Claude's Discretion
- Unit selector placement (TotalsBar, settings page, or elsewhere)
- Pounds display format (traditional "2 lb 3 oz" vs decimal "2.19 lb")
- Precision per unit (decimal places for oz, kg)
- Default unit (grams, matching current behavior)
- How formatWeight gets access to the setting (hook, context, parameter)

### Deferred Ideas (OUT OF SCOPE)
None -- discussion stayed within phase scope
</user_constraints>

<phase_requirements>
## Phase Requirements

| ID | Description | Research Support |
|----|-------------|-----------------|
| UNIT-01 | User can select preferred weight unit (g, oz, lb, kg) from settings | Settings API already exists; `useSetting`/`useUpdateSetting` hooks ready; unit selector component needed in TotalsBar |
| UNIT-02 | All weight displays across the app reflect the selected unit | Single `formatWeight()` function is the sole conversion point; 8 call sites across TotalsBar, ItemCard, CandidateCard, CategoryHeader, SetupCard, ItemPicker, collection route, setup detail route |
| UNIT-03 | Weight unit preference persists across sessions | `settings` table + `/api/settings/:key` upsert endpoint already handle this -- just use key `"weightUnit"` |
</phase_requirements>

## Standard Stack

### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| React 19 | 19.x | UI framework | Already in project |
| TanStack React Query | 5.x | Server state / caching | Already used for all data fetching; `useSetting` hook wraps it |
| Hono | 4.x | API server | Settings routes already exist |
| Drizzle ORM | latest | Database access | Settings table already defined |

### Supporting
No additional libraries needed. This phase requires zero new dependencies.

### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| Parameter-based `formatWeight(g, unit)` | React Context provider | Context adds unnecessary complexity for a single value; parameter is explicit, testable, and avoids re-render cascades |
| Zustand store for unit | `useSetting` hook (React Query) | Unit is server-persisted state, not ephemeral UI state; React Query is the correct layer per project conventions |

## Architecture Patterns

### Recommended Approach

No new files except a small `useWeightUnit` convenience hook. The changes are surgical:

```
src/client/
  lib/
    formatters.ts         # MODIFY: add unit parameter to formatWeight
  hooks/
    useWeightUnit.ts      # NEW: convenience hook wrapping useSetting("weightUnit")
  components/
    TotalsBar.tsx          # MODIFY: add unit toggle control
    ItemCard.tsx           # MODIFY: pass unit to formatWeight
    CandidateCard.tsx      # MODIFY: pass unit to formatWeight
    CategoryHeader.tsx     # MODIFY: pass unit to formatWeight
    SetupCard.tsx          # MODIFY: pass unit to formatWeight
    ItemPicker.tsx         # MODIFY: pass unit to formatWeight
  routes/
    index.tsx              # MODIFY: pass unit to formatWeight
    setups/$setupId.tsx    # MODIFY: pass unit to formatWeight
```

### Pattern 1: Weight Unit Type and Conversion Constants

**What:** Define a `WeightUnit` type and conversion map as a simple module constant.
**When to use:** Everywhere unit-related logic is needed.
**Example:**

```typescript
// In src/client/lib/formatters.ts
export type WeightUnit = "g" | "oz" | "lb" | "kg";

const GRAMS_PER_OZ = 28.3495;
const GRAMS_PER_LB = 453.592;
const GRAMS_PER_KG = 1000;

export function formatWeight(
  grams: number | null | undefined,
  unit: WeightUnit = "g",
): string {
  if (grams == null) return "--";

  switch (unit) {
    case "g":
      return `${Math.round(grams)}g`;
    case "oz":
      return `${(grams / GRAMS_PER_OZ).toFixed(1)} oz`;
    case "lb":
      return `${(grams / GRAMS_PER_LB).toFixed(2)} lb`;
    case "kg":
      return `${(grams / GRAMS_PER_KG).toFixed(2)} kg`;
  }
}
```

### Pattern 2: Convenience Hook

**What:** A thin hook that reads the weight unit setting and returns a typed value with a sensible default.
**When to use:** Any component that calls `formatWeight`.
**Example:**

```typescript
// In src/client/hooks/useWeightUnit.ts
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"; // default matches current behavior
}
```

### Pattern 3: Unit Selector in TotalsBar

**What:** A small segmented control or dropdown in the TotalsBar for switching units.
**When to use:** Global weight unit selection, always visible.
**Example concept:**

```typescript
// Segmented pill buttons in TotalsBar
const UNITS: WeightUnit[] = ["g", "oz", "lb", "kg"];

// Small inline toggle alongside stats
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
  {UNITS.map((u) => (
    <button
      key={u}
      onClick={() => updateSetting.mutate({ key: "weightUnit", value: u })}
      className={`px-2 py-0.5 text-xs rounded-full transition-colors ${
        unit === u
          ? "bg-white text-gray-700 shadow-sm font-medium"
          : "text-gray-400 hover:text-gray-600"
      }`}
    >
      {u}
    </button>
  ))}
</div>
```

### Anti-Patterns to Avoid
- **Converting on the server side:** Database stores grams, API returns grams. Conversion is purely a display concern -- never modify the API layer.
- **Using React Context for a single value:** The project uses React Query for server state. Adding a Context provider for one setting breaks convention and introduces unnecessary complexity.
- **Storing converted values:** Always store grams in the database. The `weightUnit` setting is a display preference, not a data transformation.
- **Changing weight input fields:** The requirements explicitly keep input in grams (see Out of Scope in REQUIREMENTS.md: "Per-item weight input in multiple units" is excluded). Input labels stay as "Weight (g)".

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Setting persistence | Custom localStorage + API sync | Existing `useSetting`/`useUpdateSetting` hooks + settings API | Already handles cache invalidation and server persistence |
| Unit conversion | Complex conversion library | Simple division constants (28.3495, 453.592, 1000) | Only 4 units, all linear conversions from grams -- a library is overkill |

**Key insight:** The entire feature is a ~30-line formatter change + a small UI toggle + updating 8 call sites. No external library is needed.

## Common Pitfalls

### Pitfall 1: Floating-Point Display Precision
**What goes wrong:** Showing too many decimal places (e.g., "42.328947 oz") or too few (e.g., "0 kg" for a 450g item).
**Why it happens:** Different units have different natural precision ranges.
**How to avoid:** Use unit-specific precision: `g` = 0 decimals (round), `oz` = 1 decimal, `lb` = 2 decimals, `kg` = 2 decimals. These match gear community conventions (LighterPack and similar apps use comparable precision).
**Warning signs:** Items showing "0 lb" or "0.0 oz" when they have measurable weight.

### Pitfall 2: Null/Undefined Weight Handling
**What goes wrong:** Conversion math on null values produces NaN or "NaN oz".
**Why it happens:** Many items have `weightGrams: null` (optional field).
**How to avoid:** The existing `if (grams == null) return "--"` guard at the top of `formatWeight` handles this. Keep it as the first check before any unit logic.
**Warning signs:** "NaN" or "undefined oz" appearing in the UI.

### Pitfall 3: Forgetting a Call Site
**What goes wrong:** One component still shows grams while everything else shows the selected unit.
**Why it happens:** `formatWeight` is called in 8 different files. Missing one is easy.
**How to avoid:** Grep for all `formatWeight` call sites. The complete list is: TotalsBar.tsx, ItemCard.tsx, CandidateCard.tsx, CategoryHeader.tsx, SetupCard.tsx, ItemPicker.tsx, `routes/index.tsx`, `routes/setups/$setupId.tsx`. Update all 8.
**Warning signs:** Inconsistent unit display across different views.

### Pitfall 4: Default Unit Breaks Existing Behavior
**What goes wrong:** If the default isn't "g", existing users see different numbers on upgrade.
**Why it happens:** No `weightUnit` setting exists in the database yet.
**How to avoid:** Default to `"g"` when `useSetting("weightUnit")` returns null (404 from API). This preserves backward compatibility -- the app looks identical until the user changes the unit.
**Warning signs:** Weights appearing in ounces on first load without user action.

### Pitfall 5: Rounding Drift on Edit Cycles
**What goes wrong:** User edits an item, weight displays as "42.3 oz", they save without changing weight, but the stored value shifts.
**Why it happens:** Would only occur if input fields converted units. Since input stays in grams (per Out of Scope), this cannot happen.
**How to avoid:** Keep all input fields showing grams. The label says "Weight (g)" and the stored value is always `weight_grams`. Display conversion is one-directional: grams -> display unit.
**Warning signs:** N/A -- this is prevented by the "input stays in grams" design decision.

### Pitfall 6: React Query Cache Staleness
**What goes wrong:** User changes unit but some components still show the old unit until they re-render.
**Why it happens:** The `useUpdateSetting` mutation invalidates `["settings", "weightUnit"]`, but components caching the old value might not immediately re-render.
**How to avoid:** Since `useWeightUnit()` wraps `useSetting("weightUnit")` which uses React Query with the same query key, invalidation on mutation will trigger re-renders in all subscribed components. This works out of the box.
**Warning signs:** Temporary inconsistency after changing units -- should resolve within one render cycle.

## Code Examples

### Complete formatWeight Implementation

```typescript
// src/client/lib/formatters.ts
export type WeightUnit = "g" | "oz" | "lb" | "kg";

const GRAMS_PER_OZ = 28.3495;
const GRAMS_PER_LB = 453.592;
const GRAMS_PER_KG = 1000;

export function formatWeight(
  grams: number | null | undefined,
  unit: WeightUnit = "g",
): string {
  if (grams == null) return "--";

  switch (unit) {
    case "g":
      return `${Math.round(grams)}g`;
    case "oz":
      return `${(grams / GRAMS_PER_OZ).toFixed(1)} oz`;
    case "lb":
      return `${(grams / GRAMS_PER_LB).toFixed(2)} lb`;
    case "kg":
      return `${(grams / GRAMS_PER_KG).toFixed(2)} kg`;
  }
}
```

### useWeightUnit Hook

```typescript
// src/client/hooks/useWeightUnit.ts
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";
}
```

### Component Usage Pattern (e.g., ItemCard)

```typescript
// Before:
import { formatWeight } from "../lib/formatters";
// ...
{formatWeight(weightGrams)}

// After:
import { formatWeight } from "../lib/formatters";
import { useWeightUnit } from "../hooks/useWeightUnit";
// ...
const unit = useWeightUnit();
// ...
{formatWeight(weightGrams, unit)}
```

### Stats Prop Pattern (TotalsBar and routes/index.tsx)

When `formatWeight` is called inside a stats array construction (not directly in JSX), the unit must be available in that scope:

```typescript
// routes/index.tsx - Dashboard
const unit = useWeightUnit();
// ...
stats={[
  { label: "Items", value: String(global?.itemCount ?? 0) },
  { label: "Weight", value: formatWeight(global?.totalWeight ?? null, unit) },
  { label: "Cost", value: formatPrice(global?.totalCost ?? null) },
]}
```

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| `Math.round(grams) + "g"` (hardcoded) | `formatWeight(grams, unit)` (parameterized) | This phase | All weight displays become unit-aware |

**Deprecated/outdated:**
- Nothing to deprecate. The old `formatWeight(grams)` signature remains backward-compatible since `unit` defaults to `"g"`.

## Design Recommendations (Claude's Discretion Areas)

### Unit Selector Placement: TotalsBar
**Recommendation:** Place the unit toggle in the TotalsBar, right side, near the weight stat. The TotalsBar is visible on every page that shows weight (collection, setups). It is the natural place for a global display preference.

### Pounds Display Format: Decimal
**Recommendation:** Use decimal pounds (`"2.19 lb"`) rather than traditional `"2 lb 3 oz"`. Reasons: (1) simpler implementation, (2) consistent with how LighterPack handles it, (3) easier to compare weights at a glance, (4) traditional format mixes two units which complicates the mental model.

### Precision Per Unit
**Recommendation:**
- `g`: 0 decimal places (integers, matching current behavior)
- `oz`: 1 decimal place (standard for gear weights -- e.g., "14.2 oz")
- `lb`: 2 decimal places (e.g., "2.19 lb")
- `kg`: 2 decimal places (e.g., "1.36 kg")

### Default Unit: Grams
**Recommendation:** Default to `"g"` -- this preserves backward compatibility. When `useSetting("weightUnit")` returns null (no setting in DB), the app behaves identically to today.

### How formatWeight Gets the Unit: Parameter
**Recommendation:** Pass `unit` as a parameter rather than using React Context or a global. This keeps `formatWeight` a pure function (testable without React), follows the existing pattern of the codebase (no Context providers used anywhere), and makes the data flow explicit.

## Open Questions

1. **Should the unit toggle appear in setup detail view's sub-bar?**
   - What we know: Setup detail has its own sticky bar below TotalsBar showing setup-specific stats including weight
   - What's unclear: Whether the global TotalsBar is visible enough from setup detail view
   - Recommendation: The TotalsBar is sticky at the top on every page. Its toggle applies globally. No need for a second toggle in the setup bar.

## Validation Architecture

### Test Framework
| Property | Value |
|----------|-------|
| Framework | Bun test runner (built-in) |
| Config file | None (uses bun defaults) |
| Quick run command | `bun test` |
| Full suite command | `bun test` |

### Phase Requirements -> Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| UNIT-01 | Settings API accepts and returns weightUnit value | unit | `bun test tests/services/settings.test.ts -t "weightUnit"` | No -- Wave 0 |
| UNIT-02 | formatWeight converts grams to all 4 units correctly | unit | `bun test tests/lib/formatters.test.ts` | No -- Wave 0 |
| UNIT-02 | formatWeight handles null/undefined input for all units | unit | `bun test tests/lib/formatters.test.ts` | No -- Wave 0 |
| UNIT-03 | Settings PUT upserts weightUnit, GET retrieves it | unit | `bun test tests/routes/settings.test.ts` | No -- Wave 0 |

### Sampling Rate
- **Per task commit:** `bun test`
- **Per wave merge:** `bun test`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps
- [ ] `tests/lib/formatters.test.ts` -- covers UNIT-02 (formatWeight with all units, null handling, precision)
- [ ] `tests/routes/settings.test.ts` -- covers UNIT-01, UNIT-03 (settings API for weightUnit key)

## Sources

### Primary (HIGH confidence)
- Codebase inspection: `src/client/lib/formatters.ts`, `src/client/hooks/useSettings.ts`, `src/server/routes/settings.ts`, `src/db/schema.ts` -- all directly read and analyzed
- Codebase inspection: All 8 `formatWeight` call sites verified via grep

### Secondary (MEDIUM confidence)
- [LighterPack community patterns](https://backpackers.com/how-to/calculate-backpack-weight/) -- unit toggle between g/oz/lb/kg is standard in gear apps
- [Metric conversion constants](https://www.metric-conversions.org/weight/) -- 1 oz = 28.3495g, 1 lb = 453.592g, 1 kg = 1000g (verified against international standard)

### Tertiary (LOW confidence)
- None

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH -- no new dependencies, all existing infrastructure verified in codebase
- Architecture: HIGH -- single conversion point (`formatWeight`) confirmed, settings system verified working
- Pitfalls: HIGH -- all based on direct code inspection of null handling, call sites, and data flow

**Research date:** 2026-03-16
**Valid until:** 2026-04-16 (stable -- no external dependencies or fast-moving APIs)
