diff --git a/src/client/components/ComparisonTable.tsx b/src/client/components/ComparisonTable.tsx new file mode 100644 index 0000000..c1a716a --- /dev/null +++ b/src/client/components/ComparisonTable.tsx @@ -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 = {}; + + 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 = {}; + + 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) => ( +
+ {c.imageFilename ? ( + {c.name} + ) : ( + + )} +
+ ), + }, + { + key: "name", + label: "Name", + render: (c) => ( + {c.name} + ), + }, + { + key: "rank", + label: "Rank", + render: (_c, index) => , + }, + { + key: "weight", + label: "Weight", + render: (c) => { + const isBest = c.id === bestWeightId; + const delta = weightDeltas[c.id]; + if (c.weightGrams == null) { + return ; + } + return ( +
+ + {formatWeight(c.weightGrams, unit)} + + {!isBest && delta && ( +
{delta}
+ )} +
+ ); + }, + 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 ; + } + return ( +
+ + {formatPrice(c.priceCents, currency)} + + {!isBest && delta && ( +
{delta}
+ )} +
+ ); + }, + 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) => ( + {STATUS_LABELS[c.status]} + ), + }, + { + key: "link", + label: "Link", + render: (c) => + c.productUrl ? ( + + ) : ( + + ), + }, + { + key: "notes", + label: "Notes", + render: (c) => + c.notes ? ( +

{c.notes}

+ ) : ( + + ), + }, + { + key: "pros", + label: "Pros", + render: (c) => { + if (!c.pros) return ; + const items = c.pros.split("\n").filter((s) => s.trim() !== ""); + if (items.length === 0) return ; + return ( +
    + {items.map((item, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items +
  • + {item} +
  • + ))} +
+ ); + }, + }, + { + key: "cons", + label: "Cons", + render: (c) => { + if (!c.cons) return ; + const items = c.cons.split("\n").filter((s) => s.trim() !== ""); + if (items.length === 0) return ; + return ( +
    + {items.map((item, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: stable list of static items +
  • + {item} +
  • + ))} +
+ ); + }, + }, + ]; + + const tableMinWidth = Math.max(400, candidates.length * 180); + + return ( +
+ + + + {/* Sticky empty corner cell */} + + ); + })} + + + + {ATTRIBUTE_ROWS.map((row) => ( + + {/* Sticky label cell */} + + {candidates.map((candidate, index) => { + const isWinner = candidate.id === resolvedCandidateId; + const extraClass = row.cellClass + ? row.cellClass(candidate) + : isWinner + ? "bg-amber-50/50" + : ""; + return ( + + ); + })} + + ))} + +
+ {candidates.map((candidate) => { + const isWinner = candidate.id === resolvedCandidateId; + return ( + +
+ {isWinner && ( + + )} + {candidate.name} +
+
+ {row.label} + + {row.render(candidate, index)} +
+
+ ); +}