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 (
-
-
-
- {candidateCount} {candidateCount === 1 ? "candidate" : "candidates"}
-
-
- {formatDate(createdAt)}
-
- {priceRange && (
-
- {priceRange}
-
- )}
-
-
- );
+ 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 */}
-
+ // 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 ? (
+
+ ) : (
+
+ {filteredThreads.map((thread) => (
+
+ ))}
+
+ )}
+
+
+
+ );
}