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:
336
src/client/components/ComparisonTable.tsx
Normal file
336
src/client/components/ComparisonTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user