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:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
39
src/client/components/ImpactDeltaBadge.tsx
Normal file
39
src/client/components/ImpactDeltaBadge.tsx
Normal 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>;
|
||||
}
|
||||
34
src/client/components/SetupImpactSelector.tsx
Normal file
34
src/client/components/SetupImpactSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user