Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,9 @@ The aesthetic lives in `app/globals.css` — a retro arcade / CRT theme:

Generic shadcn/ui primitives carried over from the base: button, card, dialog,
select, tabs, toast/toaster, tooltip, badge, alert, progress, switch, skeleton,
surface-card, page-container, section-title, empty-state, completion-ring, etc.
surface-card, page-container, section-title, empty-state, etc. `animated-text`
wraps the `slot-text` library (slot-machine roll for changing values, with a
`prefers-reduced-motion` fallback) — used for the dashboard value figures.

### Key conventions

Expand Down
13 changes: 11 additions & 2 deletions components/dashboard/inventory-value-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useCallback, useEffect, useState } from "react"
import { Line, LineChart, ReferenceDot, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { ChevronLeft, ChevronRight, PackageOpen, RefreshCw, TrendingUp } from "lucide-react"
import { AnimatedText } from "@/components/ui/animated-text"
import { Button } from "@/components/ui/button"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { EmptyState } from "@/components/ui/empty-state"
Expand Down Expand Up @@ -125,7 +126,9 @@ export function InventoryValuePanel() {
<div>
<p className="text-muted-foreground text-2xs tracking-[var(--tracking-eyebrow)] uppercase">Inventory value</p>
{latest ? (
<p className="text-accent mt-1 text-4xl">{formatPrice(latest.totalValue, latest.currency)}</p>
<p className="text-accent mt-1 text-4xl">
<AnimatedText text={formatPrice(latest.totalValue, latest.currency)} />
</p>
) : (
<p className="text-muted-foreground mt-1 text-2xl">—</p>
)}
Expand Down Expand Up @@ -181,6 +184,10 @@ export function InventoryValuePanel() {
strokeWidth={2}
dot={false}
activeDot={{ r: 5 }}
// Without this, every post-sync refetch hands recharts a new data
// array and the line replays its full draw — reads as the whole
// panel reloading. The value roll is the sync feedback instead.
isAnimationActive={false}
/>
{selectedPoint && (
<ReferenceDot
Expand Down Expand Up @@ -239,7 +246,9 @@ export function InventoryValuePanel() {

{holdings && (
<div className="text-right">
<p className="text-accent text-2xl">{formatPrice(holdings.totalValue, holdings.currency)}</p>
<p className="text-accent text-2xl">
<AnimatedText text={formatPrice(holdings.totalValue, holdings.currency)} />
</p>
<p className="text-muted-foreground text-2xs">{holdings.items.length} distinct items</p>
</div>
)}
Expand Down
9 changes: 7 additions & 2 deletions components/inventory/holding-row.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import { PackageOpen } from "lucide-react"
import { AnimatedText } from "@/components/ui/animated-text"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { StickerBadge } from "@/components/inventory/sticker-badge"
import { formatPrice } from "@/lib/market"
Expand Down Expand Up @@ -39,9 +40,13 @@ export function HoldingRow({ item, currency }: { item: HoldingItem; currency: st
<div className="text-right">
{item.unitPrice != null ? (
<>
<p className="text-foreground text-sm">{formatPrice(item.lineTotal ?? 0, currency)}</p>
<p className="text-foreground text-sm">
<AnimatedText text={formatPrice(item.lineTotal ?? 0, currency)} />
</p>
{item.count > 1 && (
<p className="text-muted-foreground text-2xs">{formatPrice(item.unitPrice, currency)} / unit</p>
<p className="text-muted-foreground text-2xs">
<AnimatedText text={formatPrice(item.unitPrice, currency)} /> / unit
</p>
)}
</>
) : (
Expand Down
33 changes: 26 additions & 7 deletions components/inventory/inventory-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import Link from "next/link"
import { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw, Search, PackageOpen, AlertCircle, Info } from "lucide-react"
import { AnimatedText } from "@/components/ui/animated-text"
import { Button } from "@/components/ui/button"
import { InputFrame } from "@/components/ui/input-frame"
import { Skeleton } from "@/components/ui/skeleton"
Expand Down Expand Up @@ -76,9 +77,13 @@ function ItemCard({ item, currency }: { item: InventoryItemView; currency: strin
<div className="text-right">
{item.unitPrice != null ? (
<>
<p className="text-foreground text-sm">{formatPrice(item.lineTotal ?? 0, currency)}</p>
<p className="text-foreground text-sm">
<AnimatedText text={formatPrice(item.lineTotal ?? 0, currency)} />
</p>
{item.count > 1 && (
<p className="text-muted-foreground text-2xs">{formatPrice(item.unitPrice, currency)} / unit</p>
<p className="text-muted-foreground text-2xs">
<AnimatedText text={formatPrice(item.unitPrice, currency)} /> / unit
</p>
)}
</>
) : (
Expand Down Expand Up @@ -112,19 +117,31 @@ export function InventoryList() {
const [sort, setSort] = useState<SortKey>("value-desc")
const [syncing, setSyncing] = useState(false)

const load = useCallback(async () => {
setLoading(true)
// `background` keeps the current list on screen (no skeleton swap) so the
// price roll animations can play in place after a sync — including on
// failure, where it toasts instead of wiping the mounted list.
const load = useCallback(async (background = false) => {
if (!background) setLoading(true)
setError(null)
try {
const res = await fetch("/api/inventory/items", { cache: "no-store" })
const body = await res.json()
if (!res.ok) {
setError(body.error ?? "Failed to load inventory.")
const message = body.error ?? "Failed to load inventory."
if (background) {
toast({ title: "Refresh failed", description: message, variant: "destructive" })
return
}
setError(message)
setData(null)
return
}
setData(body as InventoryItemsResponse)
} catch {
if (background) {
toast({ title: "Network error while refreshing", variant: "destructive" })
return
}
setError("Network error while loading inventory.")
} finally {
setLoading(false)
Expand All @@ -145,7 +162,7 @@ export function InventoryList() {
return
}
toast({ title: "Prices refreshed", description: formatPrice(body.totalValue, body.currency) })
await load()
await load(true)
} catch {
toast({ title: "Network error", variant: "destructive" })
} finally {
Expand All @@ -169,7 +186,9 @@ export function InventoryList() {
<Skeleton className="h-8 w-40" />
) : data ? (
<>
<p className="text-accent text-3xl">{formatPrice(data.totalValue, data.currency)}</p>
<p className="text-accent text-3xl">
<AnimatedText text={formatPrice(data.totalValue, data.currency)} />
</p>
<p className="text-muted-foreground text-2xs">
{data.totalItemCount} items · {data.pricedRows} priced
</p>
Expand Down
5 changes: 4 additions & 1 deletion components/inventory/item-price-chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useState } from "react"
import { Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"
import { TrendingUp } from "lucide-react"
import { AnimatedText } from "@/components/ui/animated-text"
import { EmptyState } from "@/components/ui/empty-state"
import { surfaceCardVariants } from "@/components/ui/surface-card"
import { DateRangeSelector } from "@/components/charts/date-range-selector"
Expand Down Expand Up @@ -34,7 +35,9 @@ function Stat({ label, value }: { label: string; value: string }) {
return (
<div className={surfaceCardVariants({ variant: "metric" })}>
<span className="text-muted-foreground text-2xs tracking-[var(--tracking-eyebrow)] uppercase">{label}</span>
<span className="text-foreground text-sm">{value}</span>
<span className="text-foreground text-sm">
<AnimatedText text={value} />
</span>
</div>
)
}
Expand Down
113 changes: 0 additions & 113 deletions components/ui/animated-number.tsx

This file was deleted.

55 changes: 55 additions & 0 deletions components/ui/animated-text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client"

import "slot-text/style.css"
import { useEffect, useState, useSyncExternalStore } from "react"
import { SlotText } from "slot-text/react"
import type { SlotOptions } from "slot-text"

// Single tuning point for every rolling label in the app.
const ROLL_OPTIONS: SlotOptions = {
duration: 300,
stagger: 45,
bounce: 0.6,
}

const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"

function subscribeToReducedMotion(onChange: () => void) {
const mql = window.matchMedia(REDUCED_MOTION_QUERY)
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}

function prefersReducedMotion() {
return window.matchMedia(REDUCED_MOTION_QUERY).matches
}

type AnimatedTextProps = {
text: string
className?: string
}

/**
* Slot-machine roll for short changing labels (values, totals). Rolls in on
* mount too: the first paint masks every digit to 0 (same width, symbols and
* separators static) and the real digits roll in right after, so each page
* load gets the animation — not just later value changes. Falls back to a
* plain <span> under prefers-reduced-motion, which slot-text does not handle
* itself.
*/
export function AnimatedText({ text, className }: AnimatedTextProps) {
const reducedMotion = useSyncExternalStore(subscribeToReducedMotion, prefersReducedMotion, () => false)
const [display, setDisplay] = useState(() => text.replace(/\d/g, "0"))

useEffect(() => {
setDisplay(text)
}, [text])

if (reducedMotion) {
return <span className={className}>{text}</span>
}

// aria-label pins the accessible name to the real value even while the
// masked first frame is on screen.
return <SlotText text={display} aria-label={text} className={className} options={ROLL_OPTIONS} />
}
Loading