v1.4 Collection Tools #9
@@ -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