Files

26 KiB

Phase 9: Weight Classification and Visualization - Research

Researched: 2026-03-16 Domain: Schema migration, classification UI, chart visualization (Recharts) Confidence: HIGH

Summary

Phase 9 adds two features: (1) per-setup item classification (base weight / worn / consumable) stored on the setup_items join table, and (2) a donut chart visualization of weight distribution inside the setup detail page. The classification feature requires a schema migration adding a classification column with a default of "base" to setup_items, updates to the sync/query service layer, a new API endpoint for updating individual item classifications, and a click-to-cycle badge on each item card within setup context. The visualization feature requires installing Recharts and building a summary card component with a donut chart, weight subtotals, and a pill toggle for switching between category and classification breakdowns.

The project has strong existing patterns to follow: the StatusBadge click-to-cycle component from Phase 8, the formatWeight() utility with useWeightUnit() hook, the TotalsBar pill toggle for weight units, and the Drizzle migration pattern established in prior phases (e.g., 0002_broken_roughhouse.sql adding a column with ALTER TABLE ... ADD). Recharts v3.x is the decided chart library, which is mature, well-documented, and has a straightforward API for donut charts using PieChart + Pie with innerRadius.

Primary recommendation: Use Recharts v3.x with Cell component for individual slice colors (still functional in v3, deprecated only for v4), Label for center text, and a custom content function on Tooltip for formatted hover data. Store classification as a text column on setup_items with a Zod enum for validation.

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • Click-to-cycle badge on each item card within a setup -- clicks cycle through base weight -> worn -> consumable -> base weight
  • Follows the StatusBadge pattern from Phase 8 (pill badge, click interaction)
  • Default classification is "base weight" when an item is added to a setup
  • Badge always visible on every item card in the setup (not hidden for default)
  • Muted gray color scheme for all classification badges (bg-gray-100 text-gray-600), consistent with Phase 8 status badges
  • Classification stored on setup_items join table (already decided in prior phases)
  • Summary section below the setup sticky bar, always visible when setup has items
  • Card with columns layout: Base | Worn | Consumable | Total -- each as a labeled column with weight value
  • Sticky bar keeps its existing simple stats (item count, total weight, total cost)
  • Summary card is a separate visual element, not inline text
  • Donut chart sits inside the summary card alongside the weight subtotals -- chart + numbers as one visual unit
  • Pill toggle above the chart for switching between "Category" and "Classification" views (same style as weight unit selector)
  • Total weight displayed in the center of the donut hole (e.g., "2.87kg")
  • Hover tooltips show segment name, weight (in selected unit), and percentage
  • Chart library: Recharts (PieChart + Pie with innerRadius for donut shape)

Claude's Discretion

  • Summary card exact layout (chart left/right, column arrangement)
  • Chart color palette for segments (should work with both category and classification views)
  • Minimum item threshold for showing chart vs a placeholder message
  • Donut chart sizing and proportions
  • Tooltip styling
  • Keyboard accessibility for classification cycling
  • Animation on chart transitions between category/classification views

Deferred Ideas (OUT OF SCOPE)

None -- discussion stayed within phase scope

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
CLAS-01 User can classify each item within a setup as base weight, worn, or consumable Classification column on setup_items, click-to-cycle badge component, PATCH API endpoint
CLAS-02 Setup totals display base weight, worn weight, consumable weight, and total separately Summary card component computing subtotals from items array grouped by classification
CLAS-03 Items default to "base weight" classification when added to a setup Schema default "base" on classification column, Drizzle migration with DEFAULT
CLAS-04 Same item can have different classifications in different setups Classification on setup_items join table (not items table) -- architecture already decided
VIZZ-01 User can view a donut chart showing weight distribution by category in a setup Recharts PieChart + Pie with innerRadius, data grouped by category
VIZZ-02 User can toggle chart between category view and classification view Pill toggle component (reuse TotalsBar pattern), local React state for view mode
VIZZ-03 User can hover chart segments to see category name, weight, and percentage Recharts Tooltip with custom content renderer using formatWeight()

</phase_requirements>

Standard Stack

Core

Library Version Purpose Why Standard
recharts ^3.8.0 Donut chart visualization Most popular React charting library, declarative API, built on D3, 27K GitHub stars

Supporting (already in project)

Library Version Purpose When to Use
drizzle-orm ^0.45.1 Schema migration for classification column Add column to setup_items table
zod ^4.3.6 Validation for classification enum API input validation
react ^19.2.4 UI components Summary card, badge, chart wrapper
tailwindcss ^4.2.1 Styling Summary card layout, badge styling

Alternatives Considered

Instead of Could Use Tradeoff
Recharts Chart.js / react-chartjs-2 Chart.js is imperative; Recharts is declarative React components -- better fit for this stack
Recharts Visx Lower-level D3 wrapper; more control but more code for a simple donut chart
Recharts Tremor Tremor wraps Recharts but adds full design system overhead -- too heavy for one chart

Installation:

bun add recharts

Note: Recharts has react and react-dom as peer dependencies, both already in the project. No additional peer deps needed.

Architecture Patterns

Schema Change: setup_items classification column

-- Migration: ALTER TABLE setup_items ADD classification text DEFAULT 'base' NOT NULL;
ALTER TABLE `setup_items` ADD `classification` text DEFAULT 'base' NOT NULL;

The Drizzle schema change in src/db/schema.ts:

export const setupItems = sqliteTable("setup_items", {
  id: integer("id").primaryKey({ autoIncrement: true }),
  setupId: integer("setup_id")
    .notNull()
    .references(() => setups.id, { onDelete: "cascade" }),
  itemId: integer("item_id")
    .notNull()
    .references(() => items.id, { onDelete: "cascade" }),
  classification: text("classification").notNull().default("base"),
});

Values: "base" | "worn" | "consumable" -- stored as text, validated with Zod enum.

API Design: Classification Update

A new PATCH /api/setups/:id/items/:itemId/classification endpoint is the cleanest approach. It avoids modifying the existing sync endpoint (which does delete-all + re-insert and would lose classifications).

Alternatively, a dedicated PATCH /api/setup-items/:setupItemId could work, but using the composite key (setupId, itemId) is more consistent with the existing DELETE /api/setups/:id/items/:itemId pattern.

Use: PATCH /api/setups/:setupId/items/:itemId/classification with body { classification: "worn" }.

syncSetupItems Must Preserve Classifications

The existing syncSetupItems function does delete-all + re-insert. After adding classification, this will reset all classifications to "base" whenever items are synced. Two approaches:

Approach A (recommended): Before deleting, read existing classifications into a map { itemId -> classification }. After re-inserting, apply the saved classifications. This keeps the atomic sync pattern intact.

Approach B: Change sync to diff-based (add new, remove missing, keep existing). More complex, breaks the simple pattern.

Use Approach A -- preserves the established pattern with minimal changes.

getSetupWithItems Must Include Classification

The getSetupWithItems query needs to select classification from setupItems:

const itemList = db
  .select({
    id: items.id,
    name: items.name,
    weightGrams: items.weightGrams,
    priceCents: items.priceCents,
    categoryId: items.categoryId,
    // ... existing fields ...
    categoryName: categories.name,
    categoryIcon: categories.icon,
    classification: setupItems.classification, // NEW
  })
  .from(setupItems)
  .innerJoin(items, eq(setupItems.itemId, items.id))
  .innerJoin(categories, eq(items.categoryId, categories.id))
  .where(eq(setupItems.setupId, setupId))
  .all();

Component Architecture

src/client/
  components/
    ClassificationBadge.tsx     # Click-to-cycle badge (base/worn/consumable)
    WeightSummaryCard.tsx       # Summary card: subtotals + donut chart
  routes/
    setups/
      $setupId.tsx              # Modified: adds ClassificationBadge to ItemCard, adds WeightSummaryCard
  hooks/
    useSetups.ts                # Modified: add useUpdateItemClassification mutation, update types

Pattern: ClassificationBadge (Click-to-Cycle)

Follow the StatusBadge pattern but simplified -- no popup menu needed since there are only 3 values and the user cycles through them. Direct click-to-cycle is faster UX for 3 options.

const CLASSIFICATION_ORDER = ["base", "worn", "consumable"] as const;
type Classification = typeof CLASSIFICATION_ORDER[number];

const CLASSIFICATION_CONFIG = {
  base: { label: "Base Weight", icon: "backpack" },
  worn: { label: "Worn", icon: "shirt" },
  consumable: { label: "Consumable", icon: "droplets" },
} as const;

Click handler cycles to next classification: base -> worn -> consumable -> base.

Pattern: Donut Chart with Recharts v3

import { PieChart, Pie, Cell, Tooltip, Label, ResponsiveContainer } from "recharts";

// Cell is still functional in v3 (deprecated for v4 removal)
// This is the standard pattern for v3.x
<ResponsiveContainer width="100%" height={200}>
  <PieChart>
    <Pie
      data={chartData}
      dataKey="weight"
      nameKey="name"
      cx="50%"
      cy="50%"
      innerRadius={55}
      outerRadius={80}
      paddingAngle={2}
    >
      {chartData.map((entry, index) => (
        <Cell key={entry.name} fill={COLORS[index % COLORS.length]} />
      ))}
      <Label
        value={formatWeight(totalWeight, unit)}
        position="center"
        className="text-lg font-semibold"
      />
    </Pie>
    <Tooltip content={<CustomTooltip unit={unit} />} />
  </PieChart>
</ResponsiveContainer>

Pattern: Custom Tooltip

function CustomTooltip({ active, payload, unit }: any) {
  if (!active || !payload?.length) return null;
  const { name, weight, percent } = payload[0].payload;
  return (
    <div className="bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-sm">
      <p className="font-medium text-gray-900">{name}</p>
      <p className="text-gray-600">
        {formatWeight(weight, unit)} ({(percent * 100).toFixed(1)}%)
      </p>
    </div>
  );
}

Pattern: Data Transformation for Chart

// Category view: group items by category, sum weights
function buildCategoryChartData(items: SetupItemWithCategory[]) {
  const groups = new Map<string, number>();
  for (const item of items) {
    const current = groups.get(item.categoryName) ?? 0;
    groups.set(item.categoryName, current + (item.weightGrams ?? 0));
  }
  const total = items.reduce((sum, i) => sum + (i.weightGrams ?? 0), 0);
  return Array.from(groups.entries())
    .filter(([_, weight]) => weight > 0)
    .map(([name, weight]) => ({ name, weight, percent: total > 0 ? weight / total : 0 }));
}

// Classification view: group by classification, sum weights
function buildClassificationChartData(items: SetupItemWithClassification[]) {
  const groups = { base: 0, worn: 0, consumable: 0 };
  for (const item of items) {
    groups[item.classification] += item.weightGrams ?? 0;
  }
  const total = Object.values(groups).reduce((a, b) => a + b, 0);
  return Object.entries(groups)
    .filter(([_, weight]) => weight > 0)
    .map(([key, weight]) => ({
      name: CLASSIFICATION_CONFIG[key as Classification].label,
      weight,
      percent: total > 0 ? weight / total : 0,
    }));
}

Pattern: Pill Toggle (View Mode Switcher)

Reuse the exact pattern from TotalsBar's weight unit toggle:

const VIEW_MODES = ["category", "classification"] as const;
type ViewMode = typeof VIEW_MODES[number];

const [viewMode, setViewMode] = useState<ViewMode>("category");

// Rendered as:
<div className="flex items-center gap-1 bg-gray-100 rounded-full px-1 py-0.5">
  {VIEW_MODES.map((mode) => (
    <button
      key={mode}
      onClick={() => setViewMode(mode)}
      className={`px-2.5 py-0.5 text-xs rounded-full transition-colors capitalize ${
        viewMode === mode
          ? "bg-white text-gray-700 shadow-sm font-medium"
          : "text-gray-400 hover:text-gray-600"
      }`}
    >
      {mode === "category" ? "Category" : "Classification"}
    </button>
  ))}
</div>

Anti-Patterns to Avoid

  • Modifying syncSetupItems to accept classifications in the itemIds array: This couples the sync endpoint to classification data. Keep them separate -- sync manages membership, classification update manages role.
  • Computing classification subtotals on the server: The setup detail page already computes totals client-side from the items array. Keep classification subtotals client-side too for consistency.
  • Using a separate table for classifications: Overkill. A single column on setup_items is the right level of complexity.
  • Using Recharts v4 patterns (RechartsSymbols.fill): v4 is not released. Stick with Cell component which works in v3.x.

Don't Hand-Roll

Problem Don't Build Use Instead Why
Donut chart rendering Custom SVG arc calculations Recharts PieChart + Pie Arc math, hit detection, animation, accessibility -- all handled
Chart tooltips Custom hover position tracking Recharts Tooltip with content prop Viewport boundary detection, positioning, hover state management
Responsive chart sizing Manual resize observers Recharts ResponsiveContainer Handles debounced resize, prevents layout thrashing
Weight unit formatting Inline conversion in chart Existing formatWeight() utility Already handles all units with correct decimal places

Key insight: Recharts handles all the hard SVG/D3 work. The implementation should focus on data transformation (grouping items into chart segments) and styling (Tailwind classes on the summary card and tooltip).

Common Pitfalls

Pitfall 1: syncSetupItems Destroys Classifications

What goes wrong: The existing sync function deletes all setup_items then re-inserts. After adding classification, every sync resets all items to "base". Why it happens: Delete-all + re-insert pattern was designed before classification existed. How to avoid: Save classifications before delete, restore after re-insert (Approach A above). Warning signs: Items losing their classification after adding/removing any item from the setup.

Pitfall 2: ResponsiveContainer Needs a Defined Parent Height

What goes wrong: Recharts ResponsiveContainer with height="100%" renders at 0px if the parent container has no explicit height. Why it happens: CSS percentage heights require the parent to have a defined height. How to avoid: Use a fixed numeric height on ResponsiveContainer (e.g., height={200}) or ensure the parent div has an explicit height (e.g., h-[200px]). Warning signs: Chart not visible, 0-height container.

Pitfall 3: Chart Data with Zero-Weight Items

What goes wrong: Items with null or 0 weight produce zero-size or NaN chart segments. Why it happens: Recharts renders slices proportional to dataKey values. Zero values create invisible or problematic slices. How to avoid: Filter out zero-weight entries before passing data to the chart. Show a "no weight data" placeholder if all items have null weight. Warning signs: Console warnings about NaN, invisible chart segments, misaligned tooltips.

Pitfall 4: Test Helper Must Match Schema

What goes wrong: Tests fail because the in-memory DB schema in tests/helpers/db.ts doesn't include the new classification column. Why it happens: The test helper has hand-written CREATE TABLE statements that must be manually kept in sync with src/db/schema.ts. How to avoid: Update the test helper's setup_items CREATE TABLE to include classification text NOT NULL DEFAULT 'base' alongside updating the Drizzle schema. Warning signs: Tests failing with "no such column: classification" errors.

Pitfall 5: Classification Badge Click Propagates to ItemCard

What goes wrong: Clicking the classification badge opens the item edit panel instead of cycling classification. Why it happens: ItemCard is a <button> element. Click events bubble up from the badge to the card. How to avoid: Call e.stopPropagation() on the classification badge click handler. This is the same pattern used by the remove button and product URL link on ItemCard. Warning signs: Edit panel opening when user tries to change classification.

Code Examples

Zod Schema for Classification

// In src/shared/schemas.ts
export const classificationSchema = z.enum(["base", "worn", "consumable"]);

export const updateClassificationSchema = z.object({
  classification: classificationSchema,
});

Service: Update Item Classification

// In src/server/services/setup.service.ts
export function updateItemClassification(
  db: Db = prodDb,
  setupId: number,
  itemId: number,
  classification: string,
) {
  return db
    .update(setupItems)
    .set({ classification })
    .where(
      sql`${setupItems.setupId} = ${setupId} AND ${setupItems.itemId} = ${itemId}`,
    )
    .run();
}

Service: syncSetupItems with Classification Preservation

export function syncSetupItems(
  db: Db = prodDb,
  setupId: number,
  itemIds: number[],
) {
  return db.transaction((tx) => {
    // Save existing classifications before delete
    const existing = tx
      .select({
        itemId: setupItems.itemId,
        classification: setupItems.classification,
      })
      .from(setupItems)
      .where(eq(setupItems.setupId, setupId))
      .all();

    const classificationMap = new Map(
      existing.map((e) => [e.itemId, e.classification]),
    );

    // Delete all existing items for this setup
    tx.delete(setupItems).where(eq(setupItems.setupId, setupId)).run();

    // Re-insert with preserved classifications
    for (const itemId of itemIds) {
      tx.insert(setupItems)
        .values({
          setupId,
          itemId,
          classification: classificationMap.get(itemId) ?? "base",
        })
        .run();
    }
  });
}

Hook: useUpdateItemClassification

// In src/client/hooks/useSetups.ts
export function useUpdateItemClassification(setupId: number) {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ itemId, classification }: { itemId: number; classification: string }) =>
      apiPut<{ success: boolean }>(
        `/api/setups/${setupId}/items/${itemId}/classification`,
        { classification },
      ),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["setups", setupId] });
    },
  });
}

Color Palette for Chart Segments

// Category colors: distinguishable palette for up to 10 categories
const CATEGORY_COLORS = [
  "#6366f1", // indigo
  "#f59e0b", // amber
  "#10b981", // emerald
  "#ef4444", // red
  "#8b5cf6", // violet
  "#06b6d4", // cyan
  "#f97316", // orange
  "#ec4899", // pink
  "#14b8a6", // teal
  "#84cc16", // lime
];

// Classification colors: 3 distinct colors matching the semantic meaning
const CLASSIFICATION_COLORS = {
  base: "#6366f1",       // indigo -- "foundation" feel
  worn: "#f59e0b",       // amber -- "on your body" warmth
  consumable: "#10b981", // emerald -- "used up" organic feel
};

State of the Art

Old Approach Current Approach When Changed Impact
Recharts Cell for per-slice colors Still Cell in v3.x (deprecated for v4) v3.0 deprecated, v4 removes Use Cell now; plan to migrate to data-mapped colors when v4 drops
Recharts v2 state management Recharts v3 rewritten state v3.0 (2024) Better performance, fewer rendering bugs
activeShape prop on Pie shape prop with isActive callback v3.0 Use shape for custom active sectors if needed

Deprecated/outdated:

  • Cell component: Deprecated in v3, removed in v4. Still functional now. When v4 releases, migrate to RechartsSymbols.fill in data objects or fillKey prop.
  • activeShape / inactiveShape props on Pie: Deprecated in v3 in favor of unified shape prop.

Open Questions

  1. Recharts bundle size impact

    • What we know: Recharts depends on D3 modules, adding ~50-80KB gzipped to the bundle
    • What's unclear: Exact tree-shaking behavior with Vite and specific imports
    • Recommendation: Import only needed components (import { PieChart, Pie, Cell, Tooltip, Label, ResponsiveContainer } from "recharts") -- Vite will tree-shake unused parts
  2. Chart animation performance

    • What we know: Recharts animations are CSS-based and generally smooth
    • What's unclear: Whether toggling between category/classification views should animate the transition
    • Recommendation: Enable default animation on initial render. For view toggles, let Recharts handle the re-render naturally (it will animate by default). If janky, set isAnimationActive={false}.

Validation Architecture

Test Framework

Property Value
Framework Bun test runner (built-in)
Config file None -- Bun test requires no config
Quick run command bun test tests/services/setup.service.test.ts
Full suite command bun test

Phase Requirements -> Test Map

Req ID Behavior Test Type Automated Command File Exists?
CLAS-01 Update item classification in setup unit bun test tests/services/setup.service.test.ts -t "updateItemClassification" Needs new tests
CLAS-02 Get setup with classification subtotals unit bun test tests/services/setup.service.test.ts -t "classification" Needs new tests
CLAS-03 Default classification is "base" unit bun test tests/services/setup.service.test.ts -t "default" Needs new tests
CLAS-04 Different classifications in different setups unit bun test tests/services/setup.service.test.ts -t "different setups" Needs new tests
VIZZ-01 Donut chart renders with category data manual-only N/A -- visual rendering N/A
VIZZ-02 Toggle switches chart data source manual-only N/A -- UI interaction N/A
VIZZ-03 Hover tooltip shows name/weight/percentage manual-only N/A -- hover behavior N/A
CLAS-01 Classification PATCH route integration bun test tests/routes/setups.test.ts -t "classification" Needs new tests
CLAS-03 syncSetupItems preserves classification unit bun test tests/services/setup.service.test.ts -t "preserves classification" Needs new tests

Sampling Rate

  • Per task commit: bun test tests/services/setup.service.test.ts && bun test tests/routes/setups.test.ts
  • Per wave merge: bun test
  • Phase gate: Full suite green before /gsd:verify-work

Wave 0 Gaps

  • tests/services/setup.service.test.ts -- add tests for updateItemClassification, classification preservation in syncSetupItems, classification defaults, and different-setup classification
  • tests/routes/setups.test.ts -- add test for PATCH /:id/items/:itemId/classification route
  • tests/helpers/db.ts -- update setup_items CREATE TABLE to include classification text NOT NULL DEFAULT 'base'

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

  • None

Metadata

Confidence breakdown:

  • Standard stack: HIGH - Recharts is the user's locked decision, v3.8.0 is current, API is well-documented
  • Architecture: HIGH - Classification column pattern mirrors the Phase 8 status column migration exactly; all code patterns verified against existing codebase
  • Pitfalls: HIGH - syncSetupItems preservation is the main risk; verified by reading the actual delete-all + re-insert code; other pitfalls are standard React/Recharts issues

Research date: 2026-03-16 Valid until: 2026-04-16 (Recharts v3 is stable; v4 with Cell removal is not imminent)