From 0e239969867b2bdbe8c462f088953675f6174121 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 15:05:23 +0100 Subject: [PATCH] docs(09): create phase plan for weight classification and visualization --- .planning/ROADMAP.md | 8 +- .../09-01-PLAN.md | 360 ++++++++++++++++++ .../09-02-PLAN.md | 309 +++++++++++++++ 3 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 .planning/phases/09-weight-classification-and-visualization/09-01-PLAN.md create mode 100644 .planning/phases/09-weight-classification-and-visualization/09-02-PLAN.md diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index a1cd4dd..25dbcea 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -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 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 -**Plans**: TBD +**Plans:** 2 plans Plans: -- [ ] 09-01: TBD -- [ ] 09-02: TBD +- [ ] 09-01-PLAN.md -- Classification vertical slice (schema, service, tests, API route, ClassificationBadge UI) +- [ ] 09-02-PLAN.md -- WeightSummaryCard with subtotals, donut chart, pill toggle, and visual verification ## Progress @@ -95,4 +95,4 @@ Plans: | 6. Category Icons | v1.1 | 3/3 | Complete | 2026-03-15 | | 7. Weight Unit Selection | v1.2 | 1/2 | In Progress | - | | 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 | - | diff --git a/.planning/phases/09-weight-classification-and-visualization/09-01-PLAN.md b/.planning/phases/09-weight-classification-and-visualization/09-01-PLAN.md new file mode 100644 index 0000000..f04678f --- /dev/null +++ b/.planning/phases/09-weight-classification-and-visualization/09-01-PLAN.md @@ -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" +--- + + +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. + + + +@/home/jlmak/.claude/get-shit-done/workflows/execute-plan.md +@/home/jlmak/.claude/get-shit-done/templates/summary.md + + + +@.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 + + + + +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(url: string): Promise; +export async function apiPost(url: string, body: unknown): Promise; +export async function apiPut(url: string, body: unknown): Promise; +export async function apiDelete(url: string): Promise; +``` + +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 +) +``` + + + + + + + Task 1: Schema migration, service layer, and tests for classification + + 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 + + + - 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 + + + 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` (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). + + + bun test tests/services/setup.service.test.ts + + + - 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 + + + + + Task 2: API route, client hook, ClassificationBadge, and wiring into setup detail page + + 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 + + + 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(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleResponse(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 `