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) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 16:38:48 +01:00
parent eb79ab671e
commit d05aac0687
2 changed files with 401 additions and 277 deletions

View File

@@ -2,76 +2,84 @@ import { useNavigate } from "@tanstack/react-router";
import { formatPrice } from "../lib/formatters"; import { formatPrice } from "../lib/formatters";
interface ThreadCardProps { interface ThreadCardProps {
id: number; id: number;
name: string; name: string;
candidateCount: number; candidateCount: number;
minPriceCents: number | null; minPriceCents: number | null;
maxPriceCents: number | null; maxPriceCents: number | null;
createdAt: string; createdAt: string;
status: "active" | "resolved"; status: "active" | "resolved";
categoryName: string;
categoryEmoji: string;
} }
function formatDate(iso: string): string { function formatDate(iso: string): string {
const d = new Date(iso); const d = new Date(iso);
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
} }
function formatPriceRange( function formatPriceRange(
min: number | null, min: number | null,
max: number | null, max: number | null,
): string | null { ): string | null {
if (min == null && max == null) return null; if (min == null && max == null) return null;
if (min === max) return formatPrice(min); if (min === max) return formatPrice(min);
return `${formatPrice(min)} - ${formatPrice(max)}`; return `${formatPrice(min)} - ${formatPrice(max)}`;
} }
export function ThreadCard({ export function ThreadCard({
id, id,
name, name,
candidateCount, candidateCount,
minPriceCents, minPriceCents,
maxPriceCents, maxPriceCents,
createdAt, createdAt,
status, status,
categoryName,
categoryEmoji,
}: ThreadCardProps) { }: ThreadCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isResolved = status === "resolved"; const isResolved = status === "resolved";
const priceRange = formatPriceRange(minPriceCents, maxPriceCents); const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
return ( return (
<button <button
type="button" type="button"
onClick={() => onClick={() =>
navigate({ to: "/threads/$threadId", params: { threadId: String(id) } }) navigate({
} to: "/threads/$threadId",
className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${ params: { threadId: String(id) },
isResolved ? "opacity-60" : "" })
}`} }
> className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${
<div className="flex items-start justify-between mb-2"> isResolved ? "opacity-60" : ""
<h3 className="text-sm font-semibold text-gray-900 truncate"> }`}
{name} >
</h3> <div className="flex items-start justify-between mb-2">
{isResolved && ( <h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
<span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0"> {isResolved && (
Resolved <span className="ml-2 inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500 shrink-0">
</span> Resolved
)} </span>
</div> )}
<div className="flex flex-wrap gap-1.5"> </div>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <div className="flex flex-wrap gap-1.5">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"} <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
</span> {categoryEmoji} {categoryName}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600"> </span>
{formatDate(createdAt)} <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
</span> {candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
{priceRange && ( </span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
{priceRange} {formatDate(createdAt)}
</span> </span>
)} {priceRange && (
</div> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-700">
</button> {priceRange}
); </span>
)}
</div>
</button>
);
} }

View File

@@ -1,252 +1,368 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import { z } from "zod"; 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 { CategoryHeader } from "../../components/CategoryHeader";
import { CreateThreadModal } from "../../components/CreateThreadModal";
import { ItemCard } from "../../components/ItemCard"; import { ItemCard } from "../../components/ItemCard";
import { ThreadTabs } from "../../components/ThreadTabs";
import { ThreadCard } from "../../components/ThreadCard"; 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"; import { useUIStore } from "../../stores/uiStore";
const searchSchema = z.object({ const searchSchema = z.object({
tab: z.enum(["gear", "planning"]).catch("gear"), tab: z.enum(["gear", "planning"]).catch("gear"),
}); });
export const Route = createFileRoute("/collection/")({ export const Route = createFileRoute("/collection/")({
validateSearch: searchSchema, validateSearch: searchSchema,
component: CollectionPage, component: CollectionPage,
}); });
function CollectionPage() { function CollectionPage() {
const { tab } = Route.useSearch(); const { tab } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
function handleTabChange(newTab: "gear" | "planning") { function handleTabChange(newTab: "gear" | "planning") {
navigate({ to: "/collection", search: { tab: newTab } }); navigate({ to: "/collection", search: { tab: newTab } });
} }
return ( return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<ThreadTabs active={tab} onChange={handleTabChange} /> <ThreadTabs active={tab} onChange={handleTabChange} />
<div className="mt-6"> <div className="mt-6">
{tab === "gear" ? <CollectionView /> : <PlanningView />} {tab === "gear" ? <CollectionView /> : <PlanningView />}
</div> </div>
</div> </div>
); );
} }
function CollectionView() { function CollectionView() {
const { data: items, isLoading: itemsLoading } = useItems(); const { data: items, isLoading: itemsLoading } = useItems();
const { data: totals } = useTotals(); const { data: totals } = useTotals();
const openAddPanel = useUIStore((s) => s.openAddPanel); const openAddPanel = useUIStore((s) => s.openAddPanel);
if (itemsLoading) { if (itemsLoading) {
return ( return (
<div className="animate-pulse space-y-6"> <div className="animate-pulse space-y-6">
<div className="h-6 bg-gray-200 rounded w-48" /> <div className="h-6 bg-gray-200 rounded w-48" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3].map((i) => ( {[1, 2, 3].map((i) => (
<div key={i} className="h-40 bg-gray-200 rounded-xl" /> <div key={i} className="h-40 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
</div> </div>
); );
} }
if (!items || items.length === 0) { if (!items || items.length === 0) {
return ( return (
<div className="py-16 text-center"> <div className="py-16 text-center">
<div className="max-w-md mx-auto"> <div className="max-w-md mx-auto">
<div className="text-5xl mb-4">🎒</div> <div className="text-5xl mb-4">🎒</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Your collection is empty Your collection is empty
</h2> </h2>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Start cataloging your gear by adding your first item. Track weight, Start cataloging your gear by adding your first item. Track weight,
price, and organize by category. price, and organize by category.
</p> </p>
<button <button
type="button" type="button"
onClick={openAddPanel} onClick={openAddPanel}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors" className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
> >
<svg <svg
className="w-4 h-4" aria-hidden="true"
fill="none" className="w-4 h-4"
stroke="currentColor" fill="none"
viewBox="0 0 24 24" stroke="currentColor"
> viewBox="0 0 24 24"
<path >
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
strokeWidth={2} strokeLinejoin="round"
d="M12 4v16m8-8H4" strokeWidth={2}
/> d="M12 4v16m8-8H4"
</svg> />
Add your first item </svg>
</button> Add your first item
</div> </button>
</div> </div>
); </div>
} );
}
// Group items by categoryId // Group items by categoryId
const groupedItems = new Map< const groupedItems = new Map<
number, number,
{ items: typeof items; categoryName: string; categoryEmoji: string } { items: typeof items; categoryName: string; categoryEmoji: string }
>(); >();
for (const item of items) { for (const item of items) {
const group = groupedItems.get(item.categoryId); const group = groupedItems.get(item.categoryId);
if (group) { if (group) {
group.items.push(item); group.items.push(item);
} else { } else {
groupedItems.set(item.categoryId, { groupedItems.set(item.categoryId, {
items: [item], items: [item],
categoryName: item.categoryName, categoryName: item.categoryName,
categoryEmoji: item.categoryEmoji, categoryEmoji: item.categoryEmoji,
}); });
} }
} }
// Build category totals lookup // Build category totals lookup
const categoryTotalsMap = new Map< const categoryTotalsMap = new Map<
number, number,
{ totalWeight: number; totalCost: number; itemCount: number } { totalWeight: number; totalCost: number; itemCount: number }
>(); >();
if (totals?.categories) { if (totals?.categories) {
for (const ct of totals.categories) { for (const ct of totals.categories) {
categoryTotalsMap.set(ct.categoryId, { categoryTotalsMap.set(ct.categoryId, {
totalWeight: ct.totalWeight, totalWeight: ct.totalWeight,
totalCost: ct.totalCost, totalCost: ct.totalCost,
itemCount: ct.itemCount, itemCount: ct.itemCount,
}); });
} }
} }
return ( return (
<> <>
{Array.from(groupedItems.entries()).map( {Array.from(groupedItems.entries()).map(
([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => { ([
const catTotals = categoryTotalsMap.get(categoryId); categoryId,
return ( { items: categoryItems, categoryName, categoryEmoji },
<div key={categoryId} className="mb-8"> ]) => {
<CategoryHeader const catTotals = categoryTotalsMap.get(categoryId);
categoryId={categoryId} return (
name={categoryName} <div key={categoryId} className="mb-8">
emoji={categoryEmoji} <CategoryHeader
totalWeight={catTotals?.totalWeight ?? 0} categoryId={categoryId}
totalCost={catTotals?.totalCost ?? 0} name={categoryName}
itemCount={catTotals?.itemCount ?? categoryItems.length} emoji={categoryEmoji}
/> totalWeight={catTotals?.totalWeight ?? 0}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> totalCost={catTotals?.totalCost ?? 0}
{categoryItems.map((item) => ( itemCount={catTotals?.itemCount ?? categoryItems.length}
<ItemCard />
key={item.id} <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
id={item.id} {categoryItems.map((item) => (
name={item.name} <ItemCard
weightGrams={item.weightGrams} key={item.id}
priceCents={item.priceCents} id={item.id}
categoryName={categoryName} name={item.name}
categoryEmoji={categoryEmoji} weightGrams={item.weightGrams}
imageFilename={item.imageFilename} priceCents={item.priceCents}
/> categoryName={categoryName}
))} categoryEmoji={categoryEmoji}
</div> imageFilename={item.imageFilename}
</div> />
); ))}
}, </div>
)} </div>
</> );
); },
)}
</>
);
} }
function PlanningView() { function PlanningView() {
const [showResolved, setShowResolved] = useState(false); const [activeTab, setActiveTab] = useState<"active" | "resolved">("active");
const [newThreadName, setNewThreadName] = useState(""); const [categoryFilter, setCategoryFilter] = useState<number | null>(null);
const { data: threads, isLoading } = useThreads(showResolved);
const createThread = useCreateThread();
function handleCreateThread(e: React.FormEvent) { const openCreateThreadModal = useUIStore((s) => s.openCreateThreadModal);
e.preventDefault(); const { data: categories } = useCategories();
const name = newThreadName.trim(); const { data: threads, isLoading } = useThreads(activeTab === "resolved");
if (!name) return;
createThread.mutate(
{ name },
{ onSuccess: () => setNewThreadName("") },
);
}
if (isLoading) { if (isLoading) {
return ( return (
<div className="animate-pulse space-y-4"> <div className="animate-pulse space-y-4">
{[1, 2].map((i) => ( {[1, 2].map((i) => (
<div key={i} className="h-24 bg-gray-200 rounded-xl" /> <div key={i} className="h-24 bg-gray-200 rounded-xl" />
))} ))}
</div> </div>
); );
} }
return ( // Filter threads by active tab and category
<div> const filteredThreads = (threads ?? [])
{/* Create thread form */} .filter((t) => t.status === activeTab)
<form onSubmit={handleCreateThread} className="flex gap-2 mb-6"> .filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
<input
type="text"
value={newThreadName}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={!newThreadName.trim() || createThread.isPending}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white text-sm font-medium rounded-lg transition-colors"
>
{createThread.isPending ? "Creating..." : "Create"}
</button>
</form>
{/* Show resolved toggle */} // Determine if we should show the educational empty state
<label className="flex items-center gap-2 mb-4 text-sm text-gray-600 cursor-pointer"> const isEmptyNoFilters =
<input filteredThreads.length === 0 &&
type="checkbox" activeTab === "active" &&
checked={showResolved} categoryFilter === null &&
onChange={(e) => setShowResolved(e.target.checked)} (!threads || threads.length === 0);
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Show archived threads
</label>
{/* Thread list */} return (
{!threads || threads.length === 0 ? ( <div>
<div className="py-12 text-center"> {/* Header row */}
<div className="text-4xl mb-3">🔍</div> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <h2 className="text-lg font-semibold text-gray-900">
No planning threads yet Planning Threads
</h3> </h2>
<p className="text-sm text-gray-500"> <button
Start one to research your next purchase. type="button"
</p> onClick={openCreateThreadModal}
</div> className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
) : ( >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <svg
{threads.map((thread) => ( aria-hidden="true"
<ThreadCard className="w-4 h-4"
key={thread.id} fill="none"
id={thread.id} stroke="currentColor"
name={thread.name} viewBox="0 0 24 24"
candidateCount={thread.candidateCount} >
minPriceCents={thread.minPriceCents} <path
maxPriceCents={thread.maxPriceCents} strokeLinecap="round"
createdAt={thread.createdAt} strokeLinejoin="round"
status={thread.status} strokeWidth={2}
/> d="M12 4v16m8-8H4"
))} />
</div> </svg>
)} New Thread
</div> </button>
); </div>
{/* Filter row */}
<div className="flex items-center justify-between mb-6">
{/* Pill tabs */}
<div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
<button
type="button"
onClick={() => setActiveTab("active")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "active"
? "bg-blue-600 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Active
</button>
<button
type="button"
onClick={() => setActiveTab("resolved")}
className={`px-4 py-1.5 text-sm font-medium rounded-full transition-colors ${
activeTab === "resolved"
? "bg-blue-600 text-white"
: "text-gray-600 hover:bg-gray-200"
}`}
>
Resolved
</button>
</div>
{/* Category filter */}
<select
value={categoryFilter ?? ""}
onChange={(e) =>
setCategoryFilter(e.target.value ? Number(e.target.value) : null)
}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-sm bg-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All categories</option>
{categories?.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.emoji} {cat.name}
</option>
))}
</select>
</div>
{/* Content: empty state or thread grid */}
{isEmptyNoFilters ? (
<div className="py-16">
<div className="max-w-lg mx-auto text-center">
<h2 className="text-xl font-semibold text-gray-900 mb-8">
Plan your next purchase
</h2>
<div className="space-y-6 text-left mb-10">
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
1
</div>
<div>
<p className="font-medium text-gray-900">Create a thread</p>
<p className="text-sm text-gray-500">
Start a research thread for gear you're considering
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
2
</div>
<div>
<p className="font-medium text-gray-900">Add candidates</p>
<p className="text-sm text-gray-500">
Add products you're comparing with prices and weights
</p>
</div>
</div>
<div className="flex items-start gap-4">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100 text-blue-700 font-bold text-sm shrink-0">
3
</div>
<div>
<p className="font-medium text-gray-900">Pick a winner</p>
<p className="text-sm text-gray-500">
Resolve the thread and the winner joins your collection
</p>
</div>
</div>
</div>
<button
type="button"
onClick={openCreateThreadModal}
className="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
Create your first thread
</button>
</div>
</div>
) : filteredThreads.length === 0 ? (
<div className="py-12 text-center">
<p className="text-sm text-gray-500">No threads found</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredThreads.map((thread) => (
<ThreadCard
key={thread.id}
id={thread.id}
name={thread.name}
candidateCount={thread.candidateCount}
minPriceCents={thread.minPriceCents}
maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt}
status={thread.status}
categoryName={thread.categoryName}
categoryEmoji={thread.categoryEmoji}
/>
))}
</div>
)}
<CreateThreadModal />
</div>
);
} }