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 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 15:13:08 +01:00
parent 4491e4c6f1
commit fb738d7cc2
6 changed files with 169 additions and 13 deletions

View File

@@ -0,0 +1,30 @@
const CLASSIFICATION_LABELS: Record<string, string> = {
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 (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onCycle();
}}
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer"
>
{label}
</button>
);
}

View File

@@ -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] });
},
});
}

View File

@@ -45,6 +45,15 @@ export async function apiPut<T>(url: string, body: unknown): Promise<T> {
return handleResponse<T>(res);
}
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);
}
export async function apiDelete<T>(url: string): Promise<T> {
const res = await fetch(url, { method: "DELETE" });
return handleResponse<T>(res);

View File

@@ -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() {
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{categoryItems.map((item) => (
<ItemCard
key={item.id}
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
onRemove={() => removeItem.mutate(item.id)}
/>
<div key={item.id}>
<ItemCard
id={item.id}
name={item.name}
weightGrams={item.weightGrams}
priceCents={item.priceCents}
categoryName={categoryName}
categoryIcon={categoryIcon}
imageFilename={item.imageFilename}
productUrl={item.productUrl}
onRemove={() => removeItem.mutate(item.id)}
/>
<div className="px-4 pb-3 -mt-1">
<ClassificationBadge
classification={item.classification}
onCycle={() =>
updateClassification.mutate({
itemId: item.id,
classification: nextClassification(
item.classification,
),
})
}
/>
</div>
</div>
))}
</div>
</div>

View File

@@ -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"));

View File

@@ -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");