diff --git a/src/client/components/DashboardCard.tsx b/src/client/components/DashboardCard.tsx new file mode 100644 index 0000000..cf83ed1 --- /dev/null +++ b/src/client/components/DashboardCard.tsx @@ -0,0 +1,50 @@ +import { Link } from "@tanstack/react-router"; +import type { ReactNode } from "react"; + +interface DashboardCardProps { + to: string; + search?: Record; + title: string; + icon: ReactNode; + stats: Array<{ label: string; value: string }>; + emptyText?: string; +} + +export function DashboardCard({ + to, + search, + title, + icon, + stats, + emptyText, +}: DashboardCardProps) { + const allZero = stats.every( + (s) => s.value === "0" || s.value === "$0.00" || s.value === "0g", + ); + + return ( + +
+ {icon} +

{title}

+
+
+ {stats.map((stat) => ( +
+ {stat.label} + + {stat.value} + +
+ ))} +
+ {allZero && emptyText && ( +

{emptyText}

+ )} + + ); +} diff --git a/src/client/components/TotalsBar.tsx b/src/client/components/TotalsBar.tsx index 3277050..dab715b 100644 --- a/src/client/components/TotalsBar.tsx +++ b/src/client/components/TotalsBar.tsx @@ -1,36 +1,57 @@ +import { Link } from "@tanstack/react-router"; import { useTotals } from "../hooks/useTotals"; import { formatWeight, formatPrice } from "../lib/formatters"; -export function TotalsBar() { +interface TotalsBarProps { + title?: string; + stats?: Array<{ label: string; value: string }>; + linkTo?: string; +} + +export function TotalsBar({ title = "GearBox", stats, linkTo }: TotalsBarProps) { const { data } = useTotals(); - const global = data?.global; + // When no stats provided, use global totals (backward compatible) + const displayStats = stats ?? (data?.global + ? [ + { label: "items", value: String(data.global.itemCount) }, + { label: "total", value: formatWeight(data.global.totalWeight) }, + { label: "spent", value: formatPrice(data.global.totalCost) }, + ] + : [ + { label: "items", value: "0" }, + { label: "total", value: formatWeight(null) }, + { label: "spent", value: formatPrice(null) }, + ]); + + const titleElement = linkTo ? ( + + {title} + + ) : ( +

{title}

+ ); + + // If stats prop is explicitly an empty array, show title only (dashboard mode) + const showStats = stats === undefined || stats.length > 0; return (
-

GearBox

-
- - - {global?.itemCount ?? 0} - {" "} - items - - - - {formatWeight(global?.totalWeight ?? null)} - {" "} - total - - - - {formatPrice(global?.totalCost ?? null)} - {" "} - spent - -
+ {titleElement} + {showStats && ( +
+ {displayStats.map((stat) => ( + + + {stat.value} + {" "} + {stat.label} + + ))} +
+ )}
diff --git a/src/client/hooks/useItems.ts b/src/client/hooks/useItems.ts index eddc52c..26fc3af 100644 --- a/src/client/hooks/useItems.ts +++ b/src/client/hooks/useItems.ts @@ -52,6 +52,7 @@ export function useUpdateItem() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["totals"] }); + queryClient.invalidateQueries({ queryKey: ["setups"] }); }, }); } @@ -64,6 +65,7 @@ export function useDeleteItem() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["items"] }); queryClient.invalidateQueries({ queryKey: ["totals"] }); + queryClient.invalidateQueries({ queryKey: ["setups"] }); }, }); } diff --git a/src/client/hooks/useSetups.ts b/src/client/hooks/useSetups.ts new file mode 100644 index 0000000..cef4fa4 --- /dev/null +++ b/src/client/hooks/useSetups.ts @@ -0,0 +1,107 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { apiGet, apiPost, apiPut, apiDelete } from "../lib/api"; + +interface SetupListItem { + id: number; + name: string; + createdAt: string; + updatedAt: string; + itemCount: number; + totalWeight: number; + totalCost: number; +} + +interface SetupItemWithCategory { + id: number; + name: string; + weightGrams: number | null; + priceCents: number | null; + categoryId: number; + notes: string | null; + productUrl: string | null; + imageFilename: string | null; + createdAt: string; + updatedAt: string; + categoryName: string; + categoryEmoji: string; +} + +interface SetupWithItems { + id: number; + name: string; + createdAt: string; + updatedAt: string; + items: SetupItemWithCategory[]; +} + +export type { SetupListItem, SetupWithItems, SetupItemWithCategory }; + +export function useSetups() { + return useQuery({ + queryKey: ["setups"], + queryFn: () => apiGet("/api/setups"), + }); +} + +export function useSetup(setupId: number | null) { + return useQuery({ + queryKey: ["setups", setupId], + queryFn: () => apiGet(`/api/setups/${setupId}`), + enabled: setupId != null, + }); +} + +export function useCreateSetup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name: string }) => + apiPost("/api/setups", data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} + +export function useUpdateSetup(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: { name?: string }) => + apiPut(`/api/setups/${setupId}`, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} + +export function useDeleteSetup() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiDelete<{ success: boolean }>(`/api/setups/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} + +export function useSyncSetupItems(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemIds: number[]) => + apiPut<{ success: boolean }>(`/api/setups/${setupId}/items`, { itemIds }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} + +export function useRemoveSetupItem(setupId: number) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemId: number) => + apiDelete<{ success: boolean }>(`/api/setups/${setupId}/items/${itemId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["setups"] }); + }, + }); +} diff --git a/src/client/routeTree.gen.ts b/src/client/routeTree.gen.ts index d204c26..50cbc58 100644 --- a/src/client/routeTree.gen.ts +++ b/src/client/routeTree.gen.ts @@ -10,33 +10,73 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' +import { Route as ThreadsThreadIdRouteImport } from './routes/threads/$threadId' +import { Route as CollectionIndexRouteImport } from './routes/collection/index' +import { Route as SetupsIndexRouteImport } from './routes/setups/index' +import { Route as SetupsSetupIdRouteImport } from './routes/setups/$setupId' const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const ThreadsThreadIdRoute = ThreadsThreadIdRouteImport.update({ + id: '/threads/$threadId', + path: '/threads/$threadId', + getParentRoute: () => rootRouteImport, +} as any) +const CollectionIndexRoute = CollectionIndexRouteImport.update({ + id: '/collection/', + path: '/collection/', + getParentRoute: () => rootRouteImport, +} as any) +const SetupsIndexRoute = SetupsIndexRouteImport.update({ + id: '/setups/', + path: '/setups/', + getParentRoute: () => rootRouteImport, +} as any) +const SetupsSetupIdRoute = SetupsSetupIdRouteImport.update({ + id: '/setups/$setupId', + path: '/setups/$setupId', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/threads/$threadId': typeof ThreadsThreadIdRoute + '/collection': typeof CollectionIndexRoute + '/setups': typeof SetupsIndexRoute + '/setups/$setupId': typeof SetupsSetupIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute + '/threads/$threadId': typeof ThreadsThreadIdRoute + '/collection': typeof CollectionIndexRoute + '/setups': typeof SetupsIndexRoute + '/setups/$setupId': typeof SetupsSetupIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/threads/$threadId': typeof ThreadsThreadIdRoute + '/collection/': typeof CollectionIndexRoute + '/setups/': typeof SetupsIndexRoute + '/setups/$setupId': typeof SetupsSetupIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' + fullPaths: '/' | '/threads/$threadId' | '/collection' | '/setups' | '/setups/$setupId' fileRoutesByTo: FileRoutesByTo - to: '/' - id: '__root__' | '/' + to: '/' | '/threads/$threadId' | '/collection' | '/setups' | '/setups/$setupId' + id: '__root__' | '/' | '/threads/$threadId' | '/collection/' | '/setups/' | '/setups/$setupId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ThreadsThreadIdRoute: typeof ThreadsThreadIdRoute + CollectionIndexRoute: typeof CollectionIndexRoute + SetupsIndexRoute: typeof SetupsIndexRoute + SetupsSetupIdRoute: typeof SetupsSetupIdRoute } declare module '@tanstack/react-router' { @@ -48,11 +88,43 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/threads/$threadId': { + id: '/threads/$threadId' + path: '/threads/$threadId' + fullPath: '/threads/$threadId' + preLoaderRoute: typeof ThreadsThreadIdRouteImport + parentRoute: typeof rootRouteImport + } + '/collection/': { + id: '/collection/' + path: '/collection' + fullPath: '/collection' + preLoaderRoute: typeof CollectionIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/setups/': { + id: '/setups/' + path: '/setups' + fullPath: '/setups' + preLoaderRoute: typeof SetupsIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/setups/$setupId': { + id: '/setups/$setupId' + path: '/setups/$setupId' + fullPath: '/setups/$setupId' + preLoaderRoute: typeof SetupsSetupIdRouteImport + parentRoute: typeof rootRouteImport + } } } const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ThreadsThreadIdRoute: ThreadsThreadIdRoute, + CollectionIndexRoute: CollectionIndexRoute, + SetupsIndexRoute: SetupsIndexRoute, + SetupsSetupIdRoute: SetupsSetupIdRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/client/routes/__root.tsx b/src/client/routes/__root.tsx index 9ff45f3..bd63385 100644 --- a/src/client/routes/__root.tsx +++ b/src/client/routes/__root.tsx @@ -59,14 +59,38 @@ function RootLayout() { const isItemPanelOpen = panelMode !== "closed"; const isCandidatePanelOpen = candidatePanelMode !== "closed"; - // Detect if we're on a thread detail page to get the threadId for candidate forms + // Route matching for contextual behavior const matchRoute = useMatchRoute(); + const threadMatch = matchRoute({ to: "/threads/$threadId", fuzzy: true, }) as { threadId?: string } | false; const currentThreadId = threadMatch ? Number(threadMatch.threadId) : null; + const isDashboard = !!matchRoute({ to: "/" }); + const isCollection = !!matchRoute({ to: "/collection", fuzzy: true }); + const isSetupDetail = !!matchRoute({ to: "/setups/$setupId", fuzzy: true }); + + // Determine TotalsBar props based on current route + const totalsBarProps = isDashboard + ? { stats: [] as Array<{ label: string; value: string }> } // Title only, no stats, no link + : isSetupDetail + ? { linkTo: "/" } // Setup detail will render its own local bar; root bar just has link + : { linkTo: "/" }; // All other pages: default stats + link to dashboard + + // On dashboard, don't show the default global stats - pass empty stats + // On collection, let TotalsBar fetch its own global stats (default behavior) + const finalTotalsProps = isDashboard + ? { stats: [] as Array<{ label: string; value: string }> } + : isCollection + ? { linkTo: "/" } + : { linkTo: "/" }; + + // FAB visibility: only show on /collection route when gear tab is active + const collectionSearch = matchRoute({ to: "/collection" }) as { tab?: string } | false; + const showFab = isCollection && (!collectionSearch || (collectionSearch as Record).tab !== "planning"); + // Show a minimal loading state while checking onboarding status if (onboardingLoading) { return ( @@ -78,7 +102,7 @@ function RootLayout() { return (
- + {/* Item Slide-out Panel */} @@ -135,13 +159,13 @@ function RootLayout() { onClose={closeResolveDialog} onResolved={() => { closeResolveDialog(); - navigate({ to: "/", search: { tab: "planning" } }); + navigate({ to: "/collection", search: { tab: "planning" } }); }} /> )} - {/* Floating Add Button - only on gear tab */} - {!threadMatch && ( + {/* Floating Add Button - only on collection gear tab */} + {showFab && (
+ ); +} + +function CollectionView() { + 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 (!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 } + >(); + + 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, + }); + } + } + + 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(); + + function handleCreateThread(e: React.FormEvent) { + e.preventDefault(); + const name = newThreadName.trim(); + if (!name) return; + createThread.mutate( + { name }, + { onSuccess: () => setNewThreadName("") }, + ); + } + + 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" + /> + +
+ + {/* Show resolved toggle */} + + + {/* Thread list */} + {!threads || threads.length === 0 ? ( +
+
🔍
+

+ No planning threads yet +

+

+ Start one to research your next purchase. +

+
+ ) : ( +
+ {threads.map((thread) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/client/routes/index.tsx b/src/client/routes/index.tsx index e1be436..31cdb79 100644 --- a/src/client/routes/index.tsx +++ b/src/client/routes/index.tsx @@ -1,252 +1,55 @@ -import { useState } from "react"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { z } from "zod"; -import { useItems } from "../hooks/useItems"; +import { createFileRoute } from "@tanstack/react-router"; import { useTotals } from "../hooks/useTotals"; -import { useThreads, useCreateThread } from "../hooks/useThreads"; -import { CategoryHeader } from "../components/CategoryHeader"; -import { ItemCard } from "../components/ItemCard"; -import { ThreadTabs } from "../components/ThreadTabs"; -import { ThreadCard } from "../components/ThreadCard"; -import { useUIStore } from "../stores/uiStore"; - -const searchSchema = z.object({ - tab: z.enum(["gear", "planning"]).catch("gear"), -}); +import { useThreads } from "../hooks/useThreads"; +import { useSetups } from "../hooks/useSetups"; +import { DashboardCard } from "../components/DashboardCard"; +import { formatWeight, formatPrice } from "../lib/formatters"; export const Route = createFileRoute("/")({ - validateSearch: searchSchema, - component: HomePage, + component: DashboardPage, }); -function HomePage() { - const { tab } = Route.useSearch(); - const navigate = useNavigate(); - - function handleTabChange(newTab: "gear" | "planning") { - navigate({ to: "/", search: { tab: newTab } }); - } - - return ( -
- -
- {tab === "gear" ? : } -
-
- ); -} - -function CollectionView() { - const { data: items, isLoading: itemsLoading } = useItems(); +function DashboardPage() { const { data: totals } = useTotals(); - const openAddPanel = useUIStore((s) => s.openAddPanel); + const { data: threads } = useThreads(false); + const { data: setups } = useSetups(); - 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. -

- -
-
- ); - } - - // 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, - }); - } - } - - // 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, - }); - } - } + const global = totals?.global; + const activeThreadCount = threads?.length ?? 0; + const setupCount = setups?.length ?? 0; 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(); - - function handleCreateThread(e: React.FormEvent) { - e.preventDefault(); - const name = newThreadName.trim(); - if (!name) return; - createThread.mutate( - { name }, - { onSuccess: () => setNewThreadName("") }, - ); - } - - 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" - /> - -
- - {/* Show resolved toggle */} - - - {/* Thread list */} - {!threads || threads.length === 0 ? ( -
-
🔍
-

- No planning threads yet -

-

- Start one to research your next purchase. -

-
- ) : ( -
- {threads.map((thread) => ( - - ))} -
- )}
); } diff --git a/src/client/routes/setups/$setupId.tsx b/src/client/routes/setups/$setupId.tsx new file mode 100644 index 0000000..7f8463c --- /dev/null +++ b/src/client/routes/setups/$setupId.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/setups/$setupId")({ + component: SetupDetailPlaceholder, +}); + +function SetupDetailPlaceholder() { + return
Setup detail loading...
; +} diff --git a/src/client/routes/setups/index.tsx b/src/client/routes/setups/index.tsx new file mode 100644 index 0000000..189d849 --- /dev/null +++ b/src/client/routes/setups/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/setups/")({ + component: SetupsPlaceholder, +}); + +function SetupsPlaceholder() { + return
Setups loading...
; +} diff --git a/src/client/stores/uiStore.ts b/src/client/stores/uiStore.ts index 2b82fdd..ec52332 100644 --- a/src/client/stores/uiStore.ts +++ b/src/client/stores/uiStore.ts @@ -29,6 +29,15 @@ interface UIState { openResolveDialog: (threadId: number, candidateId: number) => void; closeResolveDialog: () => void; + + // Setup-related UI state + itemPickerOpen: boolean; + openItemPicker: () => void; + closeItemPicker: () => void; + + confirmDeleteSetupId: number | null; + openConfirmDeleteSetup: (id: number) => void; + closeConfirmDeleteSetup: () => void; } export const useUIStore = create((set) => ({ @@ -67,4 +76,13 @@ export const useUIStore = create((set) => ({ set({ resolveThreadId: threadId, resolveCandidateId: candidateId }), closeResolveDialog: () => set({ resolveThreadId: null, resolveCandidateId: null }), + + // Setup-related UI state + itemPickerOpen: false, + openItemPicker: () => set({ itemPickerOpen: true }), + closeItemPicker: () => set({ itemPickerOpen: false }), + + confirmDeleteSetupId: null, + openConfirmDeleteSetup: (id) => set({ confirmDeleteSetupId: id }), + closeConfirmDeleteSetup: () => set({ confirmDeleteSetupId: null }), }));