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