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