From d05aac0687d3374de3d63a7fc773e6516e25eb11 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Sun, 15 Mar 2026 16:38:48 +0100 Subject: [PATCH] feat(04-02): overhaul PlanningView with empty state, pill tabs, and category filter - Replace inline thread creation form with modal trigger - Add educational empty state with 3-step workflow guide - Add Active/Resolved pill tab selector replacing checkbox - Add category filter dropdown for thread list - Display category emoji + name badge on ThreadCard - Add aria-hidden to decorative SVG icons for a11y - Auto-format pre-existing indentation issues (spaces to tabs) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/client/components/ThreadCard.tsx | 126 +++--- src/client/routes/collection/index.tsx | 552 +++++++++++++++---------- 2 files changed, 401 insertions(+), 277 deletions(-) diff --git a/src/client/components/ThreadCard.tsx b/src/client/components/ThreadCard.tsx index c05f6a2..ab40f42 100644 --- a/src/client/components/ThreadCard.tsx +++ b/src/client/components/ThreadCard.tsx @@ -2,76 +2,84 @@ import { useNavigate } from "@tanstack/react-router"; import { formatPrice } from "../lib/formatters"; interface ThreadCardProps { - id: number; - name: string; - candidateCount: number; - minPriceCents: number | null; - maxPriceCents: number | null; - createdAt: string; - status: "active" | "resolved"; + id: number; + name: string; + candidateCount: number; + minPriceCents: number | null; + maxPriceCents: number | null; + createdAt: string; + status: "active" | "resolved"; + categoryName: string; + categoryEmoji: string; } function formatDate(iso: string): string { - const d = new Date(iso); - return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const d = new Date(iso); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } function formatPriceRange( - min: number | null, - max: number | null, + min: number | null, + max: number | null, ): string | null { - if (min == null && max == null) return null; - if (min === max) return formatPrice(min); - return `${formatPrice(min)} - ${formatPrice(max)}`; + if (min == null && max == null) return null; + if (min === max) return formatPrice(min); + return `${formatPrice(min)} - ${formatPrice(max)}`; } export function ThreadCard({ - id, - name, - candidateCount, - minPriceCents, - maxPriceCents, - createdAt, - status, + id, + name, + candidateCount, + minPriceCents, + maxPriceCents, + createdAt, + status, + categoryName, + categoryEmoji, }: ThreadCardProps) { - const navigate = useNavigate(); + const navigate = useNavigate(); - const isResolved = status === "resolved"; - const priceRange = formatPriceRange(minPriceCents, maxPriceCents); + const isResolved = status === "resolved"; + const priceRange = formatPriceRange(minPriceCents, maxPriceCents); - return ( - - ); + return ( + + ); } diff --git a/src/client/routes/collection/index.tsx b/src/client/routes/collection/index.tsx index 2695206..b87f999 100644 --- a/src/client/routes/collection/index.tsx +++ b/src/client/routes/collection/index.tsx @@ -1,252 +1,368 @@ -import { useState } from "react"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useState } from "react"; import { z } from "zod"; -import { useItems } from "../../hooks/useItems"; -import { useTotals } from "../../hooks/useTotals"; -import { useThreads, useCreateThread } from "../../hooks/useThreads"; import { CategoryHeader } from "../../components/CategoryHeader"; +import { CreateThreadModal } from "../../components/CreateThreadModal"; import { ItemCard } from "../../components/ItemCard"; -import { ThreadTabs } from "../../components/ThreadTabs"; import { ThreadCard } from "../../components/ThreadCard"; +import { ThreadTabs } from "../../components/ThreadTabs"; +import { useCategories } from "../../hooks/useCategories"; +import { useItems } from "../../hooks/useItems"; +import { useThreads } from "../../hooks/useThreads"; +import { useTotals } from "../../hooks/useTotals"; import { useUIStore } from "../../stores/uiStore"; const searchSchema = z.object({ - tab: z.enum(["gear", "planning"]).catch("gear"), + tab: z.enum(["gear", "planning"]).catch("gear"), }); export const Route = createFileRoute("/collection/")({ - validateSearch: searchSchema, - component: CollectionPage, + validateSearch: searchSchema, + component: CollectionPage, }); function CollectionPage() { - const { tab } = Route.useSearch(); - const navigate = useNavigate(); + const { tab } = Route.useSearch(); + const navigate = useNavigate(); - function handleTabChange(newTab: "gear" | "planning") { - navigate({ to: "/collection", search: { tab: newTab } }); - } + function handleTabChange(newTab: "gear" | "planning") { + navigate({ to: "/collection", search: { tab: newTab } }); + } - return ( -
- -
- {tab === "gear" ? : } -
-
- ); + return ( +
+ +
+ {tab === "gear" ? : } +
+
+ ); } function CollectionView() { - const { data: items, isLoading: itemsLoading } = useItems(); - const { data: totals } = useTotals(); - const openAddPanel = useUIStore((s) => s.openAddPanel); + const { data: items, isLoading: itemsLoading } = useItems(); + const { data: totals } = useTotals(); + const openAddPanel = useUIStore((s) => s.openAddPanel); - if (itemsLoading) { - return ( -
-
-
- {[1, 2, 3].map((i) => ( -
- ))} -
-
- ); - } + if (itemsLoading) { + return ( +
+
+
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+
+ ); + } - if (!items || items.length === 0) { - return ( -
-
-
🎒
-

- Your collection is empty -

-

- Start cataloging your gear by adding your first item. Track weight, - price, and organize by category. -

- -
-
- ); - } + if (!items || items.length === 0) { + return ( +
+
+
🎒
+

+ Your collection is empty +

+

+ Start cataloging your gear by adding your first item. Track weight, + price, and organize by category. +

+ +
+
+ ); + } - // Group items by categoryId - const groupedItems = new Map< - number, - { items: typeof items; categoryName: string; categoryEmoji: string } - >(); + // Group items by categoryId + const groupedItems = new Map< + number, + { items: typeof items; categoryName: string; categoryEmoji: string } + >(); - for (const item of items) { - const group = groupedItems.get(item.categoryId); - if (group) { - group.items.push(item); - } else { - groupedItems.set(item.categoryId, { - items: [item], - categoryName: item.categoryName, - categoryEmoji: item.categoryEmoji, - }); - } - } + for (const item of items) { + const group = groupedItems.get(item.categoryId); + if (group) { + group.items.push(item); + } else { + groupedItems.set(item.categoryId, { + items: [item], + categoryName: item.categoryName, + categoryEmoji: item.categoryEmoji, + }); + } + } - // Build category totals lookup - const categoryTotalsMap = new Map< - number, - { totalWeight: number; totalCost: number; itemCount: number } - >(); - if (totals?.categories) { - for (const ct of totals.categories) { - categoryTotalsMap.set(ct.categoryId, { - totalWeight: ct.totalWeight, - totalCost: ct.totalCost, - itemCount: ct.itemCount, - }); - } - } + // Build category totals lookup + const categoryTotalsMap = new Map< + number, + { totalWeight: number; totalCost: number; itemCount: number } + >(); + if (totals?.categories) { + for (const ct of totals.categories) { + categoryTotalsMap.set(ct.categoryId, { + totalWeight: ct.totalWeight, + totalCost: ct.totalCost, + itemCount: ct.itemCount, + }); + } + } - return ( - <> - {Array.from(groupedItems.entries()).map( - ([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => { - const catTotals = categoryTotalsMap.get(categoryId); - return ( -
- -
- {categoryItems.map((item) => ( - - ))} -
-
- ); - }, - )} - - ); + return ( + <> + {Array.from(groupedItems.entries()).map( + ([ + categoryId, + { items: categoryItems, categoryName, categoryEmoji }, + ]) => { + const catTotals = categoryTotalsMap.get(categoryId); + return ( +
+ +
+ {categoryItems.map((item) => ( + + ))} +
+
+ ); + }, + )} + + ); } function PlanningView() { - const [showResolved, setShowResolved] = useState(false); - const [newThreadName, setNewThreadName] = useState(""); - const { data: threads, isLoading } = useThreads(showResolved); - const createThread = useCreateThread(); + const [activeTab, setActiveTab] = useState<"active" | "resolved">("active"); + const [categoryFilter, setCategoryFilter] = useState(null); - function handleCreateThread(e: React.FormEvent) { - e.preventDefault(); - const name = newThreadName.trim(); - if (!name) return; - createThread.mutate( - { name }, - { onSuccess: () => setNewThreadName("") }, - ); - } + const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal); + const { data: categories } = useCategories(); + const { data: threads, isLoading } = useThreads(activeTab === "resolved"); - if (isLoading) { - return ( -
- {[1, 2].map((i) => ( -
- ))} -
- ); - } + if (isLoading) { + return ( +
+ {[1, 2].map((i) => ( +
+ ))} +
+ ); + } - return ( -
- {/* Create thread form */} -
- setNewThreadName(e.target.value)} - placeholder="New thread name..." - className="flex-1 px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> - -
+ // Filter threads by active tab and category + const filteredThreads = (threads ?? []) + .filter((t) => t.status === activeTab) + .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true)); - {/* Show resolved toggle */} - + // Determine if we should show the educational empty state + const isEmptyNoFilters = + filteredThreads.length === 0 && + activeTab === "active" && + categoryFilter === null && + (!threads || threads.length === 0); - {/* Thread list */} - {!threads || threads.length === 0 ? ( -
-
🔍
-

- No planning threads yet -

-

- Start one to research your next purchase. -

-
- ) : ( -
- {threads.map((thread) => ( - - ))} -
- )} -
- ); + return ( +
+ {/* Header row */} +
+

+ Planning Threads +

+ +
+ + {/* Filter row */} +
+ {/* Pill tabs */} +
+ + +
+ + {/* Category filter */} + +
+ + {/* Content: empty state or thread grid */} + {isEmptyNoFilters ? ( +
+
+

+ Plan your next purchase +

+
+
+
+ 1 +
+
+

Create a thread

+

+ Start a research thread for gear you're considering +

+
+
+
+
+ 2 +
+
+

Add candidates

+

+ Add products you're comparing with prices and weights +

+
+
+
+
+ 3 +
+
+

Pick a winner

+

+ Resolve the thread and the winner joins your collection +

+
+
+
+ +
+
+ ) : filteredThreads.length === 0 ? ( +
+

No threads found

+
+ ) : ( +
+ {filteredThreads.map((thread) => ( + + ))} +
+ )} + + +
+ ); }