feat: redesign weight summary legend and add currency selector

Redesign WeightSummaryCard stats from a disconnected 4-column grid to a
compact legend-style list with color dots, percentages, and a divider
before the total row. Switch chart and legend colors to a neutral gray
palette.

Add a currency selector to settings (USD, EUR, GBP, JPY, CAD, AUD) that
changes the displayed symbol across the app. This is visual only — no
value conversion is performed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-16 20:33:07 +01:00
parent 4cb356d6b0
commit 9647f5759d
14 changed files with 470 additions and 145 deletions

View File

@@ -1,3 +1,4 @@
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -34,6 +35,7 @@ export function CandidateCard({
onStatusChange,
}: CandidateCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const openCandidateEditPanel = useUIStore((s) => s.openCandidateEditPanel);
const openConfirmDeleteCandidate = useUIStore(
(s) => s.openConfirmDeleteCandidate,
@@ -42,14 +44,74 @@ export function CandidateCard({
const openExternalLink = useUIStore((s) => s.openExternalLink);
return (
<div className="relative bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group">
<button
type="button"
onClick={() => openCandidateEditPanel(id)}
className="relative w-full text-left bg-white rounded-xl border border-gray-100 hover:border-gray-200 hover:shadow-sm transition-all overflow-hidden group"
>
{/* Hover-reveal action buttons */}
{isActive && (
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openResolveDialog(threadId, id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openResolveDialog(threadId, id);
}
}}
className="absolute top-2 left-2 z-10 px-2 py-0.5 flex items-center gap-1 rounded-full text-xs font-medium bg-amber-100/90 text-amber-700 hover:bg-amber-200 opacity-0 group-hover:opacity-100 transition-all cursor-pointer"
title="Pick as winner"
>
<LucideIcon name="trophy" size={12} />
Winner
</span>
)}
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openConfirmDeleteCandidate(id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openConfirmDeleteCandidate(id);
}
}}
className={`absolute top-2 ${productUrl ? "right-10" : "right-2"} z-10 w-6 h-6 flex items-center justify-center rounded-full bg-gray-100/80 text-gray-400 hover:bg-red-100 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-all cursor-pointer`}
title="Delete candidate"
>
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</span>
{productUrl && (
<span
role="button"
tabIndex={0}
onClick={() => openExternalLink(productUrl)}
onClick={(e) => {
e.stopPropagation();
openExternalLink(productUrl);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.stopPropagation();
openExternalLink(productUrl);
}
}}
@@ -92,7 +154,7 @@ export function CandidateCard({
<h3 className="text-sm font-semibold text-gray-900 mb-2 truncate">
{name}
</h3>
<div className="flex flex-wrap gap-1.5 mb-3">
<div className="flex flex-wrap gap-1.5">
{weightGrams != null && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-400">
{formatWeight(weightGrams, unit)}
@@ -100,7 +162,7 @@ export function CandidateCard({
)}
{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">
{formatPrice(priceCents)}
{formatPrice(priceCents, currency)}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
@@ -113,32 +175,7 @@ export function CandidateCard({
</span>
<StatusBadge status={status} onStatusChange={onStatusChange} />
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => openCandidateEditPanel(id)}
className="text-xs text-gray-500 hover:text-gray-700 transition-colors"
>
Edit
</button>
<button
type="button"
onClick={() => openConfirmDeleteCandidate(id)}
className="text-xs text-gray-500 hover:text-red-600 transition-colors"
>
Delete
</button>
{isActive && (
<button
type="button"
onClick={() => openResolveDialog(threadId, id)}
className="ml-auto text-xs font-medium text-amber-600 hover:text-amber-700 transition-colors"
>
Pick Winner
</button>
)}
</div>
</div>
</div>
</button>
);
}

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { useDeleteCategory, useUpdateCategory } from "../hooks/useCategories";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -23,6 +24,7 @@ export function CategoryHeader({
itemCount,
}: CategoryHeaderProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(name);
const [editIcon, setEditIcon] = useState(icon);
@@ -87,7 +89,7 @@ export function CategoryHeader({
<h2 className="text-lg font-semibold text-gray-900">{name}</h2>
<span className="text-sm text-gray-400">
{itemCount} {itemCount === 1 ? "item" : "items"} ·{" "}
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost)}
{formatWeight(totalWeight, unit)} · {formatPrice(totalCost, currency)}
</span>
{!isUncategorized && (
<div className="ml-auto flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -1,7 +1,9 @@
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 { ClassificationBadge } from "./ClassificationBadge";
interface ItemCardProps {
id: number;
@@ -13,6 +15,8 @@ interface ItemCardProps {
imageFilename: string | null;
productUrl?: string | null;
onRemove?: () => void;
classification?: string;
onClassificationCycle?: () => void;
}
export function ItemCard({
@@ -25,8 +29,11 @@ export function ItemCard({
imageFilename,
productUrl,
onRemove,
classification,
onClassificationCycle,
}: ItemCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
const openEditPanel = useUIStore((s) => s.openEditPanel);
const openExternalLink = useUIStore((s) => s.openExternalLink);
@@ -129,7 +136,7 @@ export function ItemCard({
)}
{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">
{formatPrice(priceCents)}
{formatPrice(priceCents, currency)}
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-50 text-gray-600">
@@ -140,6 +147,12 @@ export function ItemCard({
/>{" "}
{categoryName}
</span>
{classification && onClassificationCycle && (
<ClassificationBadge
classification={classification}
onCycle={onClassificationCycle}
/>
)}
</div>
</div>
</button>

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useCurrency } from "../hooks/useCurrency";
import { useItems } from "../hooks/useItems";
import { useSyncSetupItems } from "../hooks/useSetups";
import { useWeightUnit } from "../hooks/useWeightUnit";
@@ -22,6 +23,7 @@ export function ItemPicker({
const { data: items } = useItems();
const syncItems = useSyncSetupItems(setupId);
const unit = useWeightUnit();
const currency = useCurrency();
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
// Reset selected IDs when panel opens
@@ -121,7 +123,7 @@ export function ItemPicker({
item.priceCents != null &&
" · "}
{item.priceCents != null &&
formatPrice(item.priceCents)}
formatPrice(item.priceCents, currency)}
</span>
</label>
))}

View File

@@ -1,4 +1,5 @@
import { Link } from "@tanstack/react-router";
import { useCurrency } from "../hooks/useCurrency";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatPrice, formatWeight } from "../lib/formatters";
@@ -18,6 +19,7 @@ export function SetupCard({
totalCost,
}: SetupCardProps) {
const unit = useWeightUnit();
const currency = useCurrency();
return (
<Link
to="/setups/$setupId"
@@ -35,7 +37,7 @@ export function SetupCard({
{formatWeight(totalWeight, unit)}
</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-50 text-green-500">
{formatPrice(totalCost)}
{formatPrice(totalCost, currency)}
</span>
</div>
</Link>

View File

@@ -1,4 +1,5 @@
import { useNavigate } from "@tanstack/react-router";
import { useCurrency } from "../hooks/useCurrency";
import { formatPrice } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
@@ -22,10 +23,11 @@ function formatDate(iso: string): string {
function formatPriceRange(
min: number | null,
max: number | null,
currency: Parameters<typeof formatPrice>[1],
): string | null {
if (min == null && max == null) return null;
if (min === max) return formatPrice(min);
return `${formatPrice(min)} - ${formatPrice(max)}`;
if (min === max) return formatPrice(min, currency);
return `${formatPrice(min, currency)} - ${formatPrice(max, currency)}`;
}
export function ThreadCard({
@@ -40,9 +42,10 @@ export function ThreadCard({
categoryIcon,
}: ThreadCardProps) {
const navigate = useNavigate();
const currency = useCurrency();
const isResolved = status === "resolved";
const priceRange = formatPriceRange(minPriceCents, maxPriceCents);
const priceRange = formatPriceRange(minPriceCents, maxPriceCents, currency);
return (
<button

View File

@@ -10,24 +10,25 @@ import {
import type { SetupItemWithCategory } from "../hooks/useSetups";
import { useWeightUnit } from "../hooks/useWeightUnit";
import { formatWeight, type WeightUnit } from "../lib/formatters";
import { LucideIcon } from "../lib/iconData";
const CATEGORY_COLORS = [
"#6366f1",
"#f59e0b",
"#10b981",
"#ef4444",
"#8b5cf6",
"#06b6d4",
"#f97316",
"#ec4899",
"#14b8a6",
"#84cc16",
"#374151",
"#4b5563",
"#6b7280",
"#7f8a94",
"#9ca3af",
"#b0b7bf",
"#c4c9cf",
"#d1d5db",
"#dfe2e6",
"#e5e7eb",
];
const CLASSIFICATION_COLORS: Record<string, string> = {
base: "#6366f1",
worn: "#f59e0b",
consumable: "#10b981",
base: "#6b7280",
worn: "#9ca3af",
consumable: "#d1d5db",
};
const CLASSIFICATION_LABELS: Record<string, string> = {
@@ -109,29 +110,34 @@ function CustomTooltip({
);
}
function SubtotalColumn({
function LegendRow({
color,
label,
weight,
unit,
color,
percent,
}: {
color: string;
label: string;
weight: number;
unit: WeightUnit;
color?: string;
percent?: number;
}) {
return (
<div className="flex flex-col items-center gap-1">
{color && (
<span
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
)}
<span className="text-xs text-gray-500">{label}</span>
<span className="text-sm font-semibold text-gray-900">
<div className="flex items-center gap-3 py-1.5">
<span
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-sm text-gray-600 flex-1">{label}</span>
<span className="text-sm font-semibold text-gray-900 tabular-nums">
{formatWeight(weight, unit)}
</span>
{percent != null && (
<span className="text-xs text-gray-400 w-10 text-right tabular-nums">
{(percent * 100).toFixed(0)}%
</span>
)}
</div>
);
}
@@ -237,27 +243,39 @@ export function WeightSummaryCard({ items }: WeightSummaryCardProps) {
</ResponsiveContainer>
</div>
{/* Weight subtotals columns */}
<div className="flex-1 grid grid-cols-4 gap-4">
<SubtotalColumn
label="Base"
{/* Weight legend */}
<div className="flex-1 flex flex-col justify-center min-w-0">
<LegendRow
color="#6b7280"
label="Base Weight"
weight={baseWeight}
unit={unit}
color="#6366f1"
percent={totalWeight > 0 ? baseWeight / totalWeight : undefined}
/>
<SubtotalColumn
<LegendRow
color="#9ca3af"
label="Worn"
weight={wornWeight}
unit={unit}
color="#f59e0b"
percent={totalWeight > 0 ? wornWeight / totalWeight : undefined}
/>
<SubtotalColumn
<LegendRow
color="#d1d5db"
label="Consumable"
weight={consumableWeight}
unit={unit}
color="#10b981"
percent={totalWeight > 0 ? consumableWeight / totalWeight : undefined}
/>
<SubtotalColumn label="Total" weight={totalWeight} unit={unit} />
<div className="border-t border-gray-200 mt-1.5 pt-1.5">
<div className="flex items-center gap-3 py-1.5">
<LucideIcon name="sigma" size={10} className="text-gray-400 shrink-0 ml-0.5" />
<span className="text-sm font-medium text-gray-700 flex-1">Total</span>
<span className="text-sm font-bold text-gray-900 tabular-nums">
{formatWeight(totalWeight, unit)}
</span>
<span className="w-10" />
</div>
</div>
</div>
</div>
</div>