v1.4 Collection Tools #9
@@ -1,7 +1,9 @@
|
|||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateCardProps {
|
interface CandidateCardProps {
|
||||||
@@ -20,6 +22,7 @@ interface CandidateCardProps {
|
|||||||
pros?: string | null;
|
pros?: string | null;
|
||||||
cons?: string | null;
|
cons?: string | null;
|
||||||
rank?: number;
|
rank?: number;
|
||||||
|
delta?: CandidateDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CandidateCard({
|
export function CandidateCard({
|
||||||
@@ -38,6 +41,7 @@ export function CandidateCard({
|
|||||||
pros,
|
pros,
|
||||||
cons,
|
cons,
|
||||||
rank,
|
rank,
|
||||||
|
delta,
|
||||||
}: CandidateCardProps) {
|
}: CandidateCardProps) {
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
|
||||||
@@ -165,11 +169,13 @@ export function CandidateCard({
|
|||||||
{weight(weightGrams)}
|
{weight(weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{priceCents != null && (
|
{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">
|
<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)}
|
{price(priceCents)}
|
||||||
</span>
|
</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">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={categoryIcon}
|
name={categoryIcon}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Reorder, useDragControls } from "framer-motion";
|
import { Reorder, useDragControls } from "framer-motion";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
import { StatusBadge } from "./StatusBadge";
|
import { StatusBadge } from "./StatusBadge";
|
||||||
|
|
||||||
interface CandidateWithCategory {
|
interface CandidateWithCategory {
|
||||||
@@ -28,6 +30,7 @@ interface CandidateListItemProps {
|
|||||||
rank: number;
|
rank: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
onStatusChange: (status: "researching" | "ordered" | "arrived") => void;
|
||||||
|
delta?: CandidateDelta;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
const RANK_COLORS = ["#D4AF37", "#C0C0C0", "#CD7F32"]; // gold, silver, bronze
|
||||||
@@ -49,6 +52,7 @@ export function CandidateListItem({
|
|||||||
rank,
|
rank,
|
||||||
isActive,
|
isActive,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
delta,
|
||||||
}: CandidateListItemProps) {
|
}: CandidateListItemProps) {
|
||||||
const controls = useDragControls();
|
const controls = useDragControls();
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
@@ -111,11 +115,13 @@ export function CandidateListItem({
|
|||||||
{weight(candidate.weightGrams)}
|
{weight(candidate.weightGrams)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
<ImpactDeltaBadge delta={delta} type="weight" formatFn={weight} />
|
||||||
{candidate.priceCents != null && (
|
{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">
|
<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)}
|
{price(candidate.priceCents)}
|
||||||
</span>
|
</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">
|
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
|
||||||
<LucideIcon
|
<LucideIcon
|
||||||
name={candidate.categoryIcon}
|
name={candidate.categoryIcon}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useFormatters } from "../hooks/useFormatters";
|
import { useFormatters } from "../hooks/useFormatters";
|
||||||
|
import type { CandidateDelta } from "../hooks/useImpactDeltas";
|
||||||
import { LucideIcon } from "../lib/iconData";
|
import { LucideIcon } from "../lib/iconData";
|
||||||
import { useUIStore } from "../stores/uiStore";
|
import { useUIStore } from "../stores/uiStore";
|
||||||
import { RankBadge } from "./CandidateListItem";
|
import { RankBadge } from "./CandidateListItem";
|
||||||
|
import { ImpactDeltaBadge } from "./ImpactDeltaBadge";
|
||||||
|
|
||||||
interface CandidateWithCategory {
|
interface CandidateWithCategory {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,6 +28,7 @@ interface CandidateWithCategory {
|
|||||||
interface ComparisonTableProps {
|
interface ComparisonTableProps {
|
||||||
candidates: CandidateWithCategory[];
|
candidates: CandidateWithCategory[];
|
||||||
resolvedCandidateId: number | null;
|
resolvedCandidateId: number | null;
|
||||||
|
deltas?: Record<number, CandidateDelta>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
||||||
@@ -37,6 +40,7 @@ const STATUS_LABELS: Record<"researching" | "ordered" | "arrived", string> = {
|
|||||||
export function ComparisonTable({
|
export function ComparisonTable({
|
||||||
candidates,
|
candidates,
|
||||||
resolvedCandidateId,
|
resolvedCandidateId,
|
||||||
|
deltas,
|
||||||
}: ComparisonTableProps) {
|
}: ComparisonTableProps) {
|
||||||
const { weight, price } = useFormatters();
|
const { weight, price } = useFormatters();
|
||||||
const openExternalLink = useUIStore((s) => s.openExternalLink);
|
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);
|
const tableMinWidth = Math.max(400, candidates.length * 180);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -324,6 +332,50 @@ export function ComparisonTable({
|
|||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 { CandidateCard } from "../../components/CandidateCard";
|
||||||
import { CandidateListItem } from "../../components/CandidateListItem";
|
import { CandidateListItem } from "../../components/CandidateListItem";
|
||||||
import { ComparisonTable } from "../../components/ComparisonTable";
|
import { ComparisonTable } from "../../components/ComparisonTable";
|
||||||
|
import { SetupImpactSelector } from "../../components/SetupImpactSelector";
|
||||||
import {
|
import {
|
||||||
useReorderCandidates,
|
useReorderCandidates,
|
||||||
useUpdateCandidate,
|
useUpdateCandidate,
|
||||||
} from "../../hooks/useCandidates";
|
} from "../../hooks/useCandidates";
|
||||||
|
import { useImpactDeltas } from "../../hooks/useImpactDeltas";
|
||||||
|
import { useSetup } from "../../hooks/useSetups";
|
||||||
import { useThread } from "../../hooks/useThreads";
|
import { useThread } from "../../hooks/useThreads";
|
||||||
import { LucideIcon } from "../../lib/iconData";
|
import { LucideIcon } from "../../lib/iconData";
|
||||||
import { useUIStore } from "../../stores/uiStore";
|
import { useUIStore } from "../../stores/uiStore";
|
||||||
@@ -23,8 +26,15 @@ function ThreadDetailPage() {
|
|||||||
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
const openCandidateAddPanel = useUIStore((s) => s.openCandidateAddPanel);
|
||||||
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
const candidateViewMode = useUIStore((s) => s.candidateViewMode);
|
||||||
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
const setCandidateViewMode = useUIStore((s) => s.setCandidateViewMode);
|
||||||
|
const selectedSetupId = useUIStore((s) => s.selectedSetupId);
|
||||||
const updateCandidate = useUpdateCandidate(threadId);
|
const updateCandidate = useUpdateCandidate(threadId);
|
||||||
const reorderMutation = useReorderCandidates(threadId);
|
const reorderMutation = useReorderCandidates(threadId);
|
||||||
|
const { data: setupData } = useSetup(selectedSetupId);
|
||||||
|
const { deltas } = useImpactDeltas(
|
||||||
|
thread?.candidates ?? [],
|
||||||
|
setupData?.items,
|
||||||
|
thread?.categoryId ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
const [tempItems, setTempItems] =
|
const [tempItems, setTempItems] =
|
||||||
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
useState<typeof thread extends { candidates: infer C } ? C : never | null>(
|
||||||
@@ -120,7 +130,7 @@ function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Toolbar: Add candidate + view toggle */}
|
{/* 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" && (
|
{isActive && candidateViewMode !== "compare" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -185,6 +195,7 @@ function ThreadDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SetupImpactSelector threadStatus={thread.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Candidates */}
|
{/* Candidates */}
|
||||||
@@ -208,6 +219,7 @@ function ThreadDetailPage() {
|
|||||||
<ComparisonTable
|
<ComparisonTable
|
||||||
candidates={displayItems}
|
candidates={displayItems}
|
||||||
resolvedCandidateId={thread.resolvedCandidateId}
|
resolvedCandidateId={thread.resolvedCandidateId}
|
||||||
|
deltas={deltas}
|
||||||
/>
|
/>
|
||||||
) : candidateViewMode === "list" ? (
|
) : candidateViewMode === "list" ? (
|
||||||
isActive ? (
|
isActive ? (
|
||||||
@@ -230,6 +242,7 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
@@ -247,6 +260,7 @@ function ThreadDetailPage() {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +290,7 @@ function ThreadDetailPage() {
|
|||||||
pros={candidate.pros}
|
pros={candidate.pros}
|
||||||
cons={candidate.cons}
|
cons={candidate.cons}
|
||||||
rank={index + 1}
|
rank={index + 1}
|
||||||
|
delta={deltas[candidate.id]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user