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 <noreply@anthropic.com>
This commit is contained in:
2026-04-03 18:11:57 +02:00
parent b9a06dd244
commit 8c1fe47a99
6 changed files with 153 additions and 1 deletions

View File

@@ -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)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{price(priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon
name={categoryIcon}

View File

@@ -1,7 +1,9 @@
import { Reorder, useDragControls } from "framer-motion";
import { useFormatters } from "../hooks/useFormatters";
import type { CandidateDelta } from "../hooks/useImpactDeltas";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
import { StatusBadge } from "./StatusBadge";
interface CandidateWithCategory {
@@ -28,6 +30,7 @@ interface CandidateListItemProps {
rank: number;
isActive: boolean;
onStatusChange: (status: "researching" | "ordered" | "arrived") => 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)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
{candidate.priceCents != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{price(candidate.priceCents)}
</span>
)}
<ImpactDeltaBadge delta={delta} type="price" formatFn={price} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
<LucideIcon
name={candidate.categoryIcon}

View File

@@ -1,8 +1,10 @@
import { useMemo } from "react";
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";
interface CandidateWithCategory {
id: number;
@@ -26,6 +28,7 @@ interface CandidateWithCategory {
interface ComparisonTableProps {
candidates: CandidateWithCategory[];
resolvedCandidateId: number | null;
deltas?: Record<number, CandidateDelta>;
}
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({
})}
</tr>
))}
{showImpact && (
<>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Weight Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="weight"
formatFn={weight}
/>
</td>
);
})}
</tr>
<tr className="border-b border-gray-50">
<td className="sticky left-0 z-10 bg-white px-4 py-3 text-xs font-medium text-gray-500 uppercase tracking-wide w-28">
Price Impact
</td>
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${isWinner ? "bg-amber-50/50" : ""}`}
>
<ImpactDeltaBadge
delta={deltas?.[candidate.id]}
type="price"
formatFn={price}
/>
</td>
);
})}
</tr>
</>
)}
</tbody>
</table>
</div>

View File

@@ -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 <span className="text-xs text-gray-400"></span>;
}
if (value === 0) {
return <span className="text-xs text-gray-400">±0</span>;
}
if (value > 0) {
return (
<span className="text-xs text-green-600">
+{formatFn(value)}
{delta.mode === "add" && (
<span className="ml-0.5 text-green-500">(add)</span>
)}
</span>
);
}
// value < 0
return <span className="text-xs text-red-500">{formatFn(value)}</span>;
}

View File

@@ -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 (
<select
value={selectedSetupId ?? ""}
onChange={(e) =>
setSelectedSetupId(e.target.value ? Number(e.target.value) : null)
}
className="border border-gray-200 rounded-lg text-sm px-3 py-1.5 text-gray-700 bg-white focus:outline-none focus:ring-2 focus:ring-gray-300"
>
<option value="">Compare with setup...</option>
{setups.map((setup) => (
<option key={setup.id} value={setup.id}>
{setup.name}
</option>
))}
</select>
);
}

View File

@@ -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<typeof thread extends { candidates: infer C } ? C : never | null>(
@@ -120,7 +130,7 @@ function ThreadDetailPage() {
)}
{/* Toolbar: Add candidate + view toggle */}
<div className="mb-6 flex items-center gap-3">
<div className="mb-6 flex items-center gap-3 flex-wrap">
{isActive && candidateViewMode !== "compare" && (
<button
type="button"
@@ -185,6 +195,7 @@ function ThreadDetailPage() {
)}
</div>
)}
<SetupImpactSelector threadStatus={thread.status} />
</div>
{/* Candidates */}
@@ -208,6 +219,7 @@ function ThreadDetailPage() {
<ComparisonTable
candidates={displayItems}
resolvedCandidateId={thread.resolvedCandidateId}
deltas={deltas}
/>
) : candidateViewMode === "list" ? (
isActive ? (
@@ -230,6 +242,7 @@ function ThreadDetailPage() {
status: newStatus,
})
}
delta={deltas[candidate.id]}
/>
))}
</Reorder.Group>
@@ -247,6 +260,7 @@ function ThreadDetailPage() {
status: newStatus,
})
}
delta={deltas[candidate.id]}
/>
))}
</div>
@@ -276,6 +290,7 @@ function ThreadDetailPage() {
pros={candidate.pros}
cons={candidate.cons}
rank={index + 1}
delta={deltas[candidate.id]}
/>
))}
</div>