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

@@ -9,6 +9,8 @@ interface ThreadCardProps {
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 {
@@ -33,6 +35,8 @@ export function ThreadCard({
maxPriceCents, maxPriceCents,
createdAt, createdAt,
status, status,
categoryName,
categoryEmoji,
}: ThreadCardProps) { }: ThreadCardProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -43,16 +47,17 @@ export function ThreadCard({
<button <button
type="button" type="button"
onClick={() => onClick={() =>
navigate({ to: "/threads/$threadId", params: { threadId: String(id) } }) navigate({
to: "/threads/$threadId",
params: { threadId: String(id) },
})
} }
className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${ className={`w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all p-4 ${
isResolved ? "opacity-60" : "" isResolved ? "opacity-60" : ""
}`} }`}
> >
<div className="flex items-start justify-between mb-2"> <div className="flex items-start justify-between mb-2">
<h3 className="text-sm font-semibold text-gray-900 truncate"> <h3 className="text-sm font-semibold text-gray-900 truncate">{name}</h3>
{name}
</h3>
{isResolved && ( {isResolved && (
<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 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">
Resolved Resolved
@@ -60,6 +65,9 @@ export function ThreadCard({
)} )}
</div> </div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{categoryEmoji} {categoryName}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700"> <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-50 text-purple-700">
{candidateCount} {candidateCount === 1 ? "candidate" : "candidates"} {candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
</span> </span>

View File

@@ -1,13 +1,15 @@
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({
@@ -73,6 +75,7 @@ function CollectionView() {
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
aria-hidden="true"
className="w-4 h-4" className="w-4 h-4"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -129,7 +132,10 @@ function CollectionView() {
return ( return (
<> <>
{Array.from(groupedItems.entries()).map( {Array.from(groupedItems.entries()).map(
([categoryId, { items: categoryItems, categoryName, categoryEmoji }]) => { ([
categoryId,
{ items: categoryItems, categoryName, categoryEmoji },
]) => {
const catTotals = categoryTotalsMap.get(categoryId); const catTotals = categoryTotalsMap.get(categoryId);
return ( return (
<div key={categoryId} className="mb-8"> <div key={categoryId} className="mb-8">
@@ -164,20 +170,12 @@ function CollectionView() {
} }
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 (
@@ -189,51 +187,165 @@ function PlanningView() {
); );
} }
// Filter threads by active tab and category
const filteredThreads = (threads ?? [])
.filter((t) => t.status === activeTab)
.filter((t) => (categoryFilter ? t.categoryId === categoryFilter : true));
// Determine if we should show the educational empty state
const isEmptyNoFilters =
filteredThreads.length === 0 &&
activeTab === "active" &&
categoryFilter === null &&
(!threads || threads.length === 0);
return ( return (
<div> <div>
{/* Create thread form */} {/* Header row */}
<form onSubmit={handleCreateThread} className="flex gap-2 mb-6"> <div className="flex items-center justify-between mb-4">
<input <h2 className="text-lg font-semibold text-gray-900">
type="text" Planning Threads
value={newThreadName} </h2>
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 <button
type="submit" type="button"
disabled={!newThreadName.trim() || createThread.isPending} onClick={openCreateThreadModal}
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" 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"
> >
{createThread.isPending ? "Creating..." : "Create"} <svg
</button> aria-hidden="true"
</form> className="w-4 h-4"
fill="none"
{/* Show resolved toggle */} stroke="currentColor"
<label className="flex items-center gap-2 mb-4 text-sm text-gray-600 cursor-pointer"> viewBox="0 0 24 24"
<input >
type="checkbox" <path
checked={showResolved} strokeLinecap="round"
onChange={(e) => setShowResolved(e.target.checked)} strokeLinejoin="round"
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500" strokeWidth={2}
d="M12 4v16m8-8H4"
/> />
Show archived threads </svg>
</label> New Thread
</button>
</div>
{/* Thread list */} {/* Filter row */}
{!threads || threads.length === 0 ? ( <div className="flex items-center justify-between mb-6">
<div className="py-12 text-center"> {/* Pill tabs */}
<div className="text-4xl mb-3">🔍</div> <div className="flex bg-gray-100 rounded-full p-0.5 gap-0.5">
<h3 className="text-lg font-semibold text-gray-900 mb-1"> <button
No planning threads yet type="button"
</h3> 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"> <p className="text-sm text-gray-500">
Start one to research your next purchase. Start a research thread for gear you're considering
</p> </p>
</div> </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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{threads.map((thread) => ( {filteredThreads.map((thread) => (
<ThreadCard <ThreadCard
key={thread.id} key={thread.id}
id={thread.id} id={thread.id}
@@ -243,10 +355,14 @@ function PlanningView() {
maxPriceCents={thread.maxPriceCents} maxPriceCents={thread.maxPriceCents}
createdAt={thread.createdAt} createdAt={thread.createdAt}
status={thread.status} status={thread.status}
categoryName={thread.categoryName}
categoryEmoji={thread.categoryEmoji}
/> />
))} ))}
</div> </div>
)} )}
<CreateThreadModal />
</div> </div>
); );
} }