feat(12-01): add ComparisonTable component

- Side-by-side tabular comparison with all 10 attribute rows (Image, Name, Rank, Weight, Price, Status, Link, Notes, Pros, Cons)
- useMemo delta computation: blue-50 highlight on lightest weight, green-50 on cheapest price
- Gray delta string (+Xg, +$X.XX) shown below non-best cells
- Sticky left column with bg-white to prevent bleed-through on horizontal scroll
- Amber tint + trophy icon on winner column for resolved threads
- Em dash for missing weight/price data (never zero)
- Declarative ATTRIBUTE_ROWS array pattern for clean, maintainable row rendering
This commit is contained in:
2026-03-17 15:29:30 +01:00
parent b090da05fa
commit e442b33a59

View File

@@ -0,0 +1,336 @@
import { useMemo } from "react";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
import { useUIStore } from "../stores/uiStore";
import { RankBadge } from "./CandidateListItem";
interface CandidateWithCategory {
id: number;
threadId: number;
name: string;
weightGrams: number | null;
priceCents: number | null;
categoryId: number;
notes: string | null;
productUrl: string | null;
imageFilename: string | null;
status: "researching" | "ordered" | "arrived";
pros: string | null;
cons: string | null;
createdAt: string;
updatedAt: string;
categoryName: string;
categoryIcon: string;
}
interface ComparisonTableProps {
candidates: CandidateWithCategory[];
resolvedCandidateId: number | null;
}
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
researching: "Researching",
ordered: "Ordered",
arrived: "Arrived",
};
export function ComparisonTable({
candidates,
resolvedCandidateId,
}: ComparisonTableProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const openExternalLink = useUIStore((s) => s.openExternalLink);
const { bestWeightId, bestPriceId, weightDeltas, priceDeltas } =
useMemo(() => {
// Weight deltas
const withWeight = candidates.filter((c) => c.weightGrams != null);
let bestWeightId: number | null = null;
const weightDeltas: Record<number, string | null> = {};
if (withWeight.length > 0) {
const minWeight = Math.min(
...withWeight.map((c) => c.weightGrams as number),
);
bestWeightId =
withWeight.find((c) => c.weightGrams === minWeight)?.id ?? null;
for (const c of candidates) {
if (c.weightGrams == null) {
weightDeltas[c.id] = null;
} else {
const delta = c.weightGrams - minWeight;
weightDeltas[c.id] =
delta === 0 ? null : `+${formatWeight(delta, unit)}`;
}
}
} else {
for (const c of candidates) {
weightDeltas[c.id] = null;
}
}
// Price deltas
const withPrice = candidates.filter((c) => c.priceCents != null);
let bestPriceId: number | null = null;
const priceDeltas: Record<number, string | null> = {};
if (withPrice.length > 0) {
const minPrice = Math.min(
...withPrice.map((c) => c.priceCents as number),
);
bestPriceId =
withPrice.find((c) => c.priceCents === minPrice)?.id ?? null;
for (const c of candidates) {
if (c.priceCents == null) {
priceDeltas[c.id] = null;
} else {
const delta = c.priceCents - minPrice;
priceDeltas[c.id] =
delta === 0 ? null : `+${formatPrice(delta, currency)}`;
}
}
} else {
for (const c of candidates) {
priceDeltas[c.id] = null;
}
}
return { bestWeightId, bestPriceId, weightDeltas, priceDeltas };
}, [candidates, unit, currency]);
const ATTRIBUTE_ROWS: Array<{
key: string;
label: string;
render: (
candidate: CandidateWithCategory,
index: number,
) => React.ReactNode;
cellClass?: (candidate: CandidateWithCategory) => string;
}> = [
{
key: "image",
label: "Image",
render: (c) => (
<div className="w-12 h-12 rounded-lg overflow-hidden bg-gray-50 flex items-center justify-center">
{c.imageFilename ? (
<img
src={`/uploads/${c.imageFilename}`}
alt={c.name}
className="w-full h-full object-cover"
/>
) : (
<LucideIcon
name={c.categoryIcon}
size={20}
className="text-gray-400"
/>
)}
</div>
),
},
{
key: "name",
label: "Name",
render: (c) => (
<span className="text-sm font-medium text-gray-900">{c.name}</span>
),
},
{
key: "rank",
label: "Rank",
render: (_c, index) => <RankBadge rank={index + 1} />,
},
{
key: "weight",
label: "Weight",
render: (c) => {
const isBest = c.id === bestWeightId;
const delta = weightDeltas[c.id];
if (c.weightGrams == null) {
return <span className="text-gray-300"></span>;
}
return (
<div>
<span className="font-medium text-gray-900">
{formatWeight(c.weightGrams, unit)}
</span>
{!isBest && delta && (
<div className="text-xs text-gray-400">{delta}</div>
)}
</div>
);
},
cellClass: (c) => {
if (c.id === bestWeightId) return "bg-blue-50";
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
return "";
},
},
{
key: "price",
label: "Price",
render: (c) => {
const isBest = c.id === bestPriceId;
const delta = priceDeltas[c.id];
if (c.priceCents == null) {
return <span className="text-gray-300"></span>;
}
return (
<div>
<span className="font-medium text-gray-900">
{formatPrice(c.priceCents, currency)}
</span>
{!isBest && delta && (
<div className="text-xs text-gray-400">{delta}</div>
)}
</div>
);
},
cellClass: (c) => {
if (c.id === bestPriceId) return "bg-green-50";
if (c.id === resolvedCandidateId) return "bg-amber-50/50";
return "";
},
},
{
key: "status",
label: "Status",
render: (c) => (
<span className="text-xs text-gray-600">{STATUS_LABELS[c.status]}</span>
),
},
{
key: "link",
label: "Link",
render: (c) =>
c.productUrl ? (
<button
type="button"
onClick={() => openExternalLink(c.productUrl as string)}
className="text-xs text-blue-500 hover:underline"
>
View
</button>
) : (
<span className="text-gray-300"></span>
),
},
{
key: "notes",
label: "Notes",
render: (c) =>
c.notes ? (
<p className="text-xs text-gray-700 whitespace-pre-line">{c.notes}</p>
) : (
<span className="text-gray-300"></span>
),
},
{
key: "pros",
label: "Pros",
render: (c) => {
if (!c.pros) return <span className="text-gray-300"></span>;
const items = c.pros.split("\n").filter((s) => s.trim() !== "");
if (items.length === 0) return <span className="text-gray-300"></span>;
return (
<ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700">
{item}
</li>
))}
</ul>
);
},
},
{
key: "cons",
label: "Cons",
render: (c) => {
if (!c.cons) return <span className="text-gray-300"></span>;
const items = c.cons.split("\n").filter((s) => s.trim() !== "");
if (items.length === 0) return <span className="text-gray-300"></span>;
return (
<ul className="list-disc list-inside space-y-0.5">
{items.map((item, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items
<li key={i} className="text-xs text-gray-700">
{item}
</li>
))}
</ul>
);
},
},
];
const tableMinWidth = Math.max(400, candidates.length * 180);
return (
<div className="overflow-x-auto rounded-xl border border-gray-100">
<table
className="border-collapse text-sm w-full"
style={{ minWidth: `${tableMinWidth}px` }}
>
<thead>
<tr className="border-b border-gray-100">
{/* Sticky empty corner cell */}
<th className="sticky left-0 z-10 bg-white px-4 py-3 text-left text-xs font-medium text-gray-700 w-28" />
{candidates.map((candidate) => {
const isWinner = candidate.id === resolvedCandidateId;
return (
<th
key={candidate.id}
className={`px-4 py-3 text-left text-xs font-medium min-w-[160px] ${
isWinner ? "bg-amber-50 text-amber-800" : "text-gray-700"
}`}
>
<div className="flex items-center gap-1">
{isWinner && (
<LucideIcon
name="trophy"
size={12}
className="text-amber-600"
/>
)}
{candidate.name}
</div>
</th>
);
})}
</tr>
</thead>
<tbody>
{ATTRIBUTE_ROWS.map((row) => (
<tr key={row.key} className="border-b border-gray-50">
{/* Sticky label cell */}
<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">
{row.label}
</td>
{candidates.map((candidate, index) => {
const isWinner = candidate.id === resolvedCandidateId;
const extraClass = row.cellClass
? row.cellClass(candidate)
: isWinner
? "bg-amber-50/50"
: "";
return (
<td
key={candidate.id}
className={`px-4 py-3 min-w-[160px] ${extraClass}`}
>
{row.render(candidate, index)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}