docs(09): create phase plan for weight classification and visualization

This commit is contained in:
2026-03-16 15:05:23 +01:00
parent 7d6cf31b05
commit 0e23996986
3 changed files with 673 additions and 4 deletions

View File

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