docs(09): create phase plan for weight classification and visualization
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user