docs(phase-09): research phase domain
This commit is contained in:
@@ -0,0 +1,553 @@
|
|||||||
|
# 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:**
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 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`:
|
||||||
|
```typescript
|
||||||
|
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`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// In src/shared/schemas.ts
|
||||||
|
export const classificationSchema = z.enum(["base", "worn", "consumable"]);
|
||||||
|
|
||||||
|
export const updateClassificationSchema = z.object({
|
||||||
|
classification: classificationSchema,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Service: Update Item Classification
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 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)
|
||||||
|
- [Recharts API docs - Pie](https://recharts.github.io/en-US/api/Pie) - innerRadius, outerRadius, dataKey, Cell usage
|
||||||
|
- [Recharts API docs - Tooltip](https://recharts.github.io/en-US/api/Tooltip/) - custom content, formatter, active/payload
|
||||||
|
- [Recharts API docs - Cell (deprecation notice)](https://recharts.github.io/en-US/api/Cell/) - deprecated in v3, removed in v4
|
||||||
|
- [Recharts npm](https://www.npmjs.com/package/recharts) - v3.8.0 latest, MIT license
|
||||||
|
- Existing codebase: `src/db/schema.ts`, `src/server/services/setup.service.ts`, `src/client/components/StatusBadge.tsx`, `src/client/components/TotalsBar.tsx`
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- [Recharts Cell Discussion #5474](https://github.com/recharts/recharts/discussions/5474) - Cell replacement patterns
|
||||||
|
- [GeeksforGeeks Donut Chart Tutorial](https://www.geeksforgeeks.org/reactjs/create-a-donut-chart-using-recharts-in-reactjs/) - donut chart pattern
|
||||||
|
- [Recharts Label in center of PieChart #191](https://github.com/recharts/recharts/issues/191) - center label patterns
|
||||||
|
- [Recharts 3.0 Migration Guide](https://github.com/recharts/recharts/wiki/3.0-migration-guide) - v3 breaking changes
|
||||||
|
|
||||||
|
### 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)
|
||||||
Reference in New Issue
Block a user