diff --git a/src/client/components/CandidateCard.tsx b/src/client/components/CandidateCard.tsx index 7821431..34da898 100644 --- a/src/client/components/CandidateCard.tsx +++ b/src/client/components/CandidateCard.tsx @@ -1,7 +1,9 @@ import { useFormatters } from "../hooks/useFormatters"; +import type { CandidateDelta } from "../hooks/useImpactDeltas"; import { LucideIcon } from "../lib/iconData"; import { useUIStore } from "../stores/uiStore"; import { RankBadge } from "./CandidateListItem"; +import { ImpactDeltaBadge } from "./ImpactDeltaBadge"; import { StatusBadge } from "./StatusBadge"; interface CandidateCardProps { @@ -20,6 +22,7 @@ interface CandidateCardProps { pros?: string | null; cons?: string | null; rank?: number; + delta?: CandidateDelta; } export function CandidateCard({ @@ -38,6 +41,7 @@ export function CandidateCard({ pros, cons, rank, + delta, }: CandidateCardProps) { const { weight, price } = useFormatters(); const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel); @@ -165,11 +169,13 @@ export function CandidateCard({ {weight(weightGrams)} )} + {priceCents != null && ( {price(priceCents)} )} + void; + delta?: CandidateDelta; } const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze @@ -49,6 +52,7 @@ export function CandidateListItem({ rank, isActive, onStatusChange, + delta, }: CandidateListItemProps) { const controls = useDragControls(); const { weight, price } = useFormatters(); @@ -111,11 +115,13 @@ export function CandidateListItem({ {weight(candidate.weightGrams)} )} + {candidate.priceCents != null && ( {price(candidate.priceCents)} )} + ; } const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = { @@ -37,6 +40,7 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = { export function ComparisonTable({ candidates, resolvedCandidateId, + deltas, }: ComparisonTableProps) { const { weight, price } = useFormatters(); const openExternalLink = useUIStore((s) => s.openExternalLink); @@ -263,6 +267,10 @@ export function ComparisonTable({ }, ]; + // Determine if impact rows should be shown + const firstDelta = deltas ? Object.values(deltas)[0] : undefined; + const showImpact = !!deltas && !!firstDelta && firstDelta.mode !== "none"; + const tableMinWidth = Math.max(400, candidates.length * 180); return ( @@ -324,6 +332,50 @@ export function ComparisonTable({ })} ))} + {showImpact && ( + <> + + + Weight Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + + + Price Impact + + {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + + + + ); + })} + + + )} diff --git a/src/client/components/ImpactDeltaBadge.tsx b/src/client/components/ImpactDeltaBadge.tsx new file mode 100644 index 0000000..5fec0a7 --- /dev/null +++ b/src/client/components/ImpactDeltaBadge.tsx @@ -0,0 +1,39 @@ +import type { CandidateDelta } from "../hooks/useImpactDeltas"; + +interface ImpactDeltaBadgeProps { + delta: CandidateDelta | undefined; + type: "weight" | "price"; + formatFn: (value: number) => string; +} + +export function ImpactDeltaBadge({ + delta, + type, + formatFn, +}: ImpactDeltaBadgeProps) { + if (!delta || delta.mode === "none") return null; + + const value = type === "weight" ? delta.weightDelta : delta.priceDelta; + + if (value === null) { + return ; + } + + if (value === 0) { + return ±0; + } + + if (value > 0) { + return ( + + +{formatFn(value)} + {delta.mode === "add" && ( + (add) + )} + + ); + } + + // value < 0 + return {formatFn(value)}; +} diff --git a/src/client/components/SetupImpactSelector.tsx b/src/client/components/SetupImpactSelector.tsx new file mode 100644 index 0000000..b282ea2 --- /dev/null +++ b/src/client/components/SetupImpactSelector.tsx @@ -0,0 +1,34 @@ +import { useSetups } from "../hooks/useSetups"; +import { useUIStore } from "../stores/uiStore"; + +interface SetupImpactSelectorProps { + threadStatus: "active" | "resolved"; +} + +export function SetupImpactSelector({ + threadStatus, +}: SetupImpactSelectorProps) { + const { data: setups } = useSetups(); + const selectedSetupId = useUIStore((s) => s.selectedSetupId); + const setSelectedSetupId = useUIStore((s) => s.setSelectedSetupId); + + if (threadStatus !== "active") return null; + if (!setups || setups.length === 0) return null; + + return ( + + ); +} diff --git a/src/client/routes/threads/$threadId.tsx b/src/client/routes/threads/$threadId.tsx index 4ba63b2..52b5ebf 100644 --- a/src/client/routes/threads/$threadId.tsx +++ b/src/client/routes/threads/$threadId.tsx @@ -4,10 +4,13 @@ import { useEffect, useState } from "react"; import { CandidateCard } from "../../components/CandidateCard"; import { CandidateListItem } from "../../components/CandidateListItem"; import { ComparisonTable } from "../../components/ComparisonTable"; +import { SetupImpactSelector } from "../../components/SetupImpactSelector"; import { useReorderCandidates, useUpdateCandidate, } from "../../hooks/useCandidates"; +import { useImpactDeltas } from "../../hooks/useImpactDeltas"; +import { useSetup } from "../../hooks/useSetups"; import { useThread } from "../../hooks/useThreads"; import { LucideIcon } from "../../lib/iconData"; import { useUIStore } from "../../stores/uiStore"; @@ -23,8 +26,15 @@ function ThreadDetailPage() { const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel); const candidateViewMode = useUIStore((s) => s.candidateViewMode); const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode); + const selectedSetupId = useUIStore((s) => s.selectedSetupId); const updateCandidate = useUpdateCandidate(threadId); const reorderMutation = useReorderCandidates(threadId); + const { data: setupData } = useSetup(selectedSetupId); + const { deltas } = useImpactDeltas( + thread?.candidates ?? [], + setupData?.items, + thread?.categoryId ?? 0, + ); const [tempItems, setTempItems] = useState( @@ -120,7 +130,7 @@ function ThreadDetailPage() { )} {/* Toolbar: Add candidate + view toggle */} -
+
{isActive && candidateViewMode !== "compare" && (
{/* Candidates */} @@ -208,6 +219,7 @@ function ThreadDetailPage() { ) : candidateViewMode === "list" ? ( isActive ? ( @@ -230,6 +242,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))} @@ -247,6 +260,7 @@ function ThreadDetailPage() { status: newStatus, }) } + delta={deltas[candidate.id]} /> ))}
@@ -276,6 +290,7 @@ function ThreadDetailPage() { pros={candidate.pros} cons={candidate.cons} rank={index + 1} + delta={deltas[candidate.id]} /> ))}