Files

18 KiB

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

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:

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

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

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

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

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

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

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

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)