docs(09): create phase plan for weight classification and visualization
This commit is contained in:
@@ -75,11 +75,11 @@ Plans:
|
|||||||
2. Setup detail view shows separate weight subtotals for base weight, worn weight, and consumable weight in addition to the overall total
|
2. Setup detail view shows separate weight subtotals for base weight, worn weight, and consumable weight in addition to the overall total
|
||||||
3. User can view a donut chart in a setup showing weight distribution, and toggle between category breakdown and classification breakdown
|
3. User can view a donut chart in a setup showing weight distribution, and toggle between category breakdown and classification breakdown
|
||||||
4. User can hover chart segments to see the category/classification name, weight (in selected unit), and percentage
|
4. User can hover chart segments to see the category/classification name, weight (in selected unit), and percentage
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 09-01: TBD
|
- [ ] 09-01-PLAN.md -- Classification vertical slice (schema, service, tests, API route, ClassificationBadge UI)
|
||||||
- [ ] 09-02: TBD
|
- [ ] 09-02-PLAN.md -- WeightSummaryCard with subtotals, donut chart, pill toggle, and visual verification
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
@@ -95,4 +95,4 @@ Plans:
|
|||||||
| 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 |
|
| 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 |
|
||||||
| 7. Weight Unit Selection | v1.2 | 1/2 | In Progress | - |
|
| 7. Weight Unit Selection | v1.2 | 1/2 | In Progress | - |
|
||||||
| 8. Search, Filter, and Candidate Status | v1.2 | 0/2 | Not started | - |
|
| 8. Search, Filter, and Candidate Status | v1.2 | 0/2 | Not started | - |
|
||||||
| 9. Weight Classification and Visualization | v1.2 | 0/? | Not started | - |
|
| 9. Weight Classification and Visualization | v1.2 | 0/2 | Not started | - |
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
---
|
||||||
|
phase: 09-weight-classification-and-visualization
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- src/db/schema.ts
|
||||||
|
- src/shared/schemas.ts
|
||||||
|
- src/shared/types.ts
|
||||||
|
- src/server/services/setup.service.ts
|
||||||
|
- src/server/routes/setups.ts
|
||||||
|
- src/client/lib/api.ts
|
||||||
|
- src/client/hooks/useSetups.ts
|
||||||
|
- src/client/components/ClassificationBadge.tsx
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
- tests/helpers/db.ts
|
||||||
|
- tests/services/setup.service.test.ts
|
||||||
|
- tests/routes/setups.test.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CLAS-01, CLAS-03, CLAS-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "User can click a classification badge on any item card within a setup and it cycles through base weight, worn, consumable"
|
||||||
|
- "Items default to base weight classification when added to a setup"
|
||||||
|
- "Same item in different setups can have different classifications"
|
||||||
|
- "Classifications persist after adding/removing other items from the setup (syncSetupItems preserves them)"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/db/schema.ts"
|
||||||
|
provides: "classification column on setupItems table"
|
||||||
|
contains: "classification.*text.*default.*base"
|
||||||
|
- path: "src/shared/schemas.ts"
|
||||||
|
provides: "classificationSchema Zod enum and updateClassificationSchema"
|
||||||
|
exports: ["classificationSchema", "updateClassificationSchema"]
|
||||||
|
- path: "src/server/services/setup.service.ts"
|
||||||
|
provides: "updateItemClassification service function, classification-preserving syncSetupItems, classification field in getSetupWithItems"
|
||||||
|
exports: ["updateItemClassification"]
|
||||||
|
- path: "src/server/routes/setups.ts"
|
||||||
|
provides: "PATCH /:id/items/:itemId/classification endpoint"
|
||||||
|
- path: "src/client/components/ClassificationBadge.tsx"
|
||||||
|
provides: "Click-to-cycle classification badge component"
|
||||||
|
min_lines: 30
|
||||||
|
- path: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
provides: "ClassificationBadge wired into item cards in setup view"
|
||||||
|
- path: "tests/services/setup.service.test.ts"
|
||||||
|
provides: "Tests for updateItemClassification, classification preservation, defaults"
|
||||||
|
- path: "tests/routes/setups.test.ts"
|
||||||
|
provides: "Integration test for PATCH classification route"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/components/ClassificationBadge.tsx"
|
||||||
|
to: "/api/setups/:id/items/:itemId/classification"
|
||||||
|
via: "useUpdateItemClassification mutation hook"
|
||||||
|
pattern: "apiPatch.*classification"
|
||||||
|
- from: "src/server/routes/setups.ts"
|
||||||
|
to: "src/server/services/setup.service.ts"
|
||||||
|
via: "updateItemClassification service call"
|
||||||
|
pattern: "updateItemClassification"
|
||||||
|
- from: "src/server/services/setup.service.ts"
|
||||||
|
to: "src/db/schema.ts"
|
||||||
|
via: "setupItems.classification column"
|
||||||
|
pattern: "setupItems\\.classification"
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "src/client/components/ClassificationBadge.tsx"
|
||||||
|
via: "ClassificationBadge rendered on each ItemCard"
|
||||||
|
pattern: "ClassificationBadge"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add per-setup item classification (base weight / worn / consumable) as a complete vertical slice: schema migration, service layer with tests, API route, and ClassificationBadge UI component wired into the setup detail page.
|
||||||
|
|
||||||
|
Purpose: Users need to classify gear items by their role within a specific setup to enable weight breakdown analysis. The same item can serve different roles in different setups (e.g., a jacket is "worn" in a hiking setup but "base weight" in a bike setup).
|
||||||
|
|
||||||
|
Output: Working classification system -- clicking a badge on any item card in a setup cycles through base/worn/consumable, persists to the database, and survives item sync operations.
|
||||||
|
</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/09-weight-classification-and-visualization/09-CONTEXT.md
|
||||||
|
@.planning/phases/09-weight-classification-and-visualization/09-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From src/db/schema.ts (setupItems table -- CURRENT, needs classification column added):
|
||||||
|
```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" }),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/server/services/setup.service.ts (functions to modify):
|
||||||
|
```typescript
|
||||||
|
type Db = typeof prodDb;
|
||||||
|
export function getSetupWithItems(db: Db, setupId: number): { ...setup, items: [...] } | null;
|
||||||
|
export function syncSetupItems(db: Db, setupId: number, itemIds: number[]): void;
|
||||||
|
export function removeSetupItem(db: Db, setupId: number, itemId: number): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/shared/schemas.ts (existing pattern for enums):
|
||||||
|
```typescript
|
||||||
|
export const candidateStatusSchema = z.enum(["researching", "ordered", "arrived"]);
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/api.ts (existing helpers -- NO apiPatch exists):
|
||||||
|
```typescript
|
||||||
|
export async function apiGet<T>(url: string): Promise<T>;
|
||||||
|
export async function apiPost<T>(url: string, body: unknown): Promise<T>;
|
||||||
|
export async function apiPut<T>(url: string, body: unknown): Promise<T>;
|
||||||
|
export async function apiDelete<T>(url: string): Promise<T>;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts (existing types):
|
||||||
|
```typescript
|
||||||
|
interface SetupItemWithCategory {
|
||||||
|
id: number; name: string; weightGrams: number | null; priceCents: number | null;
|
||||||
|
categoryId: number; notes: string | null; productUrl: string | null;
|
||||||
|
imageFilename: string | null; createdAt: string; updatedAt: string;
|
||||||
|
categoryName: string; categoryIcon: string;
|
||||||
|
}
|
||||||
|
// NEEDS: classification field added to this interface
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/StatusBadge.tsx (pattern reference for click interaction):
|
||||||
|
```typescript
|
||||||
|
// Uses click-to-open popup with status options
|
||||||
|
// ClassificationBadge should be SIMPLER: direct click-to-cycle (only 3 values)
|
||||||
|
// Must call e.stopPropagation() to prevent ItemCard click handler
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/components/ItemCard.tsx (props interface -- badge goes in the badges area):
|
||||||
|
```typescript
|
||||||
|
interface ItemCardProps {
|
||||||
|
id: number; name: string; weightGrams: number | null; priceCents: number | null;
|
||||||
|
categoryName: string; categoryIcon: string; imageFilename: string | null;
|
||||||
|
productUrl?: string | null; onRemove?: () => void;
|
||||||
|
}
|
||||||
|
// Classification badge will be rendered OUTSIDE ItemCard, in the setup detail page's
|
||||||
|
// grid layout, alongside the ItemCard. The ItemCard itself does NOT need modification.
|
||||||
|
// The badge sits in the flex-wrap gap-1.5 area of ItemCard OR as a sibling element.
|
||||||
|
```
|
||||||
|
|
||||||
|
From tests/helpers/db.ts (setup_items CREATE TABLE -- needs classification column):
|
||||||
|
```sql
|
||||||
|
CREATE TABLE setup_items (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
setup_id INTEGER NOT NULL REFERENCES setups(id) ON DELETE CASCADE,
|
||||||
|
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto" tdd="true">
|
||||||
|
<name>Task 1: Schema migration, service layer, and tests for classification</name>
|
||||||
|
<files>
|
||||||
|
src/db/schema.ts,
|
||||||
|
src/shared/schemas.ts,
|
||||||
|
src/shared/types.ts,
|
||||||
|
src/server/services/setup.service.ts,
|
||||||
|
tests/helpers/db.ts,
|
||||||
|
tests/services/setup.service.test.ts
|
||||||
|
</files>
|
||||||
|
<behavior>
|
||||||
|
- Test: updateItemClassification sets classification for a specific item in a specific setup
|
||||||
|
- Test: updateItemClassification with "worn" changes item from default "base" to "worn"
|
||||||
|
- Test: getSetupWithItems returns classification field for each item (defaults to "base")
|
||||||
|
- Test: syncSetupItems preserves existing classifications when re-syncing (save before delete, restore after insert)
|
||||||
|
- Test: syncSetupItems assigns "base" to newly added items that have no prior classification
|
||||||
|
- Test: same item in two different setups can have different classifications
|
||||||
|
</behavior>
|
||||||
|
<action>
|
||||||
|
1. **Update test helper FIRST** (`tests/helpers/db.ts`): Add `classification text NOT NULL DEFAULT 'base'` to the `setup_items` CREATE TABLE statement.
|
||||||
|
|
||||||
|
2. **Write failing tests** in `tests/services/setup.service.test.ts`:
|
||||||
|
- Add `describe("updateItemClassification", ...)` block with tests for setting classification and verifying the update
|
||||||
|
- Add test in existing `getSetupWithItems` describe for classification field presence (should default to "base")
|
||||||
|
- Add test in existing `syncSetupItems` describe for classification preservation (sync with different item list, verify classifications retained for items that remain)
|
||||||
|
- Add test for same item in two setups having different classifications
|
||||||
|
- Import the new `updateItemClassification` function from setup.service.ts
|
||||||
|
|
||||||
|
3. **Run tests** -- they must FAIL (RED phase).
|
||||||
|
|
||||||
|
4. **Update Drizzle schema** (`src/db/schema.ts`): Add `classification: text("classification").notNull().default("base")` to the `setupItems` table definition.
|
||||||
|
|
||||||
|
5. **Generate migration**: Run `bun run db:generate` to create the migration SQL file. Then run `bun run db:push` to apply.
|
||||||
|
|
||||||
|
6. **Add Zod schema** (`src/shared/schemas.ts`):
|
||||||
|
```typescript
|
||||||
|
export const classificationSchema = z.enum(["base", "worn", "consumable"]);
|
||||||
|
export const updateClassificationSchema = z.object({
|
||||||
|
classification: classificationSchema,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Add types** (`src/shared/types.ts`): Add `UpdateClassification` type inferred from `updateClassificationSchema`. The `SetupItem` type auto-updates from Drizzle schema inference.
|
||||||
|
|
||||||
|
8. **Implement service functions** (`src/server/services/setup.service.ts`):
|
||||||
|
- Add `updateItemClassification(db, setupId, itemId, classification)` -- uses `db.update(setupItems).set({ classification }).where(sql\`..setupId AND ..itemId\`)`.
|
||||||
|
- Modify `getSetupWithItems` to include `classification: setupItems.classification` in the select fields.
|
||||||
|
- Modify `syncSetupItems` to preserve classifications using Approach A from research: before deleting, read existing classifications into a `Map<number, string>` (itemId -> classification). After re-inserting, apply saved classifications using `classificationMap.get(itemId) ?? "base"` in the insert values.
|
||||||
|
|
||||||
|
9. **Run tests** -- they must PASS (GREEN phase).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/services/setup.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- updateItemClassification changes an item's classification in a setup
|
||||||
|
- getSetupWithItems returns classification field defaulting to "base"
|
||||||
|
- syncSetupItems preserves classifications for retained items, defaults new items to "base"
|
||||||
|
- Same item can have different classifications in different setups
|
||||||
|
- All existing setup service tests still pass
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: API route, client hook, ClassificationBadge, and wiring into setup detail page</name>
|
||||||
|
<files>
|
||||||
|
src/server/routes/setups.ts,
|
||||||
|
src/client/lib/api.ts,
|
||||||
|
src/client/hooks/useSetups.ts,
|
||||||
|
src/client/components/ClassificationBadge.tsx,
|
||||||
|
src/client/routes/setups/$setupId.tsx,
|
||||||
|
tests/routes/setups.test.ts
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. **Add PATCH route** (`src/server/routes/setups.ts`):
|
||||||
|
- Import `updateClassificationSchema` from schemas and `updateItemClassification` from service.
|
||||||
|
- Add `app.patch("/:id/items/:itemId/classification", zValidator("json", updateClassificationSchema), handler)`.
|
||||||
|
- Handler: extract `setupId` and `itemId` from params, `classification` from validated body, call `updateItemClassification(db, setupId, itemId, classification)`, return `{ success: true }`.
|
||||||
|
|
||||||
|
2. **Add integration test** (`tests/routes/setups.test.ts`):
|
||||||
|
- Add `describe("PATCH /api/setups/:id/items/:itemId/classification", ...)` block.
|
||||||
|
- Test: create setup, add item, PATCH classification to "worn", GET setup and verify item has classification "worn".
|
||||||
|
- Test: PATCH with invalid classification value returns 400.
|
||||||
|
|
||||||
|
3. **Add `apiPatch` helper** (`src/client/lib/api.ts`):
|
||||||
|
```typescript
|
||||||
|
export async function apiPatch<T>(url: string, body: unknown): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return handleResponse<T>(res);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Update client hooks** (`src/client/hooks/useSetups.ts`):
|
||||||
|
- Add `classification: string` field to `SetupItemWithCategory` interface (defaults to "base" from API).
|
||||||
|
- Add `useUpdateItemClassification(setupId: number)` mutation hook:
|
||||||
|
```typescript
|
||||||
|
export function useUpdateItemClassification(setupId: number) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ itemId, classification }: { itemId: number; classification: string }) =>
|
||||||
|
apiPatch<{ success: boolean }>(
|
||||||
|
`/api/setups/${setupId}/items/${itemId}/classification`,
|
||||||
|
{ classification },
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["setups", setupId] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Import `apiPatch` from `../lib/api`.
|
||||||
|
|
||||||
|
5. **Create ClassificationBadge component** (`src/client/components/ClassificationBadge.tsx`):
|
||||||
|
- Props: `classification: string`, `onCycle: () => void`.
|
||||||
|
- Define `CLASSIFICATION_ORDER = ["base", "worn", "consumable"] as const`.
|
||||||
|
- Define `CLASSIFICATION_LABELS = { base: "Base Weight", worn: "Worn", consumable: "Consumable" }`.
|
||||||
|
- Render as a `<button>` with pill styling: `bg-gray-100 text-gray-600 hover:bg-gray-200` (muted gray per user decision).
|
||||||
|
- Display the label text for the current classification.
|
||||||
|
- On click: call `e.stopPropagation()` (critical -- prevents ItemCard from opening edit panel), then call `onCycle()`.
|
||||||
|
- The parent component computes the next classification and calls the mutation.
|
||||||
|
|
||||||
|
6. **Wire into setup detail page** (`src/client/routes/setups/$setupId.tsx`):
|
||||||
|
- Import `ClassificationBadge` and `useUpdateItemClassification`.
|
||||||
|
- Create the mutation hook: `const updateClassification = useUpdateItemClassification(numericId)`.
|
||||||
|
- Add a helper function to compute next classification:
|
||||||
|
```typescript
|
||||||
|
function nextClassification(current: string): string {
|
||||||
|
const order = ["base", "worn", "consumable"];
|
||||||
|
const idx = order.indexOf(current);
|
||||||
|
return order[(idx + 1) % order.length];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- In the items grid, render `ClassificationBadge` below each `ItemCard` (as a sibling within the grid cell). Wrap ItemCard + badge in a `<div>`:
|
||||||
|
```tsx
|
||||||
|
<div key={item.id}>
|
||||||
|
<ItemCard ... />
|
||||||
|
<div className="px-4 pb-3 -mt-1">
|
||||||
|
<ClassificationBadge
|
||||||
|
classification={item.classification}
|
||||||
|
onCycle={() => updateClassification.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
classification: nextClassification(item.classification),
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
- Alternatively, the badge can go inside the card's badge row if preferred. Use discretion on exact placement -- it should be near the weight/price badges but distinct.
|
||||||
|
|
||||||
|
7. **Run all tests** to verify nothing broken.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun test tests/routes/setups.test.ts && bun test tests/services/setup.service.test.ts</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- PATCH /api/setups/:id/items/:itemId/classification endpoint works (200 for valid, 400 for invalid)
|
||||||
|
- ClassificationBadge renders on each item card in setup detail view with muted gray styling
|
||||||
|
- Clicking the badge cycles classification: base weight -> worn -> consumable -> base weight
|
||||||
|
- Badge click does NOT open the item edit panel (stopPropagation works)
|
||||||
|
- Classification change persists after page refresh
|
||||||
|
- GET /api/setups/:id returns classification field for each item
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# All tests pass
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Classification service tests specifically
|
||||||
|
bun test tests/services/setup.service.test.ts -t "classification"
|
||||||
|
|
||||||
|
# Classification route tests specifically
|
||||||
|
bun test tests/routes/setups.test.ts -t "classification"
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Classification badge visible on every item card in setup detail view (not hidden for default)
|
||||||
|
- Click cycles through base weight -> worn -> consumable -> base weight
|
||||||
|
- Badge uses muted gray styling (bg-gray-100 text-gray-600) consistent with Phase 8 status badges
|
||||||
|
- Default classification is "base" for newly added items
|
||||||
|
- syncSetupItems preserves classifications when items are added/removed
|
||||||
|
- Same item in different setups can have different classifications
|
||||||
|
- All existing tests continue to pass
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/09-weight-classification-and-visualization/09-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
---
|
||||||
|
phase: 09-weight-classification-and-visualization
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["09-01"]
|
||||||
|
files_modified:
|
||||||
|
- src/client/components/WeightSummaryCard.tsx
|
||||||
|
- src/client/routes/setups/$setupId.tsx
|
||||||
|
- package.json
|
||||||
|
autonomous: false
|
||||||
|
requirements: [CLAS-02, VIZZ-01, VIZZ-02, VIZZ-03]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Setup detail view shows separate weight subtotals for base weight, worn weight, consumable weight, and total"
|
||||||
|
- "User can view a donut chart showing weight distribution by category in the setup"
|
||||||
|
- "User can toggle the chart between category breakdown and classification breakdown via pill toggle"
|
||||||
|
- "Hovering a chart segment shows category/classification name, weight in selected unit, and percentage"
|
||||||
|
- "Total weight displayed in the center of the donut hole"
|
||||||
|
artifacts:
|
||||||
|
- path: "src/client/components/WeightSummaryCard.tsx"
|
||||||
|
provides: "Summary card with weight subtotals, donut chart, pill toggle, and tooltips"
|
||||||
|
min_lines: 100
|
||||||
|
- path: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
provides: "WeightSummaryCard rendered below sticky bar when setup has items"
|
||||||
|
- path: "package.json"
|
||||||
|
provides: "recharts dependency installed"
|
||||||
|
contains: "recharts"
|
||||||
|
key_links:
|
||||||
|
- from: "src/client/components/WeightSummaryCard.tsx"
|
||||||
|
to: "recharts"
|
||||||
|
via: "PieChart, Pie, Cell, Tooltip, Label, ResponsiveContainer imports"
|
||||||
|
pattern: "from.*recharts"
|
||||||
|
- from: "src/client/components/WeightSummaryCard.tsx"
|
||||||
|
to: "src/client/lib/formatters.ts"
|
||||||
|
via: "formatWeight for subtotals and tooltip display"
|
||||||
|
pattern: "formatWeight"
|
||||||
|
- from: "src/client/routes/setups/$setupId.tsx"
|
||||||
|
to: "src/client/components/WeightSummaryCard.tsx"
|
||||||
|
via: "WeightSummaryCard rendered with setup.items prop"
|
||||||
|
pattern: "WeightSummaryCard"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Add the WeightSummaryCard component with classification weight subtotals, a donut chart for weight distribution, and a pill toggle for switching between category and classification views.
|
||||||
|
|
||||||
|
Purpose: Users need to visualize how weight is distributed across their setup -- both by gear category (shelter, sleep, cook) and by classification (base weight, worn, consumable). The donut chart with tooltips makes weight analysis intuitive.
|
||||||
|
|
||||||
|
Output: A summary card below the setup sticky bar showing Base | Worn | Consumable | Total weight columns alongside a donut chart with interactive tooltips, togglable between category and classification breakdowns.
|
||||||
|
</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/09-weight-classification-and-visualization/09-CONTEXT.md
|
||||||
|
@.planning/phases/09-weight-classification-and-visualization/09-RESEARCH.md
|
||||||
|
@.planning/phases/09-weight-classification-and-visualization/09-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts from Plan 01. Executor uses these directly. -->
|
||||||
|
|
||||||
|
From src/client/hooks/useSetups.ts (after Plan 01):
|
||||||
|
```typescript
|
||||||
|
interface SetupItemWithCategory {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
weightGrams: number | null;
|
||||||
|
priceCents: number | null;
|
||||||
|
categoryId: number;
|
||||||
|
notes: string | null;
|
||||||
|
productUrl: string | null;
|
||||||
|
imageFilename: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
categoryName: string;
|
||||||
|
categoryIcon: string;
|
||||||
|
classification: string; // "base" | "worn" | "consumable" -- added by Plan 01
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/lib/formatters.ts:
|
||||||
|
```typescript
|
||||||
|
export type WeightUnit = "g" | "oz" | "lb" | "kg";
|
||||||
|
export function formatWeight(grams: number | null | undefined, unit: WeightUnit = "g"): string;
|
||||||
|
```
|
||||||
|
|
||||||
|
From src/client/hooks/useWeightUnit.ts:
|
||||||
|
```typescript
|
||||||
|
export function useWeightUnit(): WeightUnit;
|
||||||
|
```
|
||||||
|
|
||||||
|
From 09-RESEARCH.md (Recharts pattern):
|
||||||
|
```typescript
|
||||||
|
import { PieChart, Pie, Cell, Tooltip, Label, ResponsiveContainer } from "recharts";
|
||||||
|
// Use Cell for per-slice colors (still functional in v3, deprecated for v4)
|
||||||
|
// Use fixed numeric height on ResponsiveContainer (e.g., height={200})
|
||||||
|
// Filter out zero-weight entries before passing to chart
|
||||||
|
```
|
||||||
|
|
||||||
|
From 09-RESEARCH.md (color palettes):
|
||||||
|
```typescript
|
||||||
|
const CATEGORY_COLORS = [
|
||||||
|
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6",
|
||||||
|
"#06b6d4", "#f97316", "#ec4899", "#14b8a6", "#84cc16",
|
||||||
|
];
|
||||||
|
const CLASSIFICATION_COLORS = {
|
||||||
|
base: "#6366f1", // indigo
|
||||||
|
worn: "#f59e0b", // amber
|
||||||
|
consumable: "#10b981", // emerald
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
From 09-CONTEXT.md (locked decisions):
|
||||||
|
- Summary card below sticky bar, always visible when setup has items
|
||||||
|
- Card with columns layout: Base | Worn | Consumable | Total
|
||||||
|
- Donut chart inside the summary card alongside weight subtotals
|
||||||
|
- Pill toggle above the chart: "Category" / "Classification" (same style as weight unit selector)
|
||||||
|
- Total weight in center of donut hole
|
||||||
|
- Hover tooltips: segment name, weight in selected unit, percentage
|
||||||
|
- Chart library: Recharts (PieChart + Pie with innerRadius)
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Install Recharts, create WeightSummaryCard, wire into setup detail page</name>
|
||||||
|
<files>
|
||||||
|
src/client/components/WeightSummaryCard.tsx,
|
||||||
|
src/client/routes/setups/$setupId.tsx,
|
||||||
|
package.json
|
||||||
|
</files>
|
||||||
|
<action>
|
||||||
|
1. **Install Recharts**: Run `bun add recharts`. This adds recharts to package.json. React and react-dom are already peer deps in the project.
|
||||||
|
|
||||||
|
2. **Create WeightSummaryCard component** (`src/client/components/WeightSummaryCard.tsx`):
|
||||||
|
|
||||||
|
**Props interface:**
|
||||||
|
```typescript
|
||||||
|
interface WeightSummaryCardProps {
|
||||||
|
items: SetupItemWithCategory[]; // from useSetups hook (includes classification field)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Import `SetupItemWithCategory` from `../hooks/useSetups`.
|
||||||
|
|
||||||
|
**State:** `viewMode: "category" | "classification"` -- local React state, default "category".
|
||||||
|
|
||||||
|
**Weight subtotals computation** (derive from items array):
|
||||||
|
```typescript
|
||||||
|
const baseWeight = items.reduce((sum, i) => i.classification === "base" ? sum + (i.weightGrams ?? 0) : sum, 0);
|
||||||
|
const wornWeight = items.reduce((sum, i) => i.classification === "worn" ? sum + (i.weightGrams ?? 0) : sum, 0);
|
||||||
|
const consumableWeight = items.reduce((sum, i) => i.classification === "consumable" ? sum + (i.weightGrams ?? 0) : sum, 0);
|
||||||
|
const totalWeight = baseWeight + wornWeight + consumableWeight;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Chart data transformation:**
|
||||||
|
- `buildCategoryChartData(items)`: Group by `categoryName`, sum `weightGrams`, compute percentage. Filter out zero-weight groups. Return `Array<{ name: string, weight: number, percent: number }>`.
|
||||||
|
- `buildClassificationChartData(items)`: Group by classification using labels ("Base Weight", "Worn", "Consumable"), sum weights, compute percentage. Filter out zero-weight groups.
|
||||||
|
- Select data source based on `viewMode`.
|
||||||
|
|
||||||
|
**Render structure:**
|
||||||
|
```
|
||||||
|
<div className="bg-white rounded-xl border border-gray-100 p-5 mb-6">
|
||||||
|
<!-- Pill toggle: Category | Classification -->
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Weight Summary</h3>
|
||||||
|
<PillToggle viewMode={viewMode} onChange={setViewMode} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content: chart + subtotals side by side -->
|
||||||
|
<div className="flex items-center gap-8">
|
||||||
|
<!-- Donut chart -->
|
||||||
|
<div className="flex-shrink-0" style={{ width: 180, height: 180 }}>
|
||||||
|
<ResponsiveContainer width="100%" height={180}>
|
||||||
|
<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"
|
||||||
|
style={{ fontSize: "14px", fontWeight: 600, fill: "#374151" }} />
|
||||||
|
</Pie>
|
||||||
|
<Tooltip content={<CustomTooltip unit={unit} />} />
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weight subtotals columns -->
|
||||||
|
<div className="flex-1 grid grid-cols-4 gap-4">
|
||||||
|
<SubtotalColumn label="Base" weight={baseWeight} unit={unit} color="#6366f1" />
|
||||||
|
<SubtotalColumn label="Worn" weight={wornWeight} unit={unit} color="#f59e0b" />
|
||||||
|
<SubtotalColumn label="Consumable" weight={consumableWeight} unit={unit} color="#10b981" />
|
||||||
|
<SubtotalColumn label="Total" weight={totalWeight} unit={unit} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pill toggle** (inline component or extracted):
|
||||||
|
- Two buttons in a `bg-gray-100 rounded-full` container: "Category" and "Classification".
|
||||||
|
- Active state: `bg-white text-gray-700 shadow-sm font-medium`. Inactive: `text-gray-400 hover:text-gray-600`.
|
||||||
|
- Same pattern as TotalsBar weight unit selector.
|
||||||
|
|
||||||
|
**SubtotalColumn** (inline component):
|
||||||
|
- Vertical stack: colored dot (if color provided), label in text-xs text-gray-500, weight value in text-sm font-semibold text-gray-900.
|
||||||
|
|
||||||
|
**CustomTooltip:**
|
||||||
|
- Props: `active`, `payload`, `unit` (WeightUnit).
|
||||||
|
- When active and payload exists, show: segment name (bold), weight formatted with `formatWeight()`, percentage as `(XX.X%)`.
|
||||||
|
- Styled: `bg-white border border-gray-200 rounded-lg shadow-lg px-3 py-2 text-sm`.
|
||||||
|
|
||||||
|
**Color selection:**
|
||||||
|
- When `viewMode === "category"`: use `CATEGORY_COLORS` array (cycle through for many categories).
|
||||||
|
- When `viewMode === "classification"`: use `CLASSIFICATION_COLORS` object (keyed by classification value).
|
||||||
|
|
||||||
|
**Edge cases:**
|
||||||
|
- If all items have null/zero weight, show a placeholder message ("No weight data to display") instead of the chart.
|
||||||
|
- If items array is empty, component should not render (handled by parent).
|
||||||
|
|
||||||
|
3. **Wire into setup detail page** (`src/client/routes/setups/$setupId.tsx`):
|
||||||
|
- Import `WeightSummaryCard` from `../../components/WeightSummaryCard`.
|
||||||
|
- Render `<WeightSummaryCard items={setup.items} />` between the actions bar and the items grid (before the `{itemCount > 0 && (` block), but INSIDE the `itemCount > 0` condition so it only shows when there are items.
|
||||||
|
- Exact placement: after the actions `<div>` and before the items-grouped-by-category `<div>`, within the `{itemCount > 0 && (...)}` block.
|
||||||
|
|
||||||
|
4. **Verify**: Run `bun run build` to ensure no TypeScript errors and Recharts imports resolve correctly.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bun run build</automated>
|
||||||
|
</verify>
|
||||||
|
<done>
|
||||||
|
- WeightSummaryCard renders below sticky bar when setup has items
|
||||||
|
- Shows 4 columns: Base | Worn | Consumable | Total with correct weight values in selected unit
|
||||||
|
- Donut chart renders with colored segments for weight distribution
|
||||||
|
- Pill toggle switches between category view and classification view
|
||||||
|
- Hovering chart segments shows tooltip with name, weight, and percentage
|
||||||
|
- Total weight displayed in center of donut hole
|
||||||
|
- Empty/zero-weight items handled gracefully
|
||||||
|
- Build succeeds with no TypeScript errors
|
||||||
|
</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Visual verification of complete weight classification and visualization</name>
|
||||||
|
<files>N/A</files>
|
||||||
|
<action>
|
||||||
|
Present the user with verification steps for the complete Phase 9 feature set.
|
||||||
|
This checkpoint covers both Plan 01 (classification badges) and Plan 02 (summary card + chart) together.
|
||||||
|
</action>
|
||||||
|
<what-built>
|
||||||
|
Complete weight classification and visualization system:
|
||||||
|
1. Classification badges on every item card in setup view (click to cycle: base weight / worn / consumable)
|
||||||
|
2. Weight summary card with Base | Worn | Consumable | Total subtotals
|
||||||
|
3. Donut chart with category/classification toggle and hover tooltips
|
||||||
|
4. Total weight in the center of the donut hole
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Start dev servers: `bun run dev:server` and `bun run dev:client`
|
||||||
|
2. Open http://localhost:5173 and navigate to a setup with items (or create one and add items)
|
||||||
|
3. **Classification badges**: Verify each item card shows a gray pill badge. Click it and confirm it cycles: "Base Weight" -> "Worn" -> "Consumable" -> "Base Weight". Confirm clicking the badge does NOT open the item edit panel.
|
||||||
|
4. **Classification persistence**: Refresh the page. Confirm classifications are preserved.
|
||||||
|
5. **Weight subtotals**: With items classified differently, verify the summary card shows correct subtotals for Base, Worn, Consumable, and Total columns.
|
||||||
|
6. **Donut chart (Category view)**: Verify the donut chart shows colored segments grouped by category. Hover segments to see tooltip with category name, weight, and percentage.
|
||||||
|
7. **Donut chart (Classification view)**: Click the "Classification" pill toggle. Verify chart segments change to show base/worn/consumable breakdown with different colors. Hover to verify tooltips.
|
||||||
|
8. **Donut center**: Confirm total weight is displayed in the center of the donut hole in the selected weight unit.
|
||||||
|
9. **Weight unit**: Toggle the weight unit in the top bar (if available). Confirm all subtotals, chart center, and tooltips update to the new unit.
|
||||||
|
10. **Add/remove items**: Add another item to the setup. Verify it appears with default "Base Weight" badge and the chart updates. Remove an item and verify classifications for remaining items are preserved.
|
||||||
|
</how-to-verify>
|
||||||
|
<verify>Visual verification by user following steps above</verify>
|
||||||
|
<done>User confirms all classification badges, weight subtotals, donut chart, toggle, and tooltips work correctly</done>
|
||||||
|
<resume-signal>Type "approved" to complete Phase 9, or describe any issues to address</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
```bash
|
||||||
|
# Full test suite passes
|
||||||
|
bun test
|
||||||
|
|
||||||
|
# Build succeeds
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
# Lint passes
|
||||||
|
bun run lint
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- WeightSummaryCard visible below sticky bar on setup detail page (only when items exist)
|
||||||
|
- Four weight columns (Base, Worn, Consumable, Total) show correct values in selected unit
|
||||||
|
- Donut chart renders with colored segments proportional to weight distribution
|
||||||
|
- Pill toggle switches between category and classification chart views
|
||||||
|
- Tooltip on hover shows segment name, formatted weight, and percentage
|
||||||
|
- Total weight displayed in center of donut hole
|
||||||
|
- Chart handles edge cases (no weight data, single category, etc.)
|
||||||
|
- User confirms visual appearance matches expectations
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/09-weight-classification-and-visualization/09-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user