From 8c1fe47a99d79c53a5e5417daa0abc1f0e287247 Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 3 Apr 2026 18:11:57 +0200 Subject: [PATCH] feat: add setup impact preview UI with delta badges across all views Adds SetupImpactSelector dropdown and ImpactDeltaBadge inline badge, wired into the thread detail page. Delta badges appear on CandidateListItem, CandidateCard, and ComparisonTable (Weight Impact / Price Impact rows) whenever a setup is selected for comparison. Co-Authored-By: Claude Sonnet 4.6 --- src/client/components/CandidateCard.tsx | 6 +++ src/client/components/CandidateListItem.tsx | 6 +++ src/client/components/ComparisonTable.tsx | 52 +++++++++++++++++++ src/client/components/ImpactDeltaBadge.tsx | 39 ++++++++++++++ src/client/components/SetupImpactSelector.tsx | 34 ++++++++++++ src/client/routes/threads/$threadId.tsx | 17 +++++- 6 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 src/client/components/ImpactDeltaBadge.tsx create mode 100644 src/client/components/SetupImpactSelector.tsx 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]} /> ))}