v1.4 Collection Tools #9

Merged
makiolaj merged 17 commits from feature/v1.4-collection-tools into Develop 2026-04-03 18:05:24 +00:00
6 changed files with 153 additions and 1 deletions
Showing only changes of commit 8c1fe47a99 - Show all commits

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>

View 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>;
}

View 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>
);
}

View File

@@ -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>