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