From fb738d7cc24e8e46acb775d486ddcbe304184825 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Mon, 16 Mar 2026 15:13:08 +0100 Subject: [PATCH] feat(09-01): add classification API route, client hook, badge component, and setup detail wiring - Add PATCH /:id/items/:itemId/classification endpoint with Zod validation - Add apiPatch helper to client API library - Add useUpdateItemClassification mutation hook - Add classification field to SetupItemWithCategory interface - Create ClassificationBadge click-to-cycle component (base/worn/consumable) - Wire ClassificationBadge into setup detail page item grid - Add integration tests for PATCH classification route (valid + invalid) Co-Authored-By: Claude Opus 4.6 --- src/client/components/ClassificationBadge.tsx | 30 +++++++++ src/client/hooks/useSetups.ts | 20 +++++- src/client/lib/api.ts | 9 +++ src/client/routes/setups/$setupId.tsx | 47 ++++++++++---- src/server/routes/setups.ts | 15 +++++ tests/routes/setups.test.ts | 61 +++++++++++++++++++ 6 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 src/client/components/ClassificationBadge.tsx diff --git a/src/client/components/ClassificationBadge.tsx b/src/client/components/ClassificationBadge.tsx new file mode 100644 index 0000000..6d7c136 --- /dev/null +++ b/src/client/components/ClassificationBadge.tsx @@ -0,0 +1,30 @@ +const CLASSIFICATION_LABELS: Record = { + base: "Base Weight", + worn: "Worn", + consumable: "Consumable", +}; + +interface ClassificationBadgeProps { + classification: string; + onCycle: () => void; +} + +export function ClassificationBadge({ + classification, + onCycle, +}: ClassificationBadgeProps) { + const label = CLASSIFICATION_LABELS[classification] ?? "Base Weight"; + + return ( + + ); +} diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts index d4998a8..81e3350 100644 --- a/src/client/hooks/useSetups.ts +++ b/src/client/hooks/useSetups.ts @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { apiDelete, apiGet, apiPost, apiPut } from "../lib/api"; +import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "../lib/api"; interface SetupListItem { id: number; @@ -24,6 +24,7 @@ interface SetupItemWithCategory { updatedAt: string; categoryName: string; categoryIcon: string; + classification: string; } interface SetupWithItems { @@ -105,3 +106,20 @@ export function useRemoveSetupItem(setupId: number) { }, }); } + +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] }); + }, + }); +} diff --git a/src/client/lib/api.ts b/src/client/lib/api.ts index fd701a8..71dbea2 100644 --- a/src/client/lib/api.ts +++ b/src/client/lib/api.ts @@ -45,6 +45,15 @@ export async function apiPut(url: string, body: unknown): Promise { return handleResponse(res); } +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); +} + export async function apiDelete(url: string): Promise { const res = await fetch(url, { method: "DELETE" }); return handleResponse(res); diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx index 7a48e05..18753cd 100644 --- a/src/client/routes/setups/$setupId.tsx +++ b/src/client/routes/setups/$setupId.tsx @@ -1,12 +1,14 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { CategoryHeader } from "../../components/CategoryHeader"; +import { ClassificationBadge } from "../../components/ClassificationBadge"; import { ItemCard } from "../../components/ItemCard"; import { ItemPicker } from "../../components/ItemPicker"; import { useDeleteSetup, useRemoveSetupItem, useSetup, + useUpdateItemClassification, } from "../../hooks/useSetups"; import { useWeightUnit } from "../../hooks/useWeightUnit"; import { formatPrice, formatWeight } from "../../lib/formatters"; @@ -24,6 +26,7 @@ function SetupDetailPage() { const { data: setup, isLoading } = useSetup(numericId); const deleteSetup = useDeleteSetup(); const removeItem = useRemoveSetupItem(numericId); + const updateClassification = useUpdateItemClassification(numericId); const [pickerOpen, setPickerOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -86,6 +89,12 @@ function SetupDetailPage() { } } + function nextClassification(current: string): string { + const order = ["base", "worn", "consumable"]; + const idx = order.indexOf(current); + return order[(idx + 1) % order.length]; + } + function handleDelete() { deleteSetup.mutate(numericId, { onSuccess: () => navigate({ to: "/setups" }), @@ -208,18 +217,32 @@ function SetupDetailPage() { />
{categoryItems.map((item) => ( - removeItem.mutate(item.id)} - /> +
+ removeItem.mutate(item.id)} + /> +
+ + updateClassification.mutate({ + itemId: item.id, + classification: nextClassification( + item.classification, + ), + }) + } + /> +
+
))}
diff --git a/src/server/routes/setups.ts b/src/server/routes/setups.ts index f28ca02..fa773b0 100644 --- a/src/server/routes/setups.ts +++ b/src/server/routes/setups.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { createSetupSchema, syncSetupItemsSchema, + updateClassificationSchema, updateSetupSchema, } from "../../shared/schemas.ts"; import { @@ -12,6 +13,7 @@ import { getSetupWithItems, removeSetupItem, syncSetupItems, + updateItemClassification, updateSetup, } from "../services/setup.service.ts"; @@ -73,6 +75,19 @@ app.put("/:id/items", zValidator("json", syncSetupItemsSchema), (c) => { return c.json({ success: true }); }); +app.patch( + "/:id/items/:itemId/classification", + zValidator("json", updateClassificationSchema), + (c) => { + const db = c.get("db"); + const setupId = Number(c.req.param("id")); + const itemId = Number(c.req.param("itemId")); + const { classification } = c.req.valid("json"); + updateItemClassification(db, setupId, itemId, classification); + return c.json({ success: true }); + }, +); + app.delete("/:id/items/:itemId", (c) => { const db = c.get("db"); const setupId = Number(c.req.param("id")); diff --git a/tests/routes/setups.test.ts b/tests/routes/setups.test.ts index 0082425..cb121f6 100644 --- a/tests/routes/setups.test.ts +++ b/tests/routes/setups.test.ts @@ -205,6 +205,67 @@ describe("Setup Routes", () => { }); }); + describe("PATCH /api/setups/:id/items/:itemId/classification", () => { + it("updates item classification and persists it", async () => { + const setup = await createSetupViaAPI(app, "Kit"); + const item = await createItemViaAPI(app, { + name: "Jacket", + categoryId: 1, + }); + + // Sync item to setup + await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item.id] }), + }); + + // Patch classification to "worn" + const res = await app.request( + `/api/setups/${setup.id}/items/${item.id}/classification`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classification: "worn" }), + }, + ); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + + // Verify classification persisted + const getRes = await app.request(`/api/setups/${setup.id}`); + const getBody = await getRes.json(); + expect(getBody.items[0].classification).toBe("worn"); + }); + + it("returns 400 for invalid classification value", async () => { + const setup = await createSetupViaAPI(app, "Kit"); + const item = await createItemViaAPI(app, { + name: "Tent", + categoryId: 1, + }); + + await app.request(`/api/setups/${setup.id}/items`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ itemIds: [item.id] }), + }); + + const res = await app.request( + `/api/setups/${setup.id}/items/${item.id}/classification`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ classification: "invalid-value" }), + }, + ); + + expect(res.status).toBe(400); + }); + }); + describe("DELETE /api/setups/:id/items/:itemId", () => { it("removes single item from setup", async () => { const setup = await createSetupViaAPI(app, "Kit");