From 037e4360cae5559495bb79501d98f9fc66a1a581 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 12:45:19 +1000 Subject: [PATCH 001/115] =?UTF-8?q?fix(storage):=20=F0=9F=90=9B=20never=20?= =?UTF-8?q?show=20another=20account's=20data=20while=20a=20new=20scope=20l?= =?UTF-8?q?oads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useRemoteData keeps stale data only within the same account; a failed load now carries retry, and the event stream reports disconnected/incompatible first loads. --- src/lib/storage/storage.client.ts | 150 +++++++++++++++++++++++------- src/lib/storage/storage.config.ts | 14 +++ src/lib/storage/storage.types.ts | 10 +- 3 files changed, 137 insertions(+), 37 deletions(-) diff --git a/src/lib/storage/storage.client.ts b/src/lib/storage/storage.client.ts index 5ebd2eb..212cfc4 100644 --- a/src/lib/storage/storage.client.ts +++ b/src/lib/storage/storage.client.ts @@ -1,10 +1,21 @@ -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { resolveScope } from "../accounts/account-scope"; import { useAccountSelection } from "../accounts/account-scope.store"; import { ALL_ACCOUNTS } from "../accounts/accounts.config"; -import { AUTH_STATUS, REMOTE_DATA, STORAGE_API } from "./storage.config"; -import type { ApiError, AppSnapshot, StorageSnapshot } from "./storage.types"; +import { + AUTH_STATUS, + REMOTE_DATA, + REMOTE_STATUS, + SNAPSHOT_CONNECTION, + STORAGE_API, +} from "./storage.config"; +import type { + ApiError, + AppSnapshot, + RemoteData, + SnapshotConnection, + StorageSnapshot, +} from "./storage.types"; const NO_ACCOUNTS: AppSnapshot = { accounts: [], @@ -24,12 +35,23 @@ const EMPTY_SNAPSHOT: StorageSnapshot = { let snapshot: AppSnapshot = NO_ACCOUNTS; /** False until the server's first push; before that "signed out" is a guess, not a fact. */ let received = false; +let connection: SnapshotConnection = SNAPSHOT_CONNECTION.connecting; let source: EventSource | undefined; const listeners = new Set<() => void>(); +function notify(): void { + for (const listener of listeners) listener(); +} + function publish(next: AppSnapshot): void { snapshot = next; - for (const listener of listeners) listener(); + notify(); +} + +function setConnection(next: SnapshotConnection): void { + if (connection === next) return; + connection = next; + notify(); } function parseSnapshot(raw: string): AppSnapshot | undefined { @@ -43,18 +65,31 @@ function parseSnapshot(raw: string): AppSnapshot | undefined { } } +function openSource(): void { + const opened = new EventSource(STORAGE_API.events); + source = opened; + opened.onmessage = (event: MessageEvent) => { + const next = parseSnapshot(event.data); + if (!next) { + // A server from another version speaks a shape this page can't read; say so instead of waiting forever. + if (!received) setConnection(SNAPSHOT_CONNECTION.incompatible); + return; + } + received = true; + if (next.accounts.length === 0) remoteCache.clear(); + connection = SNAPSHOT_CONNECTION.open; + publish(next); + }; + opened.onerror = () => { + // EventSource retries dropped streams itself, but gives up for good on a bad response. + // Once data has arrived the last snapshot stays usable, so only the first load reports it. + if (!received) setConnection(SNAPSHOT_CONNECTION.disconnected); + }; +} + function subscribe(listener: () => void): () => void { listeners.add(listener); - if (!source) { - source = new EventSource(STORAGE_API.events); - source.onmessage = (event: MessageEvent) => { - const next = parseSnapshot(event.data); - if (!next) return; - received = true; - if (next.accounts.length === 0) remoteCache.clear(); - publish(next); - }; - } + if (!source) openSource(); return () => { listeners.delete(listener); if (listeners.size === 0) { @@ -69,6 +104,19 @@ export function useSnapshotReceived(): boolean { return useSyncExternalStore(subscribe, () => received); } +/** How the live connection to the server is doing, for the first-load screen. */ +export function useSnapshotConnection(): SnapshotConnection { + return useSyncExternalStore(subscribe, () => connection); +} + +/** Drops the current event stream and opens a new one (a closed EventSource never reconnects by itself). */ +export function retrySnapshotConnection(): void { + source?.close(); + setConnection(SNAPSHOT_CONNECTION.connecting); + if (listeners.size > 0) openSource(); + else source = undefined; +} + /** Every account and the add-account login in progress. */ export function useAppSnapshot(): AppSnapshot { return useSyncExternalStore(subscribe, () => snapshot); @@ -134,10 +182,21 @@ export async function postForJson(path: string, body: unknown): Promise { /** A failure worth retrying (server-side), as opposed to a request the server rejected. */ class TransientError extends Error {} -export type RemoteData = - | { readonly status: "loading" } - | { readonly status: "error"; readonly message: string } - | { readonly status: "ready"; readonly data: T }; +export type { RemoteData } from "./storage.types"; + +/** What a failed load keeps between renders; `retry` is attached when it is returned. */ +type StoredRemoteData = + | Exclude, { readonly status: typeof REMOTE_STATUS.error }> + | { readonly status: typeof REMOTE_STATUS.error; readonly message: string }; + +const LOADING = { status: REMOTE_STATUS.loading } as const; + +/** The account a scoped API path asks about, or null for unscoped paths. */ +function scopeOf(path: string | undefined): string | null { + if (!path) return null; + const query = path.split("?")[1]; + return query === undefined ? null : new URLSearchParams(query).get(REMOTE_DATA.scopeParam); +} /** Last good response per path, so revisiting a page shows data instantly while it refetches. */ const remoteCache = new Map(); @@ -146,9 +205,9 @@ function isCacheable(path: string): boolean { return !REMOTE_DATA.uncachedPathPrefixes.some((prefix) => path.startsWith(prefix)); } -function readCache(path: string | undefined): RemoteData | undefined { +function readCache(path: string | undefined): StoredRemoteData | undefined { if (!path || !remoteCache.has(path)) return undefined; - return { status: "ready", data: remoteCache.get(path) as T }; + return { status: REMOTE_STATUS.ready, data: remoteCache.get(path) as T }; } function writeCache(path: string, data: unknown): void { @@ -180,22 +239,30 @@ export function usePageVisible(): boolean { * GETs `path`. A changed `path` (user action) and the first load fetch at once; * a changed `revision` alone refetches debounced, and is deferred while the tab is * hidden (one catch-up fetch runs when it returns). Cached data for `path` shows - * immediately and is revalidated; previous data stays on screen during a refetch - * so the UI never flashes empty. + * immediately and is revalidated; previous data for the same account stays on screen + * during a refetch so the UI never flashes empty, but another account's data never does. + * A failed load carries `retry`, which fetches again at once. */ export function useRemoteData(path: string | undefined, revision: number): RemoteData { - const [loaded, setLoaded] = useState<{ path: string | undefined; value: RemoteData }>(() => ({ - path, - value: readCache(path) ?? { status: "loading" }, - })); + const [loaded, setLoaded] = useState<{ path: string | undefined; value: StoredRemoteData }>( + () => ({ path, value: readCache(path) ?? LOADING }), + ); const visible = usePageVisible(); const lastPath = useRef(undefined); const lastVisible = useRef(visible); const fetchedRevision = useRef(undefined); const failures = useRef(0); - // Bumped by the retry timer after a transient failure, to rerun the fetch effect. + const manualRetry = useRef(false); + // Bumped by the retry timer after a transient failure (or by `retry`), to rerun the fetch effect. const [retryTick, setRetryTick] = useState(0); + const retry = useCallback(() => { + failures.current = 0; + manualRetry.current = true; + setLoaded((current) => ({ path: current.path, value: LOADING })); + setRetryTick((tick) => tick + 1); + }, []); + useEffect(() => { if (!path) { lastPath.current = undefined; @@ -203,15 +270,21 @@ export function useRemoteData(path: string | undefined, revision: number): Re } const pathChanged = lastPath.current !== path; const becameVisible = visible && !lastVisible.current; + const immediate = manualRetry.current; + manualRetry.current = false; lastPath.current = path; lastVisible.current = visible; - if (!pathChanged && (!visible || (becameVisible && fetchedRevision.current === revision))) + if ( + !pathChanged && + !immediate && + (!visible || (becameVisible && fetchedRevision.current === revision)) + ) return; fetchedRevision.current = revision; if (pathChanged) failures.current = 0; const controller = new AbortController(); - const delay = pathChanged || becameVisible ? 0 : REMOTE_DATA.revisionDebounceMs; + const delay = pathChanged || becameVisible || immediate ? 0 : REMOTE_DATA.revisionDebounceMs; let retryTimer: ReturnType | undefined; const timer = setTimeout(async () => { try { @@ -225,7 +298,7 @@ export function useRemoteData(path: string | undefined, revision: number): Re const data = (await response.json()) as T; writeCache(path, data); failures.current = 0; - setLoaded({ path, value: { status: "ready", data } }); + setLoaded({ path, value: { status: REMOTE_STATUS.ready, data } }); } catch (error) { if (controller.signal.aborted) return; // Forget this revision was fetched, so returning to a hidden tab retries it. @@ -240,12 +313,12 @@ export function useRemoteData(path: string | undefined, revision: number): Re } // Keep this path's last good response, or its cached copy when revisiting a page. setLoaded((current) => - current.path === path && current.value.status === "ready" + current.path === path && current.value.status === REMOTE_STATUS.ready ? current : { path, value: readCache(path) ?? { - status: "error", + status: REMOTE_STATUS.error, message: error instanceof Error ? error.message : String(error), }, }, @@ -259,7 +332,12 @@ export function useRemoteData(path: string | undefined, revision: number): Re }; }, [path, revision, visible, retryTick]); - if (!path) return { status: "loading" }; - // Another path's data lingers while this one loads, unless this one is cached. - return loaded.path === path ? loaded.value : (readCache(path) ?? loaded.value); + if (!path) return LOADING; + const value = + loaded.path === path + ? loaded.value + : // Another path's data lingers while this one loads (e.g. a new filter), unless this one + // is cached; data for another account is never shown under this one. + (readCache(path) ?? (scopeOf(loaded.path) === scopeOf(path) ? loaded.value : LOADING)); + return value.status === REMOTE_STATUS.error ? { ...value, retry } : value; } diff --git a/src/lib/storage/storage.config.ts b/src/lib/storage/storage.config.ts index 5a9cf61..f6a473c 100644 --- a/src/lib/storage/storage.config.ts +++ b/src/lib/storage/storage.config.ts @@ -32,7 +32,21 @@ export const TRADE_UP_FINDER = { maxContracts: 500, } as const; +export const REMOTE_STATUS = { loading: "loading", error: "error", ready: "ready" } as const; + +/** The first-load state of the server's event stream. */ +export const SNAPSHOT_CONNECTION = { + connecting: "connecting", + open: "open", + /** The stream failed before any snapshot arrived (server down, proxy error). */ + disconnected: "disconnected", + /** The server answered with a snapshot this page can't read (version mismatch). */ + incompatible: "incompatible", +} as const; + export const REMOTE_DATA = { + /** Query parameter naming the account a request is about; data never carries across it. */ + scopeParam: "account", /** Wait this long after a revision-only change before refetching, so bursts of pushes collapse into one request. */ revisionDebounceMs: 600, /** Most responses kept for instant re-display; the oldest is evicted first. */ diff --git a/src/lib/storage/storage.types.ts b/src/lib/storage/storage.types.ts index 5907f6c..31d1c53 100644 --- a/src/lib/storage/storage.types.ts +++ b/src/lib/storage/storage.types.ts @@ -1,5 +1,5 @@ import type { Account } from "../accounts/accounts.types.ts"; -import type { AUTH_STATUS } from "./storage.config.ts"; +import type { AUTH_STATUS, REMOTE_STATUS, SNAPSHOT_CONNECTION } from "./storage.config.ts"; export type AuthState = | { readonly status: typeof AUTH_STATUS.signedOut } @@ -102,3 +102,11 @@ export type AppSnapshot = { /** Error payload shared by every non-2xx API response. */ export type ApiError = { readonly error: string }; + +export type SnapshotConnection = (typeof SNAPSHOT_CONNECTION)[keyof typeof SNAPSHOT_CONNECTION]; + +/** A GET that may still be loading, may have failed (with a way to try again), or has data. */ +export type RemoteData = + | { readonly status: typeof REMOTE_STATUS.loading } + | { readonly status: typeof REMOTE_STATUS.error; readonly message: string; readonly retry: () => void } + | { readonly status: typeof REMOTE_STATUS.ready; readonly data: T }; From a9e9d9b6441cb1e92d386f690e51296dcdf04fa5 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 12:45:19 +1000 Subject: [PATCH 002/115] =?UTF-8?q?fix(ui):=20=F0=9F=A9=B9=20swap=20endles?= =?UTF-8?q?s=20skeletons=20for=20retryable=20load=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth-gate/auth-gate.component.tsx | 18 ++++++++-- .../connection-problem.component.tsx | 35 +++++++++++++++++++ .../connection-problem.config.ts | 17 +++++++++ .../connection-problem.types.ts | 7 ++++ .../load-error/load-error.component.tsx | 23 ++++++++++++ .../load-error/load-error.config.ts | 5 +++ src/components/load-error/load-error.types.ts | 5 +++ src/lib/copy/copy.config.ts | 1 - src/routes/cases.tsx | 7 ++-- src/routes/index.tsx | 7 ++-- src/routes/items.tsx | 7 ++-- src/routes/storage.tsx | 5 ++- src/routes/terminals.tsx | 7 ++-- src/routes/tradeup.tsx | 17 +++------ 14 files changed, 124 insertions(+), 37 deletions(-) create mode 100644 src/components/connection-problem/connection-problem.component.tsx create mode 100644 src/components/connection-problem/connection-problem.config.ts create mode 100644 src/components/connection-problem/connection-problem.types.ts create mode 100644 src/components/load-error/load-error.component.tsx create mode 100644 src/components/load-error/load-error.config.ts create mode 100644 src/components/load-error/load-error.types.ts diff --git a/src/components/auth-gate/auth-gate.component.tsx b/src/components/auth-gate/auth-gate.component.tsx index 2f19643..cb85d74 100644 --- a/src/components/auth-gate/auth-gate.component.tsx +++ b/src/components/auth-gate/auth-gate.component.tsx @@ -1,12 +1,26 @@ import { AppShellSkeleton } from "~/components/app-shell-skeleton/app-shell-skeleton.component"; +import { ConnectionProblem } from "~/components/connection-problem/connection-problem.component"; import { SignInScreen } from "~/components/sign-in-screen/sign-in-screen.component"; -import { useAppSnapshot, useSnapshotReceived } from "~/lib/storage/storage.client"; +import { + useAppSnapshot, + useSnapshotConnection, + useSnapshotReceived, +} from "~/lib/storage/storage.client"; +import { SNAPSHOT_CONNECTION } from "~/lib/storage/storage.config"; import type { AuthGateProps } from "./auth-gate.types"; /** Everything inside needs at least one tracked Steam account; each account's own connection is handled further in. */ export function AuthGate({ children }: AuthGateProps) { const { accounts, login } = useAppSnapshot(); const received = useSnapshotReceived(); - if (!received) return ; + const connection = useSnapshotConnection(); + if (!received) { + return connection === SNAPSHOT_CONNECTION.disconnected || + connection === SNAPSHOT_CONNECTION.incompatible ? ( + + ) : ( + + ); + } return accounts.length > 0 ? <>{children} : ; } diff --git a/src/components/connection-problem/connection-problem.component.tsx b/src/components/connection-problem/connection-problem.component.tsx new file mode 100644 index 0000000..58999dd --- /dev/null +++ b/src/components/connection-problem/connection-problem.component.tsx @@ -0,0 +1,35 @@ +import { RefreshCw, Unplug } from "lucide-react"; +import { Button } from "~/components/button/button.component"; +import { Logo } from "~/components/logo/logo.component"; +import { retrySnapshotConnection } from "~/lib/storage/storage.client"; +import { SNAPSHOT_CONNECTION } from "~/lib/storage/storage.config"; +import { CONNECTION_PROBLEM } from "./connection-problem.config"; +import type { ConnectionProblemProps } from "./connection-problem.types"; + +/** First load failed: say why instead of showing a skeleton forever, and offer the fix. */ +export function ConnectionProblem({ reason }: ConnectionProblemProps) { + const copy = CONNECTION_PROBLEM.copy[reason]; + const incompatible = reason === SNAPSHOT_CONNECTION.incompatible; + return ( +
+
+ +
+
+ +

{copy.title}

+
+

{copy.body}

+ +
+
+
+ ); +} diff --git a/src/components/connection-problem/connection-problem.config.ts b/src/components/connection-problem/connection-problem.config.ts new file mode 100644 index 0000000..191cc02 --- /dev/null +++ b/src/components/connection-problem/connection-problem.config.ts @@ -0,0 +1,17 @@ +import { SNAPSHOT_CONNECTION } from "~/lib/storage/storage.config"; + +export const CONNECTION_PROBLEM = { + copy: { + [SNAPSHOT_CONNECTION.disconnected]: { + title: "Can't reach the OpenInventory server", + body: "The page couldn't open its live connection. Check that the server is running, then try again.", + }, + [SNAPSHOT_CONNECTION.incompatible]: { + title: "This page doesn't match the server", + body: "The server sent data this version of the page can't read. Reload to get the matching version.", + }, + }, + retry: "Try again", + reload: "Reload page", + iconSize: 14, +} as const; diff --git a/src/components/connection-problem/connection-problem.types.ts b/src/components/connection-problem/connection-problem.types.ts new file mode 100644 index 0000000..0df74a6 --- /dev/null +++ b/src/components/connection-problem/connection-problem.types.ts @@ -0,0 +1,7 @@ +import type { SNAPSHOT_CONNECTION } from "~/lib/storage/storage.config"; + +export type ConnectionProblemProps = { + readonly reason: + | typeof SNAPSHOT_CONNECTION.disconnected + | typeof SNAPSHOT_CONNECTION.incompatible; +}; diff --git a/src/components/load-error/load-error.component.tsx b/src/components/load-error/load-error.component.tsx new file mode 100644 index 0000000..ed9db8b --- /dev/null +++ b/src/components/load-error/load-error.component.tsx @@ -0,0 +1,23 @@ +import { RefreshCw } from "lucide-react"; +import { Button } from "~/components/button/button.component"; +import { LOAD_ERROR } from "./load-error.config"; +import type { LoadErrorProps } from "./load-error.types"; + +/** A failed page load: what went wrong and a way to try again, in place of an endless skeleton. */ +export function LoadError({ message, onRetry }: LoadErrorProps) { + return ( +
+
+

{LOAD_ERROR.title}

+

{message}

+
+ +
+ ); +} diff --git a/src/components/load-error/load-error.config.ts b/src/components/load-error/load-error.config.ts new file mode 100644 index 0000000..5dacd95 --- /dev/null +++ b/src/components/load-error/load-error.config.ts @@ -0,0 +1,5 @@ +export const LOAD_ERROR = { + title: "Couldn't load this page", + retry: "Try again", + iconSize: 12, +} as const; diff --git a/src/components/load-error/load-error.types.ts b/src/components/load-error/load-error.types.ts new file mode 100644 index 0000000..058082e --- /dev/null +++ b/src/components/load-error/load-error.types.ts @@ -0,0 +1,5 @@ +export type LoadErrorProps = { + /** Why the load failed, as the server or browser reported it. */ + readonly message: string; + readonly onRetry: () => void; +}; diff --git a/src/lib/copy/copy.config.ts b/src/lib/copy/copy.config.ts index 36d9b71..c3b4192 100644 --- a/src/lib/copy/copy.config.ts +++ b/src/lib/copy/copy.config.ts @@ -4,7 +4,6 @@ export const COPY = { brand: { name: "OpenInventory", tagline: "Self-hosted CS2 inventory and storage unit tracker" }, common: { loading: "Loading…", - errorPrefix: "Something went wrong:", empty: "—", refresh: "Refresh", refreshing: "Refreshing…", diff --git a/src/routes/cases.tsx b/src/routes/cases.tsx index 0560f45..5744548 100644 --- a/src/routes/cases.tsx +++ b/src/routes/cases.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { LoadError } from "~/components/load-error/load-error.component"; import { useEffect, useState } from "react"; import { BarList } from "~/components/bar-list/bar-list.component"; import { CasesSkeleton } from "~/components/cases-skeleton/cases-skeleton.component"; @@ -60,11 +61,7 @@ function CasesPage() { <> {data.status === "loading" && } - {data.status === "error" && ( -

- {COPY.common.errorPrefix} {data.message} -

- )} + {data.status === "error" && } {response && (
diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 640a5d7..bfd6250 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { LoadError } from "~/components/load-error/load-error.component"; import { RefreshCw } from "lucide-react"; import { useState } from "react"; import { Button } from "~/components/button/button.component"; @@ -59,11 +60,7 @@ function DashboardPage() { {data.status === "loading" && } - {data.status === "error" && ( -

- {COPY.common.errorPrefix} {data.message} -

- )} + {data.status === "error" && } {dashboard && (
diff --git a/src/routes/items.tsx b/src/routes/items.tsx index 28ae49f..ff306eb 100644 --- a/src/routes/items.tsx +++ b/src/routes/items.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { LoadError } from "~/components/load-error/load-error.component"; import { Gem } from "lucide-react"; import { useCallback, useDeferredValue, useMemo, useState } from "react"; import { ItemCollection } from "~/components/item-collection/item-collection.component"; @@ -224,11 +225,7 @@ function ItemsPage() { )} - {data.status === "error" && ( -

- {COPY.common.errorPrefix} {data.message} -

- )} + {data.status === "error" && } 0}> {selectedItems.length > 0 && ( - {snapshot.units.length === 0 ? ( + {data.status === "error" ? ( + + ) : snapshot.units.length === 0 ? (

{copy.noUnits}

) : organising ? ( {COPY.common.loading}

)} - {data.status === "error" && ( -

- {COPY.common.errorPrefix} {data.message} -

- )} + {data.status === "error" && } {data.status === "ready" && (terminals.length === 0 ? (

{COPY.terminal.noTerminals}

diff --git a/src/routes/tradeup.tsx b/src/routes/tradeup.tsx index 8afdfbf..3d01d36 100644 --- a/src/routes/tradeup.tsx +++ b/src/routes/tradeup.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { LoadError } from "~/components/load-error/load-error.component"; import { useMemo, useState } from "react"; import { Metric } from "~/components/metric/metric.component"; import { PageHeader } from "~/components/page-header/page-header.component"; @@ -119,11 +120,7 @@ function TradeUpPage() { {finder.status === "loading" && (

{copy.searching}

)} - {finder.status === "error" && ( -

- {COPY.common.errorPrefix} {finder.message} -

- )} + {finder.status === "error" && } {finder.status === "ready" && finder.data.results.length === 0 && (

{copy.noFinderResults}

)} @@ -171,9 +168,7 @@ function TradeUpPage() { )} {(candidates.status === "loading" || (current && !ready)) && } {candidates.status === "error" && ( -

- {COPY.common.errorPrefix} {candidates.message} -

+ )} {candidates.status === "ready" && !candidates.data.ok && (

{candidates.data.reason}

@@ -190,11 +185,7 @@ function TradeUpPage() { onClear={() => setSelection([])} /> {orderedIds.length === 0 &&

{copy.pickPrompt}

} - {odds.status === "error" && ( -

- {COPY.common.errorPrefix} {odds.message} -

- )} + {odds.status === "error" && } {odds.status === "ready" && !odds.data.ok && orderedIds.length > 0 && (

{odds.data.reason}

)} From 5119e9a60ce40aad0f6dfb8639cd970ae0a902dc Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 12:50:16 +1000 Subject: [PATCH 003/115] =?UTF-8?q?fix(accounts):=20=F0=9F=90=9B=20close?= =?UTF-8?q?=20re-auth=20on=20success=20and=20show=20every=20account's=20st?= =?UTF-8?q?atus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switcher options carry a status dot and text, the SteamID tail (also in the trigger), and a wider list; fallback names keep their ending. - The server publishes which account the last QR sign-in was for, so signing an existing account in again closes the dialog and reports "Added…" or "Signed in to … again" with a shortcut to view it. Cancel is posted only while a sign-in is pending; the dialog reads "Sign in to again". - Settings rows for broken accounts get Reconnect / Sign in again, Remove is an outline button, label saves show a pending state, and focus moves to Add account once a removed row disappears. --- server/accounts/account-manager.ts | 14 ++- .../account-actions.component.tsx | 46 +++++++ .../account-actions/account-actions.types.ts | 4 + .../account-row/account-row.component.tsx | 115 ++++++++++++++++++ .../account-row/account-row.config.ts | 4 + .../account-row/account-row.types.ts | 7 ++ .../account-status-dot.component.tsx | 27 ++++ .../account-status-dot.config.ts | 14 +++ .../account-status-dot.types.ts | 12 ++ .../account-status.component.tsx | 59 +++------ .../account-status/account-status.config.ts | 3 + .../account-switcher.component.tsx | 66 ++++++---- .../account-switcher.config.ts | 7 +- .../accounts-panel.component.tsx | 111 ++++------------- .../accounts-panel/accounts-panel.config.ts | 2 - .../add-account-dialog.component.tsx | 67 +++++++--- .../add-account-dialog.config.ts | 2 +- .../app-shell/app-shell.component.tsx | 2 + src/components/button/button.config.ts | 6 +- src/components/button/button.types.ts | 10 +- .../login-card/login-card.component.tsx | 20 ++- src/components/login-card/login-card.types.ts | 5 + .../login-outcome/login-outcome.component.tsx | 67 ++++++++++ .../login-outcome/login-outcome.config.ts | 3 + src/components/select/select.component.tsx | 40 +++++- src/components/select/select.types.ts | 10 ++ src/lib/accounts/account-scope.hooks.ts | 18 ++- src/lib/accounts/account-status.ts | 27 ++-- src/lib/accounts/add-account-dialog.store.ts | 16 +-- src/lib/accounts/add-account-dialog.types.ts | 20 +++ src/lib/copy/copy.config.ts | 24 +++- src/lib/storage/storage.client.ts | 14 ++- src/lib/storage/storage.types.ts | 19 ++- 33 files changed, 637 insertions(+), 224 deletions(-) create mode 100644 src/components/account-actions/account-actions.component.tsx create mode 100644 src/components/account-actions/account-actions.types.ts create mode 100644 src/components/account-row/account-row.component.tsx create mode 100644 src/components/account-row/account-row.config.ts create mode 100644 src/components/account-row/account-row.types.ts create mode 100644 src/components/account-status-dot/account-status-dot.component.tsx create mode 100644 src/components/account-status-dot/account-status-dot.config.ts create mode 100644 src/components/account-status-dot/account-status-dot.types.ts create mode 100644 src/components/account-status/account-status.config.ts create mode 100644 src/components/login-outcome/login-outcome.component.tsx create mode 100644 src/components/login-outcome/login-outcome.config.ts create mode 100644 src/lib/accounts/add-account-dialog.types.ts diff --git a/server/accounts/account-manager.ts b/server/accounts/account-manager.ts index 90c5257..0d8ee19 100644 --- a/server/accounts/account-manager.ts +++ b/server/accounts/account-manager.ts @@ -1,6 +1,10 @@ import type { AccountLabel } from "../../src/lib/accounts/accounts.types.ts"; import { AUTH_STATUS } from "../../src/lib/storage/storage.config.ts"; -import type { AccountState, AppSnapshot } from "../../src/lib/storage/storage.types.ts"; +import type { + AccountState, + AppSnapshot, + LoginResult, +} from "../../src/lib/storage/storage.types.ts"; import type { Database } from "../db/db.types.ts"; import type { SavedSession } from "../steam/session-file.ts"; import type { SessionStore } from "../steam/session-store.ts"; @@ -34,6 +38,7 @@ export function createAccountManager({ const listeners = new Set<(snapshot: AppSnapshot) => void>(); let revision = 0; let emitScheduled = false; + let lastLogin: LoginResult | null = null; const login = createQrLogin((session) => void adopt(session)); login.subscribe(() => emit()); @@ -66,6 +71,11 @@ export function createAccountManager({ /** A QR login finished: store the token, register the account and connect it (or reconnect with the new token). */ async function adopt(session: SavedSession): Promise { const existing = gateways.get(session.steamId); + lastLogin = { + sequence: (lastLogin?.sequence ?? 0) + 1, + steamId: session.steamId, + added: existing === undefined, + }; existing?.disconnect(); unsubscribers.get(session.steamId)?.(); sessions.forAccount(session.steamId).save(session); @@ -101,7 +111,7 @@ export function createAccountManager({ snapshot, }); } - return { accounts, login: login.getState(), revision }; + return { accounts, login: login.getState(), lastLogin, revision }; } return { diff --git a/src/components/account-actions/account-actions.component.tsx b/src/components/account-actions/account-actions.component.tsx new file mode 100644 index 0000000..7d412c0 --- /dev/null +++ b/src/components/account-actions/account-actions.component.tsx @@ -0,0 +1,46 @@ +import { useState } from "react"; +import { Button } from "~/components/button/button.component"; +import { useAddAccountDialog } from "~/lib/accounts/add-account-dialog.store"; +import { COPY } from "~/lib/copy/copy.config"; +import { postJson } from "~/lib/storage/storage.client"; +import { STORAGE_API } from "~/lib/storage/storage.config"; +import { cn } from "~/utils/cn"; +import type { AccountActionsProps } from "./account-actions.types"; + +/** The two ways to fix a broken account: retry its saved login, or sign in to it again. */ +export function AccountActions({ steamId, className }: AccountActionsProps) { + const copy = COPY.accounts; + const openReauth = useAddAccountDialog((state) => state.openReauth); + const [retrying, setRetrying] = useState(false); + const [failure, setFailure] = useState(); + + async function reconnect() { + setRetrying(true); + setFailure(undefined); + try { + await postJson(STORAGE_API.accountReconnect, { accountId: steamId }); + } catch (error) { + setFailure(error instanceof Error ? error.message : String(error)); + } finally { + setRetrying(false); + } + } + + return ( +
+
+ + +
+ {failure && ( +

+ {failure} +

+ )} +
+ ); +} diff --git a/src/components/account-actions/account-actions.types.ts b/src/components/account-actions/account-actions.types.ts new file mode 100644 index 0000000..f15a8b9 --- /dev/null +++ b/src/components/account-actions/account-actions.types.ts @@ -0,0 +1,4 @@ +export type AccountActionsProps = { + readonly steamId: string; + readonly className?: string; +}; diff --git a/src/components/account-row/account-row.component.tsx b/src/components/account-row/account-row.component.tsx new file mode 100644 index 0000000..a86faec --- /dev/null +++ b/src/components/account-row/account-row.component.tsx @@ -0,0 +1,115 @@ +import { useState } from "react"; +import { AccountActions } from "~/components/account-actions/account-actions.component"; +import { AccountStatusDot } from "~/components/account-status-dot/account-status-dot.component"; +import { Button } from "~/components/button/button.component"; +import { DangerDialog } from "~/components/danger-dialog/danger-dialog.component"; +import { Select } from "~/components/select/select.component"; +import { + accountIdSuffix, + accountName, + accountNameWithId, +} from "~/lib/accounts/account-scope.hooks"; +import { authStatusText } from "~/lib/accounts/account-status"; +import { ACCOUNT_LABELS } from "~/lib/accounts/accounts.config"; +import type { AccountLabel } from "~/lib/accounts/accounts.types"; +import { COPY } from "~/lib/copy/copy.config"; +import { postJson } from "~/lib/storage/storage.client"; +import { AUTH_STATUS, STORAGE_API } from "~/lib/storage/storage.config"; +import { ACCOUNT_ROW } from "./account-row.config"; +import type { AccountRowProps } from "./account-row.types"; + +function toLabel(id: string): AccountLabel | null { + return ACCOUNT_LABELS.find((label) => label === id) ?? null; +} + +/** One tracked account in settings: its status, how to fix it, its label, and removing it. */ +export function AccountRow({ account, onRemoved }: AccountRowProps) { + const copy = COPY.accounts; + const [removing, setRemoving] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(); + const name = accountName(account); + const { auth } = account.snapshot; + + async function setLabel(id: string) { + setError(undefined); + setSaving(true); + try { + await postJson(STORAGE_API.accountLabel, { + accountId: account.steamId, + label: toLabel(id), + }); + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)); + } finally { + setSaving(false); + } + } + + return ( +
  • +
    +
    + + {account.displayName ?? copy.fallbackShortName} + + {accountIdSuffix(account.steamId)} + + + {authStatusText(auth.status)} + +
    +
    + + {saving ? copy.labelSaving : ""} + + @@ -55,10 +69,10 @@ export function AccountSwitcher({ className }: AccountSwitcherProps) { type="button" title={copy.add} aria-label={copy.add} - onClick={() => setDialogOpen(true)} - className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground" + onClick={openAdd} + className={ACCOUNT_SWITCHER.addButtonClass} > - +
    ); diff --git a/src/components/account-switcher/account-switcher.config.ts b/src/components/account-switcher/account-switcher.config.ts index 2f7efe6..a5d1467 100644 --- a/src/components/account-switcher/account-switcher.config.ts +++ b/src/components/account-switcher/account-switcher.config.ts @@ -1,4 +1,9 @@ export const ACCOUNT_SWITCHER = { - addIconSize: 14, + addIconSize: 16, selectWidth: "min-w-0 flex-1", + /** Wide enough for a long persona name, its SteamID tail and its status line. */ + listMinWidth: 272, + /** At least 36px square, so the add button is an easy touch target. */ + addButtonClass: + "flex size-9 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", } as const; diff --git a/src/components/accounts-panel/accounts-panel.component.tsx b/src/components/accounts-panel/accounts-panel.component.tsx index 715eb71..3189ddb 100644 --- a/src/components/accounts-panel/accounts-panel.component.tsx +++ b/src/components/accounts-panel/accounts-panel.component.tsx @@ -1,103 +1,36 @@ import { Plus } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useRef } from "react"; +import { AccountRow } from "~/components/account-row/account-row.component"; import { Button } from "~/components/button/button.component"; -import { DangerDialog } from "~/components/danger-dialog/danger-dialog.component"; import { Panel } from "~/components/panel/panel.component"; -import { Select } from "~/components/select/select.component"; -import { accountName } from "~/lib/accounts/account-scope.hooks"; -import { authDotClass, authStatusText } from "~/lib/accounts/account-status"; -import { ACCOUNT_LABELS } from "~/lib/accounts/accounts.config"; -import type { AccountLabel } from "~/lib/accounts/accounts.types"; import { useAddAccountDialog } from "~/lib/accounts/add-account-dialog.store"; import { COPY } from "~/lib/copy/copy.config"; -import { postJson, useAppSnapshot } from "~/lib/storage/storage.client"; -import { STORAGE_API } from "~/lib/storage/storage.config"; -import type { AccountState } from "~/lib/storage/storage.types"; -import { cn } from "~/utils/cn"; +import { useAppSnapshot } from "~/lib/storage/storage.client"; import { ACCOUNTS_PANEL } from "./accounts-panel.config"; -function toLabel(id: string): AccountLabel | null { - return ACCOUNT_LABELS.find((label) => label === id) ?? null; -} - -function AccountRow({ account }: { readonly account: AccountState }) { - const copy = COPY.accounts; - const [removing, setRemoving] = useState(false); - const [error, setError] = useState(); - const name = accountName(account); - - async function setLabel(id: string) { - setError(undefined); - try { - await postJson(STORAGE_API.accountLabel, { - accountId: account.steamId, - label: toLabel(id), - }); - } catch (failure) { - setError(failure instanceof Error ? failure.message : String(failure)); - } - } - - return ( -
  • -
    -
    - - {name} - - {authStatusText(account.snapshot.auth)} - -
    -
    - setQuery(e.target.value)} - placeholder={COPY.common.searchPlaceholder} - className="h-8 w-full rounded-md border border-input bg-card px-2.5 outline-none placeholder:text-muted-foreground focus:border-primary sm:w-56" - /> - {isAll && ( - setQuery(e.target.value)} + placeholder={COPY.common.searchPlaceholder} + aria-label={COPY.common.searchPlaceholder} + className={ITEMS_PAGE.searchClass} /> - )} - - +
    + + + {COPY.refresh.scope(refresh.scopeLabel)} + +
    + {refresh.skipped > 0 && ( +

    + {COPY.refresh.skipped(refresh.skipped)} +

    + )} + {refresh.error && ( +

    + {refresh.error} +

    + )}
    From 75a7dedbec68b961322835310e9aa55111d1e0d6 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 12:58:54 +1000 Subject: [PATCH 009/115] =?UTF-8?q?refactor(tradeup):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20extract=20finder=20controls=20and=20results=20onto=20shared?= =?UTF-8?q?=20parts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared Select instead of a native one, copy in config, text-success for profit, '—' when float is unknown, a skeleton while searching, and StatTrak filter values from one config list used by client and server. --- server/index.ts | 6 +- server/tradeup-finder.ts | 12 ++- .../tradeup-finder-controls.component.tsx | 48 ++++++++++ .../tradeup-finder-controls.config.ts | 13 +++ .../tradeup-finder-controls.types.ts | 8 ++ .../tradeup-finder-results.component.tsx | 96 +++++++++++++++++++ .../tradeup-finder-results.config.ts | 17 ++++ .../tradeup-finder-results.types.ts | 6 ++ src/lib/copy/copy.config.ts | 8 -- src/lib/items/item.types.ts | 6 +- src/lib/storage/storage.config.ts | 13 +++ src/routes/tradeup.tsx | 88 +++-------------- 12 files changed, 233 insertions(+), 88 deletions(-) create mode 100644 src/components/tradeup-finder-controls/tradeup-finder-controls.component.tsx create mode 100644 src/components/tradeup-finder-controls/tradeup-finder-controls.config.ts create mode 100644 src/components/tradeup-finder-controls/tradeup-finder-controls.types.ts create mode 100644 src/components/tradeup-finder-results/tradeup-finder-results.component.tsx create mode 100644 src/components/tradeup-finder-results/tradeup-finder-results.config.ts create mode 100644 src/components/tradeup-finder-results/tradeup-finder-results.types.ts diff --git a/server/index.ts b/server/index.ts index a295755..e996640 100644 --- a/server/index.ts +++ b/server/index.ts @@ -7,6 +7,7 @@ import { SSE_KEEP_ALIVE_MS, STORAGE_API, STORAGE_LIMITS, + TRADE_UP_FINDER, } from "../src/lib/storage/storage.config.ts"; import type { ApiError, @@ -541,8 +542,9 @@ const server = createServer(async (req, res) => { c.skin ? [{ view: c.view, skin: c.skin }] : [], ); if (path === STORAGE_API.tradeUpFinder) { - const statTrak = url.searchParams.get("statTrak") ?? "all"; - if (statTrak !== "all" && statTrak !== "normal" && statTrak !== "stattrak") { + const requested = url.searchParams.get("statTrak") ?? TRADE_UP_FINDER.defaultStatTrakFilter; + const statTrak = TRADE_UP_FINDER.statTrakFilters.find((filter) => filter === requested); + if (statTrak === undefined) { throw new UserFacingError("Invalid StatTrak filter", HTTP.badRequest); } return sendJson( diff --git a/server/tradeup-finder.ts b/server/tradeup-finder.ts index f39f4c8..7c44e1c 100644 --- a/server/tradeup-finder.ts +++ b/server/tradeup-finder.ts @@ -3,7 +3,11 @@ import { type TradeUpFinderOptions, type TradeUpFinderResponse, } from "../src/lib/items/item.types.ts"; -import { TRADE_UP, TRADE_UP_FINDER } from "../src/lib/storage/storage.config.ts"; +import { + TRADE_UP, + TRADE_UP_FINDER, + TRADE_UP_STATTRAK_FILTER, +} from "../src/lib/storage/storage.config.ts"; import type { Catalog } from "./items/catalog.types.ts"; import type { PriceBook } from "./prices/prices.ts"; import { computeTradeUp, inputCountFor, isPriced, type TradeUpCandidate } from "./tradeup.ts"; @@ -23,8 +27,10 @@ export function findTradeUps( if (!isPriced(candidate)) return false; if (!options.includeStorage && candidate.view.locationId !== INVENTORY_LOCATION_ID) return false; - if (options.statTrak === "normal" && candidate.skin.isStatTrak) return false; - if (options.statTrak === "stattrak" && !candidate.skin.isStatTrak) return false; + if (options.statTrak === TRADE_UP_STATTRAK_FILTER.normal && candidate.skin.isStatTrak) + return false; + if (options.statTrak === TRADE_UP_STATTRAK_FILTER.statTrak && !candidate.skin.isStatTrak) + return false; if (options.rarityId === TRADE_UP.covertRarity) { return ( candidate.skin.skinId !== null && catalog.specialsFor(candidate.skin.skinId).length > 0 diff --git a/src/components/tradeup-finder-controls/tradeup-finder-controls.component.tsx b/src/components/tradeup-finder-controls/tradeup-finder-controls.component.tsx new file mode 100644 index 0000000..62e7c2e --- /dev/null +++ b/src/components/tradeup-finder-controls/tradeup-finder-controls.component.tsx @@ -0,0 +1,48 @@ +import { useId } from "react"; +import { Select } from "~/components/select/select.component"; +import { TRADE_UP_FINDER } from "~/lib/storage/storage.config"; +import { TRADEUP_FINDER_CONTROLS } from "./tradeup-finder-controls.config"; +import type { TradeUpFinderControlsProps } from "./tradeup-finder-controls.types"; + +/** Filters for the best-EV contract search: StatTrak mix and whether storage units count. */ +export function TradeUpFinderControls({ + statTrak, + onStatTrakChange, + includeStorage, + onIncludeStorageChange, +}: TradeUpFinderControlsProps) { + const copy = TRADEUP_FINDER_CONTROLS; + const headingId = useId(); + return ( +
    +

    + {copy.title} +

    +
    + {copy.statTrakLabel} + onIncludeStorageChange(event.target.checked)} + className="size-4 accent-primary" + /> + {copy.includeStorage} + +
    + ); +} diff --git a/src/components/tradeup-finder-controls/tradeup-finder-controls.config.ts b/src/components/tradeup-finder-controls/tradeup-finder-controls.config.ts new file mode 100644 index 0000000..171941d --- /dev/null +++ b/src/components/tradeup-finder-controls/tradeup-finder-controls.config.ts @@ -0,0 +1,13 @@ +import { TRADE_UP_STATTRAK_FILTER } from "~/lib/storage/storage.config"; + +export const TRADEUP_FINDER_CONTROLS = { + title: "Find best EV contracts", + statTrakLabel: "StatTrak", + statTrakOptions: [ + { id: TRADE_UP_STATTRAK_FILTER.all, label: "Include all" }, + { id: TRADE_UP_STATTRAK_FILTER.normal, label: "Normal only" }, + { id: TRADE_UP_STATTRAK_FILTER.statTrak, label: "StatTrak only" }, + ], + includeStorage: "Include storage unit items", + selectWidth: "w-40", +} as const; diff --git a/src/components/tradeup-finder-controls/tradeup-finder-controls.types.ts b/src/components/tradeup-finder-controls/tradeup-finder-controls.types.ts new file mode 100644 index 0000000..e911ac1 --- /dev/null +++ b/src/components/tradeup-finder-controls/tradeup-finder-controls.types.ts @@ -0,0 +1,8 @@ +import type { TradeUpStatTrakFilter } from "~/lib/items/item.types"; + +export type TradeUpFinderControlsProps = { + readonly statTrak: TradeUpStatTrakFilter; + readonly onStatTrakChange: (filter: TradeUpStatTrakFilter) => void; + readonly includeStorage: boolean; + readonly onIncludeStorageChange: (include: boolean) => void; +}; diff --git a/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx b/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx new file mode 100644 index 0000000..d7f95de --- /dev/null +++ b/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx @@ -0,0 +1,96 @@ +import { LoadError } from "~/components/load-error/load-error.component"; +import { LoadingRegion } from "~/components/loading-region/loading-region.component"; +import { Skeleton } from "~/components/skeleton/skeleton.component"; +import { COPY } from "~/lib/copy/copy.config"; +import { REMOTE_STATUS } from "~/lib/storage/storage.config"; +import { cn } from "~/utils/cn"; +import { formatFloat, formatMoney, formatPercent } from "~/utils/format"; +import { TRADEUP_FINDER_RESULTS } from "./tradeup-finder-results.config"; +import type { TradeUpFinderResultsProps } from "./tradeup-finder-results.types"; + +const range = (count: number) => Array.from({ length: count }, (_, index) => index); + +/** The best-EV contracts the finder found in this account, or its loading and empty states. */ +export function TradeUpFinderResults({ finder }: TradeUpFinderResultsProps) { + const copy = TRADEUP_FINDER_RESULTS; + if (finder.status === REMOTE_STATUS.loading) { + return ( + + {range(copy.skeletonCards).map((card) => ( +
    + + +
    + {range(copy.skeletonOutcomes).map((outcome) => ( + + ))} +
    +
    + ))} +
    + ); + } + if (finder.status === REMOTE_STATUS.error) + return ; + const { results, currency } = finder.data; + if (results.length === 0) return

    {copy.empty}

    ; + + return ( +
      + {results.map((contract, index) => ( +
    1. +
      + {copy.rank(index)} +
      + {[ + { label: copy.expectedValue, value: formatMoney(contract.expectedValue, currency) }, + { label: copy.cost, value: formatMoney(contract.inputCost, currency) }, + { + label: copy.profit, + value: formatMoney(contract.profit, currency), + tone: contract.profit >= 0 ? "text-success" : "text-destructive", + }, + { + label: copy.float, + value: contract.odds.ok + ? formatFloat(contract.odds.averageFloat) + : COPY.common.empty, + }, + ].map(({ label, value, tone }) => ( +
      +
      {label}
      +
      {value}
      +
      + ))} +
      +
      +

      + {copy.inputs}:{" "} + {contract.inputs.map((item) => item.name).join(copy.inputSeparator)} +

      + {contract.odds.ok && ( +
        + {contract.odds.outcomes.map((outcome) => ( +
      • + + {outcome.name} + {copy.outcomeSeparator} + {formatPercent(outcome.probability)} + + + {outcome.price === null + ? COPY.common.empty + : formatMoney(outcome.price, currency)} + +
      • + ))} +
      + )} +
    2. + ))} +
    + ); +} diff --git a/src/components/tradeup-finder-results/tradeup-finder-results.config.ts b/src/components/tradeup-finder-results/tradeup-finder-results.config.ts new file mode 100644 index 0000000..4912317 --- /dev/null +++ b/src/components/tradeup-finder-results/tradeup-finder-results.config.ts @@ -0,0 +1,17 @@ +export const TRADEUP_FINDER_RESULTS = { + rank: (index: number) => `#${index + 1}`, + expectedValue: "EV", + cost: "Cost", + profit: "Profit", + float: "Float", + inputs: "Inputs", + inputSeparator: ", ", + outcomeSeparator: " · ", + listLabel: "Best EV contracts", + /** Scrolls on its own so the calculator below stays on screen. */ + listClass: "max-h-[40vh] shrink-0 overflow-y-auto", + empty: "No complete contracts found for these filters.", + /** Placeholder cards while the search runs; real results vary up to the server's limit. */ + skeletonCards: 3, + skeletonOutcomes: 3, +} as const; diff --git a/src/components/tradeup-finder-results/tradeup-finder-results.types.ts b/src/components/tradeup-finder-results/tradeup-finder-results.types.ts new file mode 100644 index 0000000..2db99ef --- /dev/null +++ b/src/components/tradeup-finder-results/tradeup-finder-results.types.ts @@ -0,0 +1,6 @@ +import type { TradeUpFinderResponse } from "~/lib/items/item.types"; +import type { RemoteData } from "~/lib/storage/storage.types"; + +export type TradeUpFinderResultsProps = { + readonly finder: RemoteData; +}; diff --git a/src/lib/copy/copy.config.ts b/src/lib/copy/copy.config.ts index eb77691..e413c59 100644 --- a/src/lib/copy/copy.config.ts +++ b/src/lib/copy/copy.config.ts @@ -304,14 +304,6 @@ export const COPY = { expectedValue: "Expected value", expectedReturn: "Expected return", chanceToProfit: "Chance to profit", - finderTitle: "Find best EV contracts", - statTrakFilter: "StatTrak", - allItems: "Include all", - normalOnly: "Normal only", - statTrakOnly: "StatTrak only", - includeStorage: "Include storage unit items", - searching: "Searching holdings…", - noFinderResults: "No complete contracts found for these filters.", outcomes: "Possible outcomes", autoFill: "Pick cheapest", clear: "Clear", diff --git a/src/lib/items/item.types.ts b/src/lib/items/item.types.ts index 30b4f36..f2893e4 100644 --- a/src/lib/items/item.types.ts +++ b/src/lib/items/item.types.ts @@ -1,3 +1,5 @@ +import type { TRADE_UP_FINDER } from "../storage/storage.config.ts"; + export type StickerView = { readonly name: string; readonly imageUrl: string | null; @@ -126,9 +128,11 @@ export type TradeUpOddsResponse = readonly currency: string; }; +export type TradeUpStatTrakFilter = (typeof TRADE_UP_FINDER.statTrakFilters)[number]; + export type TradeUpFinderOptions = { readonly rarityId: string; - readonly statTrak: "all" | "normal" | "stattrak"; + readonly statTrak: TradeUpStatTrakFilter; readonly includeStorage: boolean; }; diff --git a/src/lib/storage/storage.config.ts b/src/lib/storage/storage.config.ts index f6a473c..07ff0af 100644 --- a/src/lib/storage/storage.config.ts +++ b/src/lib/storage/storage.config.ts @@ -26,7 +26,20 @@ export const STORAGE_API = { terminalNextOffer: "/api/terminals/next-offer", } as const; +/** Wire values of the finder's StatTrak filter (`statTrak=` on the finder request). */ +export const TRADE_UP_STATTRAK_FILTER = { + all: "all", + normal: "normal", + statTrak: "stattrak", +} as const; + export const TRADE_UP_FINDER = { + statTrakFilters: [ + TRADE_UP_STATTRAK_FILTER.all, + TRADE_UP_STATTRAK_FILTER.normal, + TRADE_UP_STATTRAK_FILTER.statTrak, + ], + defaultStatTrakFilter: TRADE_UP_STATTRAK_FILTER.all, candidateLimit: 40, resultLimit: 20, maxContracts: 500, diff --git a/src/routes/tradeup.tsx b/src/routes/tradeup.tsx index 3587379..ff5b9e2 100644 --- a/src/routes/tradeup.tsx +++ b/src/routes/tradeup.tsx @@ -4,6 +4,8 @@ import { useMemo, useState } from "react"; import { Metric } from "~/components/metric/metric.component"; import { PageHeader } from "~/components/page-header/page-header.component"; import { RarityPicker } from "~/components/rarity-picker/rarity-picker.component"; +import { TradeUpFinderControls } from "~/components/tradeup-finder-controls/tradeup-finder-controls.component"; +import { TradeUpFinderResults } from "~/components/tradeup-finder-results/tradeup-finder-results.component"; import { TradeUpInputs } from "~/components/tradeup-inputs/tradeup-inputs.component"; import { TradeUpOutcomes } from "~/components/tradeup-outcomes/tradeup-outcomes.component"; import { TradeUpSkeleton } from "~/components/tradeup-skeleton/tradeup-skeleton.component"; @@ -12,10 +14,11 @@ import type { TradeUpCandidatesResponse, TradeUpFinderResponse, TradeUpOddsResponse, + TradeUpStatTrakFilter, } from "~/lib/items/item.types"; import { useScopedPath, useSingleAccountLabel } from "~/lib/accounts/account-scope.hooks"; import { useRemoteData, useStorageSnapshot } from "~/lib/storage/storage.client"; -import { STORAGE_API, TRADE_UP } from "~/lib/storage/storage.config"; +import { STORAGE_API, TRADE_UP, TRADE_UP_FINDER } from "~/lib/storage/storage.config"; import { usePriceQuery } from "~/lib/settings/settings.store"; import { formatChance, formatFloat, formatMoney, formatPercent } from "~/utils/format"; @@ -35,7 +38,9 @@ function TradeUpPage() { const accountLabel = useSingleAccountLabel(); const [rarity, setRarity] = useState(TRADE_UP.defaultInputRarity); const [selection, setSelection] = useState([]); - const [finderStatTrak, setFinderStatTrak] = useState<"all" | "normal" | "stattrak">("all"); + const [finderStatTrak, setFinderStatTrak] = useState( + TRADE_UP_FINDER.defaultStatTrakFilter, + ); const [includeStorage, setIncludeStorage] = useState(false); const candidatesPath = useScopedPath(`${STORAGE_API.tradeUp}?rarity=${rarity}&${priceQuery}`); const candidates = useRemoteData(candidatesPath, snapshot.revision); @@ -95,78 +100,13 @@ function TradeUpPage() { }} /> -
    -

    {copy.finderTitle}

    - - -
    - {finder.status === "loading" && ( -

    {copy.searching}

    - )} - {finder.status === "error" && } - {finder.status === "ready" && finder.data.results.length === 0 && ( -

    {copy.noFinderResults}

    - )} - {finder.status === "ready" && finder.data.results.length > 0 && ( -
    - {finder.data.results.map((contract, index) => ( -
    -
    - #{index + 1} - EV {formatMoney(contract.expectedValue, finder.data.currency)} - Cost {formatMoney(contract.inputCost, finder.data.currency)} - = 0 ? "text-positive" : "text-destructive"}> - Profit {formatMoney(contract.profit, finder.data.currency)} - - Float {formatFloat(contract.odds.ok ? contract.odds.averageFloat : 0)} -
    -

    - Inputs: {contract.inputs.map((item) => item.name).join(", ")} -

    - {contract.odds.ok && ( -
    - {contract.odds.outcomes.map((outcome) => ( -
    - - {outcome.name} · {formatPercent(outcome.probability)} - - - {outcome.price === null - ? COPY.common.empty - : formatMoney(outcome.price, finder.data.currency)} - -
    - ))} -
    - )} -
    - ))} -
    - )} + + {(candidates.status === "loading" || (current && !ready)) && } {candidates.status === "error" && ( From 9372587a8b4f5b62b1b898a0b1ebd50ab89b4b2f Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 12:59:05 +1000 Subject: [PATCH 010/115] =?UTF-8?q?refactor(banners):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20move=20banner=20prop=20types=20into=20their=20.types.ts=20fi?= =?UTF-8?q?les?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/sync-error/sync-error.component.tsx | 10 ++-------- src/components/sync-error/sync-error.types.ts | 7 +++++++ .../transfer-status/transfer-status.component.tsx | 10 ++-------- .../transfer-status/transfer-status.types.ts | 7 +++++++ 4 files changed, 18 insertions(+), 16 deletions(-) create mode 100644 src/components/sync-error/sync-error.types.ts create mode 100644 src/components/transfer-status/transfer-status.types.ts diff --git a/src/components/sync-error/sync-error.component.tsx b/src/components/sync-error/sync-error.component.tsx index fac0940..f6659ce 100644 --- a/src/components/sync-error/sync-error.component.tsx +++ b/src/components/sync-error/sync-error.component.tsx @@ -6,16 +6,10 @@ import { COPY } from "~/lib/copy/copy.config"; import { postJson, useAppSnapshot } from "~/lib/storage/storage.client"; import { STORAGE_API } from "~/lib/storage/storage.config"; import { usePriceQuery } from "~/lib/settings/settings.store"; -import type { AccountState } from "~/lib/storage/storage.types"; import { SYNC_ERROR } from "./sync-error.config"; +import type { SyncErrorBannerProps } from "./sync-error.types"; -function SyncErrorBanner({ - account, - named, -}: { - readonly account: AccountState; - readonly named: boolean; -}) { +function SyncErrorBanner({ account, named }: SyncErrorBannerProps) { const { syncError, loadingUnits } = account.snapshot; const priceQuery = usePriceQuery(); const [retrying, setRetrying] = useState(false); diff --git a/src/components/sync-error/sync-error.types.ts b/src/components/sync-error/sync-error.types.ts new file mode 100644 index 0000000..dfabed7 --- /dev/null +++ b/src/components/sync-error/sync-error.types.ts @@ -0,0 +1,7 @@ +import type { AccountState } from "~/lib/storage/storage.types"; + +export type SyncErrorBannerProps = { + readonly account: AccountState; + /** Prefixes the banner with the account's name; set when more than one account is tracked. */ + readonly named: boolean; +}; diff --git a/src/components/transfer-status/transfer-status.component.tsx b/src/components/transfer-status/transfer-status.component.tsx index 5e5aa30..ad9fed7 100644 --- a/src/components/transfer-status/transfer-status.component.tsx +++ b/src/components/transfer-status/transfer-status.component.tsx @@ -2,18 +2,12 @@ import { useState } from "react"; import { Button } from "~/components/button/button.component"; import { accountName } from "~/lib/accounts/account-scope.hooks"; import { postJson, useAppSnapshot } from "~/lib/storage/storage.client"; -import type { AccountState } from "~/lib/storage/storage.types"; import { STORAGE_API } from "~/lib/storage/storage.config"; import { TRANSFER_STATUS } from "./transfer-status.config"; +import type { TransferBannerProps } from "./transfer-status.types"; /** Banner for one account's running/last transfer. */ -function TransferBanner({ - account, - named, -}: { - readonly account: AccountState; - readonly named: boolean; -}) { +function TransferBanner({ account, named }: TransferBannerProps) { const { transfer } = account.snapshot; const accountId = { accountId: account.steamId }; const owner = named ? ( diff --git a/src/components/transfer-status/transfer-status.types.ts b/src/components/transfer-status/transfer-status.types.ts new file mode 100644 index 0000000..405f3d5 --- /dev/null +++ b/src/components/transfer-status/transfer-status.types.ts @@ -0,0 +1,7 @@ +import type { AccountState } from "~/lib/storage/storage.types"; + +export type TransferBannerProps = { + readonly account: AccountState; + /** Prefixes the banner with the account's name; set when more than one account is tracked. */ + readonly named: boolean; +}; From ccaac03f69b1e3c705d1365d16456e299435cd1d Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:01:36 +1000 Subject: [PATCH 011/115] =?UTF-8?q?fix(tradeup):=20=F0=9F=A9=B9=20show=20o?= =?UTF-8?q?utcome=20odds=20as=20unsigned=20chances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tradeup-finder-results.component.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx b/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx index d7f95de..5b52fd3 100644 --- a/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx +++ b/src/components/tradeup-finder-results/tradeup-finder-results.component.tsx @@ -4,7 +4,7 @@ import { Skeleton } from "~/components/skeleton/skeleton.component"; import { COPY } from "~/lib/copy/copy.config"; import { REMOTE_STATUS } from "~/lib/storage/storage.config"; import { cn } from "~/utils/cn"; -import { formatFloat, formatMoney, formatPercent } from "~/utils/format"; +import { formatChance, formatFloat, formatMoney } from "~/utils/format"; import { TRADEUP_FINDER_RESULTS } from "./tradeup-finder-results.config"; import type { TradeUpFinderResultsProps } from "./tradeup-finder-results.types"; @@ -78,7 +78,7 @@ export function TradeUpFinderResults({ finder }: TradeUpFinderResultsProps) { {outcome.name} {copy.outcomeSeparator} - {formatPercent(outcome.probability)} + {formatChance(outcome.probability)} {outcome.price === null From add76f72c70b5e11e2cd50baadc3537d36d17bf5 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:01:36 +1000 Subject: [PATCH 012/115] =?UTF-8?q?fix(items):=20=F0=9F=A9=B9=20drop=20the?= =?UTF-8?q?=20native=20search=20clear=20glyph=20from=20the=20filter=20box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/items.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/routes/items.tsx b/src/routes/items.tsx index 51c8ee5..0921308 100644 --- a/src/routes/items.tsx +++ b/src/routes/items.tsx @@ -211,7 +211,6 @@ function ItemsPage() { )} setQuery(e.target.value)} placeholder={COPY.common.searchPlaceholder} From afbc77ed34ca378ae927755d469500d8c426527d Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:02:05 +1000 Subject: [PATCH 013/115] =?UTF-8?q?feat(terminals):=20=E2=9C=A8=20name=20t?= =?UTF-8?q?he=20account=20when=20confirming=20an=20activation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terminal-activate/terminal-activate.component.tsx | 10 ++++++++-- src/lib/copy/copy.config.ts | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/components/terminal-activate/terminal-activate.component.tsx b/src/components/terminal-activate/terminal-activate.component.tsx index 062d0e0..352f5f7 100644 --- a/src/components/terminal-activate/terminal-activate.component.tsx +++ b/src/components/terminal-activate/terminal-activate.component.tsx @@ -1,14 +1,18 @@ import { useState } from "react"; import { DangerDialog } from "~/components/danger-dialog/danger-dialog.component"; +import { accountNameWithId } from "~/lib/accounts/account-scope.hooks"; import { COPY } from "~/lib/copy/copy.config"; -import { postJson } from "~/lib/storage/storage.client"; +import { postJson, useAppSnapshot } from "~/lib/storage/storage.client"; import { STORAGE_API } from "~/lib/storage/storage.config"; import type { TerminalActivateProps } from "./terminal-activate.types"; /** Activating a Terminal is irreversible, so it sits behind an explicit confirmation. */ export function TerminalActivate({ itemId, accountId, name }: TerminalActivateProps) { const [open, setOpen] = useState(false); + const { accounts } = useAppSnapshot(); const copy = COPY.terminal; + // Activation can start from the all-accounts item list, so name the account when there is a choice. + const account = accounts.length > 1 ? accounts.find((a) => a.steamId === accountId) : undefined; return ( <>
    - + {showLocation && (

    {accountName ? `${accountName} · ${item.locationName}` : item.locationName} diff --git a/src/components/item-grid-card/item-grid-card.config.ts b/src/components/item-grid-card/item-grid-card.config.ts index f4450a0..c32a5d4 100644 --- a/src/components/item-grid-card/item-grid-card.config.ts +++ b/src/components/item-grid-card/item-grid-card.config.ts @@ -4,4 +4,6 @@ export const ITEM_GRID_CARD = { stickerSize: 16, maxStickers: 5, selectRowLabel: "Select item", + /** Cards are narrow at every width, so one reason chip shows and the rest fold into `+N`. */ + visibleHighlights: 1, } as const; diff --git a/src/components/item-highlights/item-highlights.component.tsx b/src/components/item-highlights/item-highlights.component.tsx index 87c3af2..782fd03 100644 --- a/src/components/item-highlights/item-highlights.component.tsx +++ b/src/components/item-highlights/item-highlights.component.tsx @@ -12,10 +12,12 @@ import type { * Reason chips on one line under the item name, so a row never grows past its fixed height. * Chips that don't fit collapse into `+N`, which names the hidden reasons for screen readers. */ -export function ItemHighlights({ item, children }: ItemHighlightsProps) { +export function ItemHighlights({ item, children, visible }: ItemHighlightsProps) { const { reasons } = itemHighlights(item); if (reasons.length === 0 && !children) return null; - const { narrow, wide } = ITEM_HIGHLIGHTS.visibleChips; + // A fixed count (e.g. a narrow grid card) applies at every breakpoint. + const { narrow, wide } = + visible === undefined ? ITEM_HIGHLIGHTS.visibleChips : { narrow: visible, wide: visible }; const overflow = (from: number) => reasons.slice(from).map((reason) => reason.label); const more = (from: number, className: string) => reasons.length > from && ( diff --git a/src/components/item-highlights/item-highlights.types.ts b/src/components/item-highlights/item-highlights.types.ts index 772943e..601e878 100644 --- a/src/components/item-highlights/item-highlights.types.ts +++ b/src/components/item-highlights/item-highlights.types.ts @@ -5,6 +5,8 @@ export type ItemHighlightsProps = { readonly item: ItemView; /** Extra inline content kept on the same line, such as sticker thumbnails. */ readonly children?: ReactNode; + /** Chips shown before `+N` at every width; by default it follows the viewport (narrow vs wide). */ + readonly visible?: number; }; export type ItemTierBadgeProps = { From 0eeb083eddfd5d15b61bd7d2894ad133fcc4c538 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:08:44 +1000 Subject: [PATCH 015/115] =?UTF-8?q?fix(accounts):=20=F0=9F=94=A7=20offer?= =?UTF-8?q?=20Reconnect=20and=20Sign=20in=20again=20right=20in=20the=20par?= =?UTF-8?q?tial=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account-gate/account-gate.component.tsx | 13 ++++++------- src/lib/copy/copy.config.ts | 1 - 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/account-gate/account-gate.component.tsx b/src/components/account-gate/account-gate.component.tsx index 0d61403..d1b2d3b 100644 --- a/src/components/account-gate/account-gate.component.tsx +++ b/src/components/account-gate/account-gate.component.tsx @@ -1,6 +1,7 @@ import { Link } from "@tanstack/react-router"; import { TriangleAlert } from "lucide-react"; import { Fragment, useEffect, useId, useRef } from "react"; +import { AccountActions } from "~/components/account-actions/account-actions.component"; import { AccountStatus } from "~/components/account-status/account-status.component"; import { AccountStatusDot } from "~/components/account-status-dot/account-status-dot.component"; import { Button } from "~/components/button/button.component"; @@ -69,7 +70,9 @@ export function AccountGate({ pathname, children }: AccountGateProps) { ); } const waiting = scoped.filter((account) => account.snapshot.auth.status !== AUTH_STATUS.ready); - const [onlyWaiting] = waiting.length === 1 ? waiting : []; + // One broken account can be fixed right here; otherwise every row in settings has its own actions. + const [fixable] = + waiting.length === 1 && waiting[0]?.snapshot.auth.status === AUTH_STATUS.error ? waiting : []; const notice = waiting.length > 0 ? copy.partialNotice(waiting.map(accountNameWithId)) : ""; return ( <> @@ -83,12 +86,8 @@ export function AccountGate({ pathname, children }: AccountGateProps) { {notice}

    - - {onlyWaiting && ( - - )} + + {fixable && } `${names.join(", ")} ${names.length === 1 ? "isn't" : "aren't"} connected, so ${names.length === 1 ? "its" : "their"} items are left out of this view.`, - partialFix: (name: string) => `Reconnect ${name}…`, partialSettings: "Open settings", column: "Account", filterAll: "All accounts", From f27305779c1ff850e8300293915849cd922fcff9 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:08:44 +1000 Subject: [PATCH 016/115] =?UTF-8?q?fix(a11y):=20=F0=9F=A9=B9=20read=20the?= =?UTF-8?q?=20chosen=20account's=20status=20from=20the=20switcher=20trigge?= =?UTF-8?q?r?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../account-switcher/account-switcher.component.tsx | 10 +++++++++- src/components/select/select.component.tsx | 2 ++ src/components/select/select.types.ts | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/components/account-switcher/account-switcher.component.tsx b/src/components/account-switcher/account-switcher.component.tsx index c98c673..8a7314f 100644 --- a/src/components/account-switcher/account-switcher.component.tsx +++ b/src/components/account-switcher/account-switcher.component.tsx @@ -1,5 +1,5 @@ import { Plus } from "lucide-react"; -import { useMemo } from "react"; +import { useId, useMemo } from "react"; import { AccountStatusDot } from "~/components/account-status-dot/account-status-dot.component"; import { Select } from "~/components/select/select.component"; import type { SelectOption } from "~/components/select/select.types"; @@ -19,6 +19,7 @@ export function AccountSwitcher({ className }: AccountSwitcherProps) { const { accounts, scope } = useAccountScope(); const select = useAccountSelection((state) => state.select); const openAdd = useAddAccountDialog((state) => state.openAdd); + const statusId = useId(); const copy = COPY.accounts; const options = useMemo(() => { @@ -53,6 +54,9 @@ export function AccountSwitcher({ className }: AccountSwitcherProps) { ]; }, [accounts, copy]); if (scope === undefined) return null; + // The trigger's dot is colour only; its option's name, SteamID tail and status line are read instead. + const current = options.find((option) => option.id === scope); + const description = current ? [current.label, current.detail, current.hint] : []; return (
    @@ -62,9 +66,13 @@ export function AccountSwitcher({ className }: AccountSwitcherProps) { className={ACCOUNT_SWITCHER.selectWidth} listMinWidth={ACCOUNT_SWITCHER.listMinWidth} showDetailInTrigger + describedBy={statusId} options={options} onChange={select} /> + + {description.filter(Boolean).join(", ")} +
    - {formatInteger(ordered.length)} {ITEM_GRID.countSuffix} + {formatCount(ordered.length, ITEM_GRID.count)}
  • ); diff --git a/src/components/item-grid/item-grid.config.ts b/src/components/item-grid/item-grid.config.ts index 9f94cfd..c79dd45 100644 --- a/src/components/item-grid/item-grid.config.ts +++ b/src/components/item-grid/item-grid.config.ts @@ -10,7 +10,7 @@ export const ITEM_GRID = { selectAllText: "Select all", sortLabel: "Sort items", emptyLabel: "No items", - countSuffix: "items", + count: { one: "item", other: "items" }, defaultSort: "price-desc", sorts: [ { id: "price-desc", label: "Price: high to low" }, diff --git a/src/components/unit-card/unit-card.component.tsx b/src/components/unit-card/unit-card.component.tsx index 8d0694f..d779e26 100644 --- a/src/components/unit-card/unit-card.component.tsx +++ b/src/components/unit-card/unit-card.component.tsx @@ -3,7 +3,8 @@ import { ItemThumb } from "~/components/item-thumb/item-thumb.component"; import { Skeleton } from "~/components/skeleton/skeleton.component"; import { UnitName } from "~/components/unit-name/unit-name.component"; import { cn } from "~/utils/cn"; -import { formatInteger, formatMoney } from "~/utils/format"; +import { formatMoney } from "~/utils/format"; +import { formatCount } from "~/utils/plural"; import { UNIT_CARD } from "./unit-card.config"; import type { UnitCardProps } from "./unit-card.types"; @@ -40,7 +41,7 @@ export function UnitCard({

    - {formatInteger(unit.itemCount)} {COPY.storage.items} + {formatCount(unit.itemCount, COPY.storage.items)}

    {loading ? ( diff --git a/src/lib/copy/copy.config.ts b/src/lib/copy/copy.config.ts index bb8606f..136bfa1 100644 --- a/src/lib/copy/copy.config.ts +++ b/src/lib/copy/copy.config.ts @@ -290,7 +290,7 @@ export const COPY = { noUnits: "No storage units found on this account.", selectUnit: "Select a unit to see what's inside.", unitEmpty: "This unit is empty.", - items: "items", + items: { one: "item", other: "items" }, value: "Value", }, tradeup: { diff --git a/src/routes/storage.tsx b/src/routes/storage.tsx index 7efba22..1c4eec0 100644 --- a/src/routes/storage.tsx +++ b/src/routes/storage.tsx @@ -24,7 +24,8 @@ import { postJson, useRemoteData, useStorageSnapshot } from "~/lib/storage/stora import { STORAGE_API } from "~/lib/storage/storage.config"; import { useItemViewMode } from "~/lib/items/view-mode.store"; import { usePriceQuery, useSettings } from "~/lib/settings/settings.store"; -import { formatInteger, formatMoney } from "~/utils/format"; +import { formatMoney } from "~/utils/format"; +import { formatCount } from "~/utils/plural"; export const Route = createFileRoute("/storage")({ component: StoragePage }); @@ -130,7 +131,7 @@ function StoragePage() {

    {selected.name} ·{" "} - {formatInteger(selected.itemCount)} {copy.items} · {copy.value}{" "} + {formatCount(selected.itemCount, copy.items)} · {copy.value}{" "} {unitPending(selected) ? ( ) : ( diff --git a/src/utils/plural.test.ts b/src/utils/plural.test.ts new file mode 100644 index 0000000..3580168 --- /dev/null +++ b/src/utils/plural.test.ts @@ -0,0 +1,18 @@ +/* oxlint-disable typescript/no-floating-promises -- node:test registers suites synchronously */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { formatCount, pluralize } from "./plural.ts"; + +const ROW = { one: "row", other: "rows" } as const; + +test("pluralize picks the singular only for exactly one", () => { + assert.equal(pluralize(1, ROW), "row"); + assert.equal(pluralize(0, ROW), "rows"); + assert.equal(pluralize(2, ROW), "rows"); +}); + +test("formatCount groups the number and keeps the noun in step", () => { + assert.equal(formatCount(1, ROW), "1 row"); + assert.equal(formatCount(0, ROW), "0 rows"); + assert.equal(formatCount(1204, ROW), "1,204 rows"); +}); diff --git a/src/utils/plural.ts b/src/utils/plural.ts new file mode 100644 index 0000000..30252ad --- /dev/null +++ b/src/utils/plural.ts @@ -0,0 +1,15 @@ +import { FORMAT_LOCALE } from "./format.config.ts"; +import { formatInteger } from "./format.ts"; +import type { PluralForms } from "./plural.types.ts"; + +const rules = new Intl.PluralRules(FORMAT_LOCALE); + +/** The form of `forms` that fits `count`: "item" for 1, "items" for 0 or 2+. */ +export function pluralize(count: number, forms: PluralForms): string { + return rules.select(count) === "one" ? forms.one : forms.other; +} + +/** A grouped count with its noun, e.g. "1 item" or "1,204 items". */ +export function formatCount(count: number, forms: PluralForms): string { + return `${formatInteger(count)} ${pluralize(count, forms)}`; +} diff --git a/src/utils/plural.types.ts b/src/utils/plural.types.ts new file mode 100644 index 0000000..f16e2b2 --- /dev/null +++ b/src/utils/plural.types.ts @@ -0,0 +1,5 @@ +/** A noun's singular and plural forms, e.g. `{ one: "item", other: "items" }`. */ +export type PluralForms = { + readonly one: string; + readonly other: string; +}; From 00381b2b57dda0b4ffe3c3356d02ca42f0bc8d18 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:45:39 +1000 Subject: [PATCH 025/115] =?UTF-8?q?fix(item-table):=20=F0=9F=A9=B9=20floor?= =?UTF-8?q?=20column=20widths=20and=20hide=20Type,=20then=20Location,=20wh?= =?UTF-8?q?en=20narrow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataTable measures its own width and drops optional columns by priority, so prices and floats never clip from the 960px Electron minimum up. Highlight chips follow the name column's width (container query) instead of the viewport. The footer row count uses the plural helper. --- .../data-table/data-table.component.tsx | 58 ++++++++++++------- .../data-table/data-table.config.ts | 15 ++++- src/components/data-table/data-table.hooks.ts | 29 ++++++++++ .../data-table/data-table.skeleton.tsx | 35 ++++++----- src/components/data-table/data-table.types.ts | 17 ++++-- src/components/data-table/data-table.utils.ts | 35 +++++++++++ .../item-highlights.component.tsx | 34 ++++++----- .../item-highlights/item-highlights.config.ts | 20 +++++-- .../item-label/item-label.component.tsx | 3 +- .../item-table/item-table.component.tsx | 18 +++--- .../item-table/item-table.config.ts | 32 +++++----- .../item-table/item-table.skeleton.tsx | 31 +++++++--- src/components/item-table/item-table.types.ts | 2 + src/routes/items.tsx | 2 +- src/utils/use-element-width.ts | 20 +++++++ 15 files changed, 258 insertions(+), 93 deletions(-) create mode 100644 src/components/data-table/data-table.hooks.ts create mode 100644 src/components/data-table/data-table.utils.ts create mode 100644 src/utils/use-element-width.ts diff --git a/src/components/data-table/data-table.component.tsx b/src/components/data-table/data-table.component.tsx index 301fe4f..cc66222 100644 --- a/src/components/data-table/data-table.component.tsx +++ b/src/components/data-table/data-table.component.tsx @@ -1,9 +1,11 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowDown, ArrowUp } from "lucide-react"; import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; -import { formatInteger } from "~/utils/format"; +import { formatCount } from "~/utils/plural"; import { cn } from "~/utils/cn"; +import { useMediaQuery } from "~/utils/use-media-query"; import { DATA_TABLE } from "./data-table.config"; +import { useFittedColumns } from "./data-table.hooks"; import type { DataTableColumn, DataTableProps, SortDirection } from "./data-table.types"; type SortState = { readonly columnId: string; readonly direction: SortDirection } | undefined; @@ -19,7 +21,7 @@ function compare(a: string | number, b: string | number): number { */ export function DataTable({ rows, - columns, + columns: allColumns, getRowKey, onRowClick, initialSort, @@ -29,12 +31,18 @@ export function DataTable({ const [sort, setSort] = useState(initialSort); const scrollRef = useRef(null); const selectAllRef = useRef(null); - const gridTemplate = [ - selection ? DATA_TABLE.selectionColumnWidth : undefined, - ...columns.map((c) => c.width), - ] - .filter(Boolean) - .join(" "); + const touch = useMediaQuery(DATA_TABLE.touchQuery); + // Optional columns hide when the table is too narrow for every column's minimum width. + const { + ref: frameRef, + columns, + gridTemplate, + } = useFittedColumns( + allColumns, + selection !== undefined, + touch ? DATA_TABLE.touchSelectionColumnWidth : DATA_TABLE.selectionColumnWidth, + ); + const gridStyle = { gridTemplateColumns: gridTemplate, columnGap: DATA_TABLE.columnGap }; const selected = selection?.selected; const selectedCount = useMemo( () => (selected ? rows.reduce((n, row) => n + (selected.has(getRowKey(row)) ? 1 : 0), 0) : 0), @@ -49,13 +57,13 @@ export function DataTable({ const order = useMemo(() => { const indices = Array.from(rows.keys()); - const column = columns.find((c) => c.id === sort?.columnId); + const column = allColumns.find((c) => c.id === sort?.columnId); const sortValue = column?.sortValue; if (!sort || !sortValue) return indices; const keys = rows.map(sortValue); const sign = sort.direction === "asc" ? 1 : -1; return indices.sort((a, b) => sign * compare(keys[a] ?? 0, keys[b] ?? 0)); - }, [rows, columns, sort]); + }, [rows, allColumns, sort]); const virtualizer = useVirtualizer({ count: order.length, @@ -123,6 +131,7 @@ export function DataTable({ return (

    ({ role="row" aria-rowindex={1} className="grid shrink-0 border-b px-4 text-xs text-muted-foreground" - style={{ gridTemplateColumns: gridTemplate }} + style={gridStyle} > {selection && (
    -
    - {formatInteger(rows.length)} {DATA_TABLE.rowCountSuffix} + {formatCount(rows.length, DATA_TABLE.rowCount)}
    ); diff --git a/src/components/data-table/data-table.config.ts b/src/components/data-table/data-table.config.ts index bf5e63e..234b07d 100644 --- a/src/components/data-table/data-table.config.ts +++ b/src/components/data-table/data-table.config.ts @@ -4,8 +4,19 @@ export const DATA_TABLE = { sortAscendingLabel: "ascending", sortDescendingLabel: "descending", emptyLabel: "No rows", - rowCountSuffix: "rows", - selectionColumnWidth: "28px", + /** Pluralised row count in the footer. */ + rowCount: { one: "row", other: "rows" }, + /** Checkbox column in px; touch screens get a 44px hit area instead of the 16px box. */ + selectionColumnWidth: 28, + touchSelectionColumnWidth: 44, + touchQuery: "(pointer: coarse), (max-width: 639px)", + checkboxClass: "flex h-9 items-center", + /** The label around the checkbox is the hit area: 44px tall, the column's full width. */ + touchCheckboxClass: "flex h-11 w-full items-center", + /** Gap between columns, so two right-aligned headers never run together. */ + columnGap: 12, + /** The rows' `px-4`, which the fitted columns can't use. */ + horizontalPadding: 32, selectAllLabel: "Select all rows", selectRowLabel: "Select row", keys: { diff --git a/src/components/data-table/data-table.hooks.ts b/src/components/data-table/data-table.hooks.ts new file mode 100644 index 0000000..12adbb0 --- /dev/null +++ b/src/components/data-table/data-table.hooks.ts @@ -0,0 +1,29 @@ +import { useMemo, useRef } from "react"; +import { useElementWidth } from "~/utils/use-element-width"; +import { DATA_TABLE } from "./data-table.config"; +import type { FittableColumn } from "./data-table.types"; +import { columnTrack, fitColumns } from "./data-table.utils"; + +/** + * The columns that fit the table's measured width and their shared grid template. Attach `ref` + * to the table's frame; the header, rows and skeleton all use the same template. + */ +export function useFittedColumns( + columns: readonly C[], + selectable: boolean, + selectionColumnWidth: number = DATA_TABLE.selectionColumnWidth, +) { + const ref = useRef(null); + const width = useElementWidth(ref); + return useMemo(() => { + const reserved = DATA_TABLE.horizontalPadding + (selectable ? selectionColumnWidth : 0); + const fitted = fitColumns(columns, width, reserved); + const gridTemplate = [ + selectable ? `${selectionColumnWidth}px` : undefined, + ...fitted.map(columnTrack), + ] + .filter(Boolean) + .join(" "); + return { ref, columns: fitted, gridTemplate }; + }, [columns, width, selectable, selectionColumnWidth]); +} diff --git a/src/components/data-table/data-table.skeleton.tsx b/src/components/data-table/data-table.skeleton.tsx index 17c9bfb..a9442c3 100644 --- a/src/components/data-table/data-table.skeleton.tsx +++ b/src/components/data-table/data-table.skeleton.tsx @@ -1,27 +1,34 @@ import { Skeleton } from "~/components/skeleton/skeleton.component"; import { cn } from "~/utils/cn"; +import { useMediaQuery } from "~/utils/use-media-query"; import { DATA_TABLE } from "./data-table.config"; +import { useFittedColumns } from "./data-table.hooks"; import type { DataTableSkeletonProps } from "./data-table.types"; /** DataTable's frame: real headers and grid, placeholder rows, clipped to the available height. */ export function DataTableSkeleton({ - columns, + columns: allColumns, rowHeight = DATA_TABLE.rowHeight, rows, - selectable, + selectable = false, }: DataTableSkeletonProps) { - const gridTemplate = [ - selectable ? DATA_TABLE.selectionColumnWidth : undefined, - ...columns.map((c) => c.width), - ] - .filter(Boolean) - .join(" "); + const touch = useMediaQuery(DATA_TABLE.touchQuery); + const { + ref: frameRef, + columns, + gridTemplate, + } = useFittedColumns( + allColumns, + selectable, + touch ? DATA_TABLE.touchSelectionColumnWidth : DATA_TABLE.selectionColumnWidth, + ); + const gridStyle = { gridTemplateColumns: gridTemplate, columnGap: DATA_TABLE.columnGap }; return ( -
    -
    +
    +
    {selectable && (
    @@ -41,7 +48,7 @@ export function DataTableSkeleton({
    {selectable && } {columns.map((column) => ( diff --git a/src/components/data-table/data-table.types.ts b/src/components/data-table/data-table.types.ts index a4d1fd9..863f567 100644 --- a/src/components/data-table/data-table.types.ts +++ b/src/components/data-table/data-table.types.ts @@ -2,11 +2,19 @@ import type { ReactNode } from "react"; export type SortDirection = "asc" | "desc"; -export type DataTableColumn = { - readonly id: string; - readonly header: string; +/** The sizing half of a column, shared by the table and its skeleton. */ +export type FittableColumn = { /** CSS grid track, e.g. "2fr" or "120px". */ readonly width: string; + /** Floor in px: the track never shrinks below it, so values like prices never clip. */ + readonly minWidth?: number; + /** Set on optional columns; when the minimums don't fit, the lowest priority hides first. */ + readonly hidePriority?: number; +}; + +export type DataTableColumn = FittableColumn & { + readonly id: string; + readonly header: string; readonly align?: "left" | "right"; /** Sort key; omit to make the column unsortable. */ readonly sortValue?: (row: Row) => string | number; @@ -31,10 +39,9 @@ export type DataTableProps = { readonly initialSort?: { readonly columnId: string; readonly direction: SortDirection }; }; -export type DataTableSkeletonColumn = { +export type DataTableSkeletonColumn = FittableColumn & { readonly id: string; readonly header: string; - readonly width: string; readonly align?: "left" | "right"; /** Placeholder shown in this column's cell of every row. */ readonly cell: ReactNode; diff --git a/src/components/data-table/data-table.utils.ts b/src/components/data-table/data-table.utils.ts new file mode 100644 index 0000000..11287bd --- /dev/null +++ b/src/components/data-table/data-table.utils.ts @@ -0,0 +1,35 @@ +import { DATA_TABLE } from "./data-table.config"; +import type { FittableColumn } from "./data-table.types"; + +/** A column's grid track: its flexible width, floored at its minimum so its content never clips. */ +export function columnTrack(column: FittableColumn): string { + return column.minWidth === undefined + ? column.width + : `minmax(${column.minWidth}px, ${column.width})`; +} + +function requiredWidth(columns: readonly FittableColumn[], reserved: number): number { + const gaps = Math.max(0, columns.length - 1) * DATA_TABLE.columnGap; + return columns.reduce((sum, column) => sum + (column.minWidth ?? 0), reserved + gaps); +} + +/** + * Drops optional columns (lowest `hidePriority` first) until the rest fit `available` px at + * their minimum widths. Columns without a priority always stay. Unmeasured: every column. + */ +export function fitColumns( + columns: readonly C[], + available: number | undefined, + reserved: number, +): readonly C[] { + if (available === undefined) return columns; + const droppable = columns + .filter((column) => column.hidePriority !== undefined) + .sort((a, b) => (a.hidePriority ?? 0) - (b.hidePriority ?? 0)); + let kept = columns; + for (const column of droppable) { + if (requiredWidth(kept, reserved) <= available) break; + kept = kept.filter((candidate) => candidate !== column); + } + return kept; +} diff --git a/src/components/item-highlights/item-highlights.component.tsx b/src/components/item-highlights/item-highlights.component.tsx index c2e42d4..6b3a448 100644 --- a/src/components/item-highlights/item-highlights.component.tsx +++ b/src/components/item-highlights/item-highlights.component.tsx @@ -15,32 +15,37 @@ import type { export function ItemHighlights({ item, children, visible }: ItemHighlightsProps) { const { reasons } = itemHighlights(item); if (reasons.length === 0 && !children) return null; - // A fixed count (e.g. a narrow grid card) applies at every breakpoint. - const { narrow, wide } = - visible === undefined ? ITEM_HIGHLIGHTS.visibleChips : { narrow: visible, wide: visible }; - const overflow = (from: number) => reasons.slice(from).map((reason) => reason.label); + const { chipVisibility, moreVisibility } = ITEM_HIGHLIGHTS; + // A fixed count (e.g. a grid card) applies at every width; otherwise the line's width decides. + const chipClass = (index: number) => + visible === undefined ? (chipVisibility[index] ?? "hidden") : index < visible ? "" : "hidden"; const more = (from: number, className: string) => reasons.length > from && ( reason.label) + .join(", ")} > {COPY.items.moreHighlights(reasons.length - from)} - {overflow(from).join(", ")} + + {reasons + .slice(from) + .map((reason) => reason.label) + .join(", ")} + ); return ( -
    +
    {reasons.map((reason, index) => { const Icon = ITEM_HIGHLIGHTS.kindIcons[reason.kind]; return ( = narrow && "max-md:hidden", - index >= wide && "hidden", - )} + className={cn(ITEM_HIGHLIGHTS.chipClass, index > 0 && "shrink-0", chipClass(index))} > ); })} - {more(narrow, "md:hidden")} - {more(wide, "max-md:hidden")} + {visible === undefined + ? moreVisibility.map((className, from) => more(from + 1, className)) + : more(visible, "")} {children}
    ); diff --git a/src/components/item-highlights/item-highlights.config.ts b/src/components/item-highlights/item-highlights.config.ts index bf07155..37d2449 100644 --- a/src/components/item-highlights/item-highlights.config.ts +++ b/src/components/item-highlights/item-highlights.config.ts @@ -5,13 +5,23 @@ export const ITEM_HIGHLIGHTS = { iconSize: 11, tierIconSize: 12, tierPrefix: "T", - /** Chips kept on the one line under the name; the rest collapse into a `+N` chip. */ - visibleChips: { narrow: 1, wide: 3 }, - /** Chips may shrink (their label ellipsises) so the first one still fits a narrow column. */ + /** + * How many chips fit is decided by the width of the name column itself (a container query), + * not the viewport: one chip always, a second from 220px, a third from 320px. The rest fold + * into `+N`. Index i of `chipVisibility` styles chip i; chips past the end are always folded. + */ + lineClass: "@container mt-1 flex min-w-0 flex-nowrap items-center gap-1 overflow-hidden", + chipVisibility: ["", "hidden @min-[220px]:inline-flex", "hidden @min-[320px]:inline-flex"], + /** `+N` for the chips folded at each width: index i is the badge shown while i + 1 chips fit. */ + moreVisibility: [ + "@min-[220px]:hidden", + "hidden @min-[220px]:inline-flex @min-[320px]:hidden", + "hidden @min-[320px]:inline-flex", + ], + /** Only the first chip may shrink (its label ellipsises), so it still fits a narrow column. */ chipClass: "inline-flex min-w-0 items-center gap-1 rounded bg-foreground/[0.07] px-1.5 py-px text-[11px] leading-4 text-foreground/80", - /** Phone table rows are narrow, so chips drop their icon there to leave room for the label. */ - chipIconClass: "shrink-0 max-md:hidden", + chipIconClass: "shrink-0", kindIcons: { float: Ruler, pattern: Fingerprint, diff --git a/src/components/item-label/item-label.component.tsx b/src/components/item-label/item-label.component.tsx index 3a4636f..64612b0 100644 --- a/src/components/item-label/item-label.component.tsx +++ b/src/components/item-label/item-label.component.tsx @@ -15,7 +15,8 @@ export function ItemLabel({ return (
    -
    + {/* Fills the row, so the highlight line's container query sees the real space available. */} +
    {weapon && (

    {weapon} diff --git a/src/components/item-table/item-table.component.tsx b/src/components/item-table/item-table.component.tsx index 4597473..636dc07 100644 --- a/src/components/item-table/item-table.component.tsx +++ b/src/components/item-table/item-table.component.tsx @@ -22,11 +22,13 @@ export function ItemTable({ onSelectionChange, }: ItemTableProps) { const wide = useMediaQuery(ITEM_TABLE.wideQuery); + const layout = wide ? "wide" : "narrow"; + const sizes = ITEM_TABLE.columns; const columns = useMemo[]>(() => { const item: DataTableColumn = { id: "item", header: COPY.itemColumns.item, - width: wide ? ITEM_TABLE.widths.item.wide : ITEM_TABLE.widths.item.narrow, + ...sizes.item[layout], sortValue: (r) => r.name, // Narrow rows have no Account column, so the owner moves under the name. cell: (r) => ( @@ -36,7 +38,7 @@ export function ItemTable({ const price: DataTableColumn = { id: "price", header: COPY.itemColumns.price, - width: wide ? ITEM_TABLE.widths.price.wide : ITEM_TABLE.widths.price.narrow, + ...sizes.price[layout], align: "right", sortValue: (r) => r.price ?? -1, cell: (r) => (r.price === null ? COPY.common.empty : formatMoney(r.price, currency)), @@ -45,7 +47,7 @@ export function ItemTable({ const highlights: DataTableColumn = { id: "highlights", header: COPY.items.highlights, - width: wide ? ITEM_TABLE.widths.highlights : ITEM_TABLE.widths.highlightsNarrow, + ...sizes.highlights[layout], align: "right", sortValue: highlightSortValue, cell: (r) => , @@ -56,7 +58,7 @@ export function ItemTable({ { id: "location", header: COPY.itemColumns.location, - width: ITEM_TABLE.widths.location, + ...sizes.location, sortValue: (r) => r.locationName, cell: (r) => {r.locationName}, }, @@ -67,7 +69,7 @@ export function ItemTable({ { id: "account", header: COPY.accounts.column, - width: ITEM_TABLE.widths.account, + ...sizes.account, sortValue: (r) => accountNames.get(r.accountId)?.name ?? "", cell: (r) => { const name = accountNames.get(r.accountId); @@ -83,14 +85,14 @@ export function ItemTable({ { id: "type", header: COPY.itemColumns.type, - width: ITEM_TABLE.widths.type, + ...sizes.type, sortValue: (r) => r.category, cell: (r) => {r.category}, }, { id: "float", header: COPY.itemColumns.float, - width: ITEM_TABLE.widths.float, + ...sizes.float, align: "right", sortValue: (r) => r.wear ?? -1, cell: (r) => (r.wear === null ? COPY.common.empty : formatFloat(r.wear)), @@ -98,7 +100,7 @@ export function ItemTable({ highlights, price, ]; - }, [currency, showLocation, wide, accountNames]); + }, [currency, showLocation, wide, layout, sizes, accountNames]); const selection = useMemo( () => diff --git a/src/components/item-table/item-table.config.ts b/src/components/item-table/item-table.config.ts index 516d0bc..ae099c7 100644 --- a/src/components/item-table/item-table.config.ts +++ b/src/components/item-table/item-table.config.ts @@ -1,19 +1,25 @@ +import type { FittableColumn } from "~/components/data-table/data-table.types"; + export const ITEM_TABLE = { - /** Narrow rows are taller: the item name may wrap to two lines above its highlight chips. */ - /** `narrowWithAccount` adds the owning account's line under the name in the all-accounts view. */ + /** + * Narrow rows are taller: the item name may wrap to two lines above its highlight chips, and + * `narrowWithAccount` adds the owning account's line in the all-accounts view. + */ rowHeight: { wide: 64, narrow: 80, narrowWithAccount: 96 }, wideQuery: "(min-width: 768px)", - /** CSS grid tracks, shared by the table and its loading skeleton. */ - widths: { - item: { wide: "4fr", narrow: "minmax(0, 1fr)" }, - account: "1.3fr", - location: "1.6fr", - type: "1.2fr", - float: "0.9fr", - highlights: "0.7fr", - highlightsNarrow: "84px", - price: { wide: "0.9fr", narrow: "92px" }, - }, + /** + * Grid tracks, shared by the table and its loading skeleton. Minimums keep prices and floats + * whole; when the table can't fit them all, Type hides first, then Location, then Account. + */ + columns: { + item: { wide: { width: "4fr", minWidth: 220 }, narrow: { width: "minmax(0, 1fr)" } }, + account: { width: "1.3fr", minWidth: 120, hidePriority: 3 }, + location: { width: "1.6fr", minWidth: 96, hidePriority: 2 }, + type: { width: "1.2fr", minWidth: 80, hidePriority: 1 }, + float: { width: "0.9fr", minWidth: 64 }, + highlights: { wide: { width: "0.7fr", minWidth: 76 }, narrow: { width: "84px" } }, + price: { wide: { width: "0.9fr", minWidth: 96 }, narrow: { width: "92px" } }, + } satisfies Record, /** Enough rows to fill a tall screen; the rest are clipped. */ skeletonRows: 16, } as const; diff --git a/src/components/item-table/item-table.skeleton.tsx b/src/components/item-table/item-table.skeleton.tsx index 7b83a40..23037b4 100644 --- a/src/components/item-table/item-table.skeleton.tsx +++ b/src/components/item-table/item-table.skeleton.tsx @@ -10,13 +10,18 @@ import type { ItemTableSkeletonProps } from "./item-table.types"; const textBar = (width: string) => ; /** ItemTable's frame (same columns at the same breakpoints) with placeholder rows. */ -export function ItemTableSkeleton({ showLocation = true, selectable }: ItemTableSkeletonProps) { +export function ItemTableSkeleton({ + showLocation = true, + showAccount = false, + selectable, +}: ItemTableSkeletonProps) { const wide = useMediaQuery(ITEM_TABLE.wideQuery); - const { widths } = ITEM_TABLE; + const layout = wide ? "wide" : "narrow"; + const sizes = ITEM_TABLE.columns; const item: DataTableSkeletonColumn = { id: "item", header: COPY.itemColumns.item, - width: wide ? widths.item.wide : widths.item.narrow, + ...sizes.item[layout], cell: (

    {data.status === "loading" && ( - + )} {data.status === "error" && } diff --git a/src/utils/use-element-width.ts b/src/utils/use-element-width.ts new file mode 100644 index 0000000..0a00d72 --- /dev/null +++ b/src/utils/use-element-width.ts @@ -0,0 +1,20 @@ +import { useLayoutEffect, useState, type RefObject } from "react"; + +/** + * The element's content-box width in px, kept current with a ResizeObserver. It is `undefined` + * until measured after mount, so a server render and the first client render agree. + */ +export function useElementWidth(ref: RefObject): number | undefined { + const [width, setWidth] = useState(); + useLayoutEffect(() => { + const element = ref.current; + if (!element) return; + setWidth(element.clientWidth); + const observer = new ResizeObserver(([entry]) => { + if (entry) setWidth(entry.contentRect.width); + }); + observer.observe(element); + return () => observer.disconnect(); + }, [ref]); + return width; +} From b234f84634357c1a24f5e07520f101174e19b800 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:45:39 +1000 Subject: [PATCH 026/115] =?UTF-8?q?fix(a11y):=20=F0=9F=A9=B9=20hide=20the?= =?UTF-8?q?=20decorative=20table=20skeleton=20from=20assistive=20tech?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/data-table/data-table.skeleton.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/data-table/data-table.skeleton.tsx b/src/components/data-table/data-table.skeleton.tsx index a9442c3..ab7094b 100644 --- a/src/components/data-table/data-table.skeleton.tsx +++ b/src/components/data-table/data-table.skeleton.tsx @@ -5,7 +5,10 @@ import { DATA_TABLE } from "./data-table.config"; import { useFittedColumns } from "./data-table.hooks"; import type { DataTableSkeletonProps } from "./data-table.types"; -/** DataTable's frame: real headers and grid, placeholder rows, clipped to the available height. */ +/** + * DataTable's frame: real headers and grid, placeholder rows, clipped to the available height. + * It is decorative (hidden from assistive tech); the surrounding LoadingRegion announces loading. + */ export function DataTableSkeleton({ columns: allColumns, rowHeight = DATA_TABLE.rowHeight, @@ -26,6 +29,7 @@ export function DataTableSkeleton({ return (
    @@ -43,7 +47,7 @@ export function DataTableSkeleton({
    ))}
    -
    +
    {Array.from({ length: rows }, (_, row) => (
    Date: Sun, 8 Nov 2026 13:48:20 +1000 Subject: [PATCH 027/115] =?UTF-8?q?feat(items):=20=E2=9C=A8=20open=20every?= =?UTF-8?q?=20highlight=20from=20the=20tier=20badge,=20and=20fit=20phone?= =?UTF-8?q?=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phone rows drop the Highlights column: the tier and a reason count sit under the name and open a panel listing every reason, the pattern seed, exact float and attachments. The desktop Highlights cell opens the same panel. It is portalled out of the virtual rows, takes focus on open and returns it on Escape or Tab. --- .../highlight-disclosure.component.tsx | 208 ++++++++++++++++++ .../highlight-disclosure.config.ts | 35 +++ .../highlight-disclosure.types.ts | 16 ++ .../item-cell/item-cell.component.tsx | 102 +++++---- src/components/item-cell/item-cell.config.ts | 1 + src/components/item-cell/item-cell.types.ts | 2 + .../item-highlights.component.tsx | 34 +-- .../item-highlights/item-highlights.types.ts | 4 +- .../item-table/item-table.component.tsx | 20 +- .../item-table/item-table.config.ts | 4 +- .../item-table/item-table.skeleton.tsx | 4 +- src/lib/copy/copy.config.ts | 1 - 12 files changed, 343 insertions(+), 88 deletions(-) create mode 100644 src/components/highlight-disclosure/highlight-disclosure.component.tsx create mode 100644 src/components/highlight-disclosure/highlight-disclosure.config.ts create mode 100644 src/components/highlight-disclosure/highlight-disclosure.types.ts diff --git a/src/components/highlight-disclosure/highlight-disclosure.component.tsx b/src/components/highlight-disclosure/highlight-disclosure.component.tsx new file mode 100644 index 0000000..92cdfa6 --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.component.tsx @@ -0,0 +1,208 @@ +import { useEffect, useId, useRef, useState, type SyntheticEvent } from "react"; +import { createPortal } from "react-dom"; +import { ItemTierBadge } from "~/components/item-highlights/item-highlights.component"; +import { ITEM_HIGHLIGHTS } from "~/components/item-highlights/item-highlights.config"; +import { COPY } from "~/lib/copy/copy.config"; +import { itemHighlights } from "~/lib/items/highlights"; +import { HIGHLIGHT_DISCLOSURE } from "./highlight-disclosure.config"; +import type { + HighlightDisclosurePlacement, + HighlightDisclosureProps, +} from "./highlight-disclosure.types"; + +/** Right-aligns the panel to its trigger, below it or (when cramped) above, inside the viewport. */ +function placementFor(trigger: HTMLElement): HighlightDisclosurePlacement { + const rect = trigger.getBoundingClientRect(); + const { width: preferred, viewportMargin: margin, gap, minRoomBelow } = HIGHLIGHT_DISCLOSURE; + const width = Math.min(preferred, window.innerWidth - margin * 2); + const left = Math.min(Math.max(margin, rect.right - width), window.innerWidth - width - margin); + const below = window.innerHeight - rect.bottom - gap - margin; + const above = rect.top - gap - margin; + return below < minRoomBelow && above > below + ? { left, width, bottom: window.innerHeight - rect.top + gap, maxHeight: above } + : { left, width, top: rect.bottom + gap, maxHeight: below }; +} + +/** Rows toggle selection on click; nothing inside the disclosure should reach them. */ +const stop = (event: SyntheticEvent) => event.stopPropagation(); + +/** + * The item's tier (or a highlight marker) as a button that opens every highlight reason with + * its seed, exact float and attachments. The panel is portalled out of the virtualised row, so + * focus moves into it on open and back to the button on Escape or Tab. + */ +export function HighlightDisclosure({ item, showCount = false }: HighlightDisclosureProps) { + const { tier, reasons } = itemHighlights(item); + const trigger = useRef(null); + const panel = useRef(null); + const [placement, setPlacement] = useState(); + const panelId = useId(); + const titleId = useId(); + const open = placement !== undefined; + const copy = HIGHLIGHT_DISCLOSURE; + + useEffect(() => { + if (!open) return; + panel.current?.focus(); + const close = () => setPlacement(undefined); + const onPointerDown = (event: PointerEvent) => { + const target = event.target as Node; + if (!panel.current?.contains(target) && !trigger.current?.contains(target)) close(); + }; + // The trigger scrolls with its row (or the page), which would leave the panel behind. + const onScroll = (event: Event) => { + if (!(event.target instanceof Node && panel.current?.contains(event.target))) close(); + }; + document.addEventListener("pointerdown", onPointerDown); + window.addEventListener("scroll", onScroll, true); + window.addEventListener("resize", close); + return () => { + document.removeEventListener("pointerdown", onPointerDown); + window.removeEventListener("scroll", onScroll, true); + window.removeEventListener("resize", close); + }; + }, [open]); + + if (tier === null && reasons.length === 0) return null; + + function hide(refocus: boolean) { + setPlacement(undefined); + if (refocus) trigger.current?.focus(); + } + + const MarkerIcon = ITEM_HIGHLIGHTS.markerIcon; + const CountIcon = copy.countIcon; + const CloseIcon = copy.closeIcon; + const attachments = [ + { + label: copy.details.stickers, + names: item.stickers.map((sticker) => sticker.name.replace(copy.stickerPrefix, "")), + }, + { + label: copy.details.charms, + names: item.charms.map((charm) => charm.name.replace(copy.charmPrefix, "")), + }, + ].filter(({ names }) => names.length > 0); + + return ( + <> + + {open && + createPortal( +
    { + stop(event); + if (event.key === copy.keys.close) { + event.preventDefault(); + hide(true); + } else if (event.key === copy.keys.leave) { + // Hand focus back to the button and let Tab carry on from there, in page order. + hide(true); + } + }} + > +
    +
    +

    + {item.name} +

    + {tier !== null && ( +

    {COPY.items.tierDescription(tier)}

    + )} +
    + +
    + {reasons.length > 0 && ( +
    +

    {copy.reasonsHeading}

    +
      + {reasons.map((reason) => { + const Icon = ITEM_HIGHLIGHTS.kindIcons[reason.kind]; + return ( +
    • + + {reason.label} +
    • + ); + })} +
    +
    + )} +
    + {item.paintSeed != null && ( + <> +
    {copy.details.seed}
    +
    {copy.seedValue(item.paintSeed)}
    + + )} + {item.wear !== null && ( + <> +
    {copy.details.float}
    +
    {item.wear.toFixed(copy.floatDigits)}
    + + )} + {attachments.map(({ label, names }) => ( +
    +
    {label}
    +
    +
      + {names.map((name, index) => ( +
    • {name}
    • + ))} +
    +
    +
    + ))} +
    +
    , + document.body, + )} + + ); +} diff --git a/src/components/highlight-disclosure/highlight-disclosure.config.ts b/src/components/highlight-disclosure/highlight-disclosure.config.ts new file mode 100644 index 0000000..ae2c5dd --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.config.ts @@ -0,0 +1,35 @@ +import { Sparkle, X } from "lucide-react"; + +export const HIGHLIGHT_DISCLOSURE = { + /** Panel width in px, narrowed to fit the viewport on phones. */ + width: 288, + viewportMargin: 8, + gap: 6, + /** Below this much room under the trigger (px), the panel opens upwards when there is more above. */ + minRoomBelow: 220, + keys: { close: "Escape", leave: "Tab" }, + countIcon: Sparkle, + closeIcon: X, + iconSize: 12, + closeIconSize: 14, + /** Digits shown for the exact float in the panel; the table rounds to four. */ + floatDigits: 9, + triggerLabel: (name: string, count: number) => + `Highlights for ${name}: ${count} ${count === 1 ? "reason" : "reasons"}`, + close: "Close highlights", + reasonsHeading: "Why it stands out", + details: { seed: "Pattern seed", float: "Float", stickers: "Stickers", charms: "Charms" }, + seedValue: (seed: number) => `#${seed}`, + /** The row already says what kind of attachment each list holds, so the name's prefix is dropped. */ + stickerPrefix: "Sticker | ", + charmPrefix: "Charm | ", + listSeparator: ", ", + triggerClass: + "inline-flex shrink-0 items-center gap-1 rounded-md p-0.5 text-muted-foreground outline-offset-1 hover:bg-muted aria-expanded:bg-muted max-md:min-h-7", + countClass: + "tabular inline-flex items-center gap-0.5 rounded bg-foreground/[0.07] px-1.5 py-px text-[11px] leading-4 text-foreground/80", + panelClass: + "fixed z-50 flex flex-col gap-2 overflow-y-auto rounded-md border bg-card p-3 text-xs text-foreground shadow-lg outline-none", + closeClass: + "-mr-1 -mt-1 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", +} as const; diff --git a/src/components/highlight-disclosure/highlight-disclosure.types.ts b/src/components/highlight-disclosure/highlight-disclosure.types.ts new file mode 100644 index 0000000..e5f70fa --- /dev/null +++ b/src/components/highlight-disclosure/highlight-disclosure.types.ts @@ -0,0 +1,16 @@ +import type { ItemView } from "~/lib/items/item.types"; + +export type HighlightDisclosureProps = { + readonly item: ItemView; + /** Adds the number of reasons beside the tier badge, for rows with no room for chips. */ + readonly showCount?: boolean; +}; + +/** Where the open panel sits, in viewport pixels (it is `position: fixed`). */ +export type HighlightDisclosurePlacement = { + readonly left: number; + readonly width: number; + readonly top?: number; + readonly bottom?: number; + readonly maxHeight: number; +}; diff --git a/src/components/item-cell/item-cell.component.tsx b/src/components/item-cell/item-cell.component.tsx index cfbc2c2..f4aa459 100644 --- a/src/components/item-cell/item-cell.component.tsx +++ b/src/components/item-cell/item-cell.component.tsx @@ -7,16 +7,62 @@ import { INVENTORY_LOCATION_ID } from "~/lib/items/item.types"; import { ItemLabel } from "~/components/item-label/item-label.component"; import { ItemHighlights } from "~/components/item-highlights/item-highlights.component"; import { AccountName } from "~/components/account-name/account-name.component"; +import { HighlightDisclosure } from "~/components/highlight-disclosure/highlight-disclosure.component"; +import { itemHighlights } from "~/lib/items/highlights"; import { ITEM_CELL } from "./item-cell.config"; import type { ItemCellProps } from "./item-cell.types"; -export function ItemCell({ item, account }: ItemCellProps) { +export function ItemCell({ item, account, compact = false }: ItemCellProps) { const { imageSize, stickerSize, charmSize, maxStickers, patternLabel } = ITEM_CELL; // A sticker without an image would render as a broken-image glyph; its name is in the item data. const stickers = item.stickers .filter((sticker) => sticker.imageUrl !== null) .slice(0, maxStickers); const hasAttachments = stickers.length > 0 || item.charms.length > 0; + const { tier, reasons } = itemHighlights(item); + const hasHighlights = tier !== null || reasons.length > 0; + const attachments = hasAttachments && ( + + {stickers.map((sticker, index) => ( + {sticker.name} + ))} + {item.charms.map((charm, index) => ( + + {charm.imageUrl !== null && ( + {charm.name} + )} + {charm.pattern !== null && #{charm.pattern}} + {charm.videoUrl !== null && ( + + )} + + ))} + + ); return ( } {/* Reasons and attachments share one line, so the row keeps its fixed height. */} - - {hasAttachments && ( - - {stickers.map((sticker, index) => ( - {sticker.name} - ))} - {item.charms.map((charm, index) => ( - - {charm.imageUrl !== null && ( - {charm.name} - )} - {charm.pattern !== null && #{charm.pattern}} - {charm.videoUrl !== null && ( - - )} - - ))} - - )} - + {compact ? ( + (hasAttachments || hasHighlights) && ( +
    + + {attachments} +
    + ) + ) : ( + {attachments} + )}
    ); } diff --git a/src/components/item-cell/item-cell.config.ts b/src/components/item-cell/item-cell.config.ts index 6e1e895..d7a5c04 100644 --- a/src/components/item-cell/item-cell.config.ts +++ b/src/components/item-cell/item-cell.config.ts @@ -4,5 +4,6 @@ export const ITEM_CELL = { charmSize: 18, maxStickers: 5, patternLabel: "Pattern", + compactLineClass: "mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden", accountClass: "mt-0.5 text-xs leading-4 text-muted-foreground", } as const; diff --git a/src/components/item-cell/item-cell.types.ts b/src/components/item-cell/item-cell.types.ts index c1569a9..f865daf 100644 --- a/src/components/item-cell/item-cell.types.ts +++ b/src/components/item-cell/item-cell.types.ts @@ -5,4 +5,6 @@ export type ItemCellProps = { readonly item: ItemView; /** The holding account, as a line under the name; for narrow rows, where there is no Account column. */ readonly account?: AccountDisplayName; + /** Narrow rows: chips don't fit, so the tier and a count open the full highlight list instead. */ + readonly compact?: boolean; }; diff --git a/src/components/item-highlights/item-highlights.component.tsx b/src/components/item-highlights/item-highlights.component.tsx index 6b3a448..d3f74df 100644 --- a/src/components/item-highlights/item-highlights.component.tsx +++ b/src/components/item-highlights/item-highlights.component.tsx @@ -2,11 +2,7 @@ import { itemHighlights } from "~/lib/items/highlights"; import { COPY } from "~/lib/copy/copy.config"; import { cn } from "~/utils/cn"; import { ITEM_HIGHLIGHTS } from "./item-highlights.config"; -import type { - ItemHighlightCellProps, - ItemHighlightsProps, - ItemTierBadgeProps, -} from "./item-highlights.types"; +import type { ItemHighlightsProps, ItemTierBadgeProps } from "./item-highlights.types"; /** * Reason chips on one line under the item name, so a row never grows past its fixed height. @@ -65,16 +61,17 @@ export function ItemHighlights({ item, children, visible }: ItemHighlightsProps) } /** Gem icon and "T1".."TX"; the most valuable tier is accented and lower tiers fade. */ -export function ItemTierBadge({ item, className }: ItemTierBadgeProps) { +export function ItemTierBadge({ item, className, decorative = false }: ItemTierBadgeProps) { const { tier } = itemHighlights(item); if (tier === null) return null; const Icon = ITEM_HIGHLIGHTS.tierIcon; const description = COPY.items.tierDescription(tier); return ( ); } - -/** The Highlights column: the tier badge, or a quiet marker for float- or craft-only highlights. */ -export function ItemHighlightCell({ item }: ItemHighlightCellProps) { - const { tier, reasons } = itemHighlights(item); - if (tier !== null) return ; - if (reasons.length === 0) return null; - const Icon = ITEM_HIGHLIGHTS.markerIcon; - const description = COPY.items.highlightMarker(reasons.map((reason) => reason.label)); - return ( - - - - ); -} diff --git a/src/components/item-highlights/item-highlights.types.ts b/src/components/item-highlights/item-highlights.types.ts index 601e878..3390805 100644 --- a/src/components/item-highlights/item-highlights.types.ts +++ b/src/components/item-highlights/item-highlights.types.ts @@ -12,6 +12,6 @@ export type ItemHighlightsProps = { export type ItemTierBadgeProps = { readonly item: ItemView; readonly className?: string; + /** Inside a control that already names the tier, so the badge is visual only. */ + readonly decorative?: boolean; }; - -export type ItemHighlightCellProps = { readonly item: ItemView }; diff --git a/src/components/item-table/item-table.component.tsx b/src/components/item-table/item-table.component.tsx index 636dc07..35996ef 100644 --- a/src/components/item-table/item-table.component.tsx +++ b/src/components/item-table/item-table.component.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { AccountName } from "~/components/account-name/account-name.component"; import { DataTable } from "~/components/data-table/data-table.component"; import type { DataTableColumn } from "~/components/data-table/data-table.types"; -import { ItemHighlightCell } from "~/components/item-highlights/item-highlights.component"; +import { HighlightDisclosure } from "~/components/highlight-disclosure/highlight-disclosure.component"; import { ItemCell } from "~/components/item-cell/item-cell.component"; import { COPY } from "~/lib/copy/copy.config"; import { itemRowKey } from "~/lib/items/item-key"; @@ -31,9 +31,12 @@ export function ItemTable({ ...sizes.item[layout], sortValue: (r) => r.name, // Narrow rows have no Account column, so the owner moves under the name. - cell: (r) => ( - - ), + cell: (r) => + wide ? ( + + ) : ( + + ), }; const price: DataTableColumn = { id: "price", @@ -43,16 +46,17 @@ export function ItemTable({ sortValue: (r) => r.price ?? -1, cell: (r) => (r.price === null ? COPY.common.empty : formatMoney(r.price, currency)), }; - // Orders rows T1 first, then other highlights, then by price; the cell shows the tier or a marker. + // Orders rows T1 first, then other highlights, then by price; the cell's tier (or marker) + // opens every reason. Narrow rows show that button under the name instead of a column. const highlights: DataTableColumn = { id: "highlights", header: COPY.items.highlights, - ...sizes.highlights[layout], + ...sizes.highlights, align: "right", sortValue: highlightSortValue, - cell: (r) => , + cell: (r) => , }; - if (!wide) return [item, highlights, price]; + if (!wide) return [item, price]; const location: DataTableColumn[] = showLocation ? [ { diff --git a/src/components/item-table/item-table.config.ts b/src/components/item-table/item-table.config.ts index ae099c7..0a44281 100644 --- a/src/components/item-table/item-table.config.ts +++ b/src/components/item-table/item-table.config.ts @@ -2,7 +2,7 @@ import type { FittableColumn } from "~/components/data-table/data-table.types"; export const ITEM_TABLE = { /** - * Narrow rows are taller: the item name may wrap to two lines above its highlight chips, and + * Narrow rows are taller: the item name may wrap to two lines above the highlight button, and * `narrowWithAccount` adds the owning account's line in the all-accounts view. */ rowHeight: { wide: 64, narrow: 80, narrowWithAccount: 96 }, @@ -17,7 +17,7 @@ export const ITEM_TABLE = { location: { width: "1.6fr", minWidth: 96, hidePriority: 2 }, type: { width: "1.2fr", minWidth: 80, hidePriority: 1 }, float: { width: "0.9fr", minWidth: 64 }, - highlights: { wide: { width: "0.7fr", minWidth: 76 }, narrow: { width: "84px" } }, + highlights: { width: "0.7fr", minWidth: 76 }, price: { wide: { width: "0.9fr", minWidth: 96 }, narrow: { width: "92px" } }, } satisfies Record, /** Enough rows to fill a tall screen; the rest are clipped. */ diff --git a/src/components/item-table/item-table.skeleton.tsx b/src/components/item-table/item-table.skeleton.tsx index 23037b4..10b10af 100644 --- a/src/components/item-table/item-table.skeleton.tsx +++ b/src/components/item-table/item-table.skeleton.tsx @@ -45,7 +45,7 @@ export function ItemTableSkeleton({ const highlights: DataTableSkeletonColumn = { id: "highlights", header: COPY.items.highlights, - ...sizes.highlights[layout], + ...sizes.highlights, align: "right", cell: null, }; @@ -83,7 +83,7 @@ export function ItemTableSkeleton({ highlights, price, ] - : [item, highlights, price]; + : [item, price]; return ( `+${count}`, - highlightMarker: (reasons: readonly string[]) => `Highlighted: ${reasons.join(", ")}`, accountFilterLabel: "Filter by account", anyAccount: "Any account", clearFilters: "Clear filters", From 3a2acf1cc28d5974577f6b5cf52d15d1f64756cd Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:52:11 +1000 Subject: [PATCH 028/115] =?UTF-8?q?fix(storage):=20=F0=9F=9A=91=EF=B8=8F?= =?UTF-8?q?=20flag=20stale=20data=20and=20dropped=20streams=20with=20a=20R?= =?UTF-8?q?etry=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failed revalidations keep the last good data but now report it, and a closed event stream is re-opened with backoff instead of going quiet. The banner says how old the screen is and Retry refetches without blanking it. --- .../app-shell/app-shell.component.tsx | 2 + .../stale-banner/stale-banner.component.tsx | 71 +++++++++++ .../stale-banner/stale-banner.config.ts | 13 ++ .../stale-banner/stale-banner.types.ts | 6 + src/lib/storage/stale-data.store.ts | 42 +++++++ src/lib/storage/storage.client.ts | 113 ++++++++++++++---- src/lib/storage/storage.config.ts | 10 ++ src/lib/storage/storage.types.ts | 18 +++ 8 files changed, 249 insertions(+), 26 deletions(-) create mode 100644 src/components/stale-banner/stale-banner.component.tsx create mode 100644 src/components/stale-banner/stale-banner.config.ts create mode 100644 src/components/stale-banner/stale-banner.types.ts create mode 100644 src/lib/storage/stale-data.store.ts diff --git a/src/components/app-shell/app-shell.component.tsx b/src/components/app-shell/app-shell.component.tsx index 87d9068..56fb04e 100644 --- a/src/components/app-shell/app-shell.component.tsx +++ b/src/components/app-shell/app-shell.component.tsx @@ -16,6 +16,7 @@ import { AuthGate } from "~/components/auth-gate/auth-gate.component"; import { InventoryPrefetch } from "~/components/inventory-prefetch/inventory-prefetch.component"; import { LoginOutcome } from "~/components/login-outcome/login-outcome.component"; import { Logo } from "~/components/logo/logo.component"; +import { StaleBanner } from "~/components/stale-banner/stale-banner.component"; import { SyncError } from "~/components/sync-error/sync-error.component"; import { TransferStatus } from "~/components/transfer-status/transfer-status.component"; import { COPY } from "~/lib/copy/copy.config"; @@ -76,6 +77,7 @@ export function AppShell() {
    + diff --git a/src/components/stale-banner/stale-banner.component.tsx b/src/components/stale-banner/stale-banner.component.tsx new file mode 100644 index 0000000..347891c --- /dev/null +++ b/src/components/stale-banner/stale-banner.component.tsx @@ -0,0 +1,71 @@ +import { RefreshCw } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "~/components/button/button.component"; +import { useStaleData } from "~/lib/storage/stale-data.store"; +import { retrySnapshotConnection } from "~/lib/storage/storage.client"; +import type { StaleDataState } from "~/lib/storage/storage.types"; +import { formatDateTime, formatRelativeTime } from "~/utils/format"; +import { RELATIVE_TIME } from "~/utils/format.config"; +import { STALE_BANNER } from "./stale-banner.config"; +import type { StaleSummary } from "./stale-banner.types"; + +function summarize({ reconnecting, lastSnapshotAt, staleLoads }: StaleDataState): StaleSummary { + const times = [ + ...(reconnecting && lastSnapshotAt !== null ? [lastSnapshotAt] : []), + ...staleLoads.map((load) => load.lastSuccessAt), + ]; + return { + message: reconnecting ? STALE_BANNER.reconnecting : STALE_BANNER.refreshFailed, + since: times.length > 0 ? Math.min(...times) : null, + }; +} + +/** + * Says when the screen shows old data: the live stream dropped, or a refresh failed while the + * last good data stays up. Retry reconnects and refetches at once without blanking anything. + */ +export function StaleBanner() { + const stale = useStaleData(); + const [now, setNow] = useState(() => Date.now()); + const visible = stale.reconnecting || stale.staleLoads.length > 0; + + useEffect(() => { + if (!visible) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), RELATIVE_TIME.refreshMs); + return () => clearInterval(timer); + }, [visible]); + + const { message, since } = summarize(stale); + const Icon = STALE_BANNER.icon; + + function retry() { + if (stale.reconnecting) retrySnapshotConnection(); + for (const load of stale.staleLoads) load.refresh(); + } + + return ( + <> + {/* Always mounted, so losing the connection is announced politely. */} +

    + {visible ? message : ""} +

    + {visible && ( +
    +

    + + + {message} + {since !== null && + ` ${STALE_BANNER.showingSince(formatDateTime(since), formatRelativeTime(since, now))}`} + +

    + +
    + )} + + ); +} diff --git a/src/components/stale-banner/stale-banner.config.ts b/src/components/stale-banner/stale-banner.config.ts new file mode 100644 index 0000000..2b505bf --- /dev/null +++ b/src/components/stale-banner/stale-banner.config.ts @@ -0,0 +1,13 @@ +import { CloudOff } from "lucide-react"; + +export const STALE_BANNER = { + icon: CloudOff, + iconSize: 14, + reconnecting: "Lost the live connection to the server. Reconnecting…", + refreshFailed: "Couldn't refresh this page.", + /** Says how old the data on screen is, e.g. "Showing data from 2:04 PM (5 minutes ago)." */ + showingSince: (time: string, relative: string) => `Showing data from ${time} (${relative}).`, + retry: "Retry now", + rootClass: + "flex shrink-0 flex-wrap items-center justify-between gap-x-3 gap-y-2 rounded-md border border-warning/40 bg-warning/5 px-3 py-2 text-xs", +} as const; diff --git a/src/components/stale-banner/stale-banner.types.ts b/src/components/stale-banner/stale-banner.types.ts new file mode 100644 index 0000000..985af7a --- /dev/null +++ b/src/components/stale-banner/stale-banner.types.ts @@ -0,0 +1,6 @@ +/** What the banner says: why the screen may be out of date, and since when. */ +export type StaleSummary = { + readonly message: string; + /** When the oldest data on screen arrived (epoch ms), or null when unknown. */ + readonly since: number | null; +}; diff --git a/src/lib/storage/stale-data.store.ts b/src/lib/storage/stale-data.store.ts new file mode 100644 index 0000000..2afb71f --- /dev/null +++ b/src/lib/storage/stale-data.store.ts @@ -0,0 +1,42 @@ +import { useSyncExternalStore } from "react"; +import type { StaleDataState, StaleLoad } from "./storage.types"; + +// Failed refreshes and dropped live streams, kept apart from the snapshot store so the banner +// that reads them re-renders on its own. Pages keep showing their last good data meanwhile. + +const loads = new Map(); +const listeners = new Set<() => void>(); +let state: StaleDataState = { reconnecting: false, lastSnapshotAt: null, staleLoads: [] }; + +function update(next: Partial): void { + state = { ...state, ...next }; + for (const listener of listeners) listener(); +} + +/** Records that `key`'s refresh failed while its earlier data stays on screen. */ +export function markStale(key: symbol, load: StaleLoad): void { + loads.set(key, load); + update({ staleLoads: [...loads.values()] }); +} + +/** `key` loaded fresh data again (or went away), so it no longer counts as stale. */ +export function clearStale(key: symbol): void { + if (!loads.delete(key)) return; + update({ staleLoads: [...loads.values()] }); +} + +/** Tracks the live stream for the banner: dropped after data arrived, and when data last arrived. */ +export function setStreamState(reconnecting: boolean, lastSnapshotAt: number | null): void { + if (state.reconnecting === reconnecting && state.lastSnapshotAt === lastSnapshotAt) return; + update({ reconnecting, lastSnapshotAt }); +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** Whether anything on screen is older than it should be, and since when. */ +export function useStaleData(): StaleDataState { + return useSyncExternalStore(subscribe, () => state); +} diff --git a/src/lib/storage/storage.client.ts b/src/lib/storage/storage.client.ts index 61e23a6..8d3dc85 100644 --- a/src/lib/storage/storage.client.ts +++ b/src/lib/storage/storage.client.ts @@ -2,12 +2,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore import { resolveScope } from "../accounts/account-scope"; import { useAccountSelection } from "../accounts/account-scope.store"; import { ALL_ACCOUNTS } from "../accounts/accounts.config"; +import { clearStale, markStale, setStreamState } from "./stale-data.store"; import { AUTH_STATUS, REMOTE_DATA, REMOTE_STATUS, SNAPSHOT_CONNECTION, STORAGE_API, + STREAM_RECONNECT, } from "./storage.config"; import type { ApiError, @@ -38,6 +40,10 @@ let snapshot: AppSnapshot = NO_ACCOUNTS; let received = false; let connection: SnapshotConnection = SNAPSHOT_CONNECTION.connecting; let source: EventSource | undefined; +/** When the last snapshot arrived, so a dropped stream can say how old the screen is. */ +let lastSnapshotAt: number | null = null; +let reconnectTimer: ReturnType | undefined; +let reconnectAttempts = 0; const listeners = new Set<() => void>(); function notify(): void { @@ -52,9 +58,23 @@ function publish(next: AppSnapshot): void { function setConnection(next: SnapshotConnection): void { if (connection === next) return; connection = next; + setStreamState(next === SNAPSHOT_CONNECTION.reconnecting, lastSnapshotAt); notify(); } +/** Re-opens a stream the browser gave up on, waiting longer after each failed attempt. */ +function scheduleReconnect(): void { + clearTimeout(reconnectTimer); + const { delaysMs } = STREAM_RECONNECT; + const wait = delaysMs[Math.min(reconnectAttempts, delaysMs.length - 1)] ?? delaysMs[0]; + reconnectAttempts += 1; + reconnectTimer = setTimeout(() => { + if (listeners.size === 0) return; + source?.close(); + openSource(); + }, wait); +} + function parseSnapshot(raw: string): AppSnapshot | undefined { try { const value: unknown = JSON.parse(raw); @@ -84,14 +104,25 @@ function openSource(): void { return; } received = true; + lastSnapshotAt = Date.now(); + reconnectAttempts = 0; + clearTimeout(reconnectTimer); if (next.accounts.length === 0) remoteCache.clear(); connection = SNAPSHOT_CONNECTION.open; + setStreamState(false, lastSnapshotAt); publish(next); }; opened.onerror = () => { - // EventSource retries dropped streams itself, but gives up for good on a bad response. - // Once data has arrived the last snapshot stays usable, so only the first load reports it. - if (!received) setConnection(SNAPSHOT_CONNECTION.disconnected); + // Before any data, the first-load screen reports the failure and offers Retry. + if (!received) { + setConnection(SNAPSHOT_CONNECTION.disconnected); + return; + } + // After that the last snapshot stays on screen under a "reconnecting" banner. EventSource + // retries a dropped stream itself but gives up for good on a bad response (e.g. a proxy + // 502), so a closed stream is re-opened here. + setConnection(SNAPSHOT_CONNECTION.reconnecting); + if (opened.readyState === EventSource.CLOSED) scheduleReconnect(); }; } @@ -101,6 +132,7 @@ function subscribe(listener: () => void): () => void { return () => { listeners.delete(listener); if (listeners.size === 0) { + clearTimeout(reconnectTimer); source?.close(); source = undefined; } @@ -119,8 +151,10 @@ export function useSnapshotConnection(): SnapshotConnection { /** Drops the current event stream and opens a new one (a closed EventSource never reconnects by itself). */ export function retrySnapshotConnection(): void { + clearTimeout(reconnectTimer); + reconnectAttempts = 0; source?.close(); - setConnection(SNAPSHOT_CONNECTION.connecting); + setConnection(received ? SNAPSHOT_CONNECTION.reconnecting : SNAPSHOT_CONNECTION.connecting); if (listeners.size > 0) openSource(); else source = undefined; } @@ -206,22 +240,27 @@ function scopeOf(path: string | undefined): string | null { return query === undefined ? null : new URLSearchParams(query).get(REMOTE_DATA.scopeParam); } -/** Last good response per path, so revisiting a page shows data instantly while it refetches. */ -const remoteCache = new Map(); +/** Last good response per path and when it arrived, so revisiting a page shows data instantly while it refetches. */ +const remoteCache = new Map(); function isCacheable(path: string): boolean { return !REMOTE_DATA.uncachedPathPrefixes.some((prefix) => path.startsWith(prefix)); } function readCache(path: string | undefined): StoredRemoteData | undefined { - if (!path || !remoteCache.has(path)) return undefined; - return { status: REMOTE_STATUS.ready, data: remoteCache.get(path) as T }; + const entry = path === undefined ? undefined : remoteCache.get(path); + return entry && { status: REMOTE_STATUS.ready, data: entry.data as T }; } -function writeCache(path: string, data: unknown): void { +/** When `path`'s cached response arrived, if it has one. */ +function cachedAt(path: string | undefined): number | undefined { + return path === undefined ? undefined : remoteCache.get(path)?.at; +} + +function writeCache(path: string, data: unknown, at: number): void { if (!isCacheable(path)) return; remoteCache.delete(path); - remoteCache.set(path, data); + remoteCache.set(path, { data, at }); while (remoteCache.size > REMOTE_DATA.cacheMaxEntries) { const oldest = remoteCache.keys().next(); if (oldest.done) break; @@ -252,9 +291,15 @@ export function usePageVisible(): boolean { * A failed load carries `retry`, which fetches again at once. */ export function useRemoteData(path: string | undefined, revision: number): RemoteData { - const [loaded, setLoaded] = useState<{ path: string | undefined; value: StoredRemoteData }>( - () => ({ path, value: readCache(path) ?? LOADING }), - ); + // `at` is when the data shown arrived, so a failed refresh can say how old it is. + const [loaded, setLoaded] = useState<{ + path: string | undefined; + value: StoredRemoteData; + at?: number; + }>(() => ({ path, value: readCache(path) ?? LOADING, at: cachedAt(path) })); + const latest = useRef(loaded); + latest.current = loaded; + const [staleKey] = useState(() => Symbol(REMOTE_DATA.staleKeyDescription)); const visible = usePageVisible(); const lastPath = useRef(undefined); const lastVisible = useRef(visible); @@ -271,12 +316,24 @@ export function useRemoteData(path: string | undefined, revision: number): Re setRetryTick((tick) => tick + 1); }, []); + // Refetches at once without blanking what is on screen (the stale banner's Retry). + const refresh = useCallback(() => { + failures.current = 0; + manualRetry.current = true; + setRetryTick((tick) => tick + 1); + }, []); + + // A page that goes away (or moves to another path) no longer has anything stale to report. + useEffect(() => () => clearStale(staleKey), [staleKey]); + useEffect(() => { if (!path) { lastPath.current = undefined; + clearStale(staleKey); return; } const pathChanged = lastPath.current !== path; + if (pathChanged) clearStale(staleKey); const becameVisible = visible && !lastVisible.current; const immediate = manualRetry.current; manualRetry.current = false; @@ -304,9 +361,11 @@ export function useRemoteData(path: string | undefined, revision: number): Re : new Error(message); } const data = (await response.json()) as T; - writeCache(path, data); + const at = Date.now(); + writeCache(path, data, at); failures.current = 0; - setLoaded({ path, value: { status: REMOTE_STATUS.ready, data } }); + clearStale(staleKey); + setLoaded({ path, value: { status: REMOTE_STATUS.ready, data }, at }); } catch (error) { if (controller.signal.aborted) return; // Forget this revision was fetched, so returning to a hidden tab retries it. @@ -319,18 +378,20 @@ export function useRemoteData(path: string | undefined, revision: number): Re failures.current += 1; retryTimer = setTimeout(() => setRetryTick((tick) => tick + 1), wait); } - // Keep this path's last good response, or its cached copy when revisiting a page. - setLoaded((current) => + // Keep this path's last good response, or its cached copy when revisiting a page, and + // report it as stale so the page says how old it is instead of failing silently. + const message = error instanceof Error ? error.message : String(error); + const current = latest.current; + const kept = current.path === path && current.value.status === REMOTE_STATUS.ready ? current - : { - path, - value: readCache(path) ?? { - status: REMOTE_STATUS.error, - message: error instanceof Error ? error.message : String(error), - }, - }, - ); + : { path, value: readCache(path), at: cachedAt(path) }; + if (kept.value === undefined) { + setLoaded({ path, value: { status: REMOTE_STATUS.error, message } }); + return; + } + if (kept !== current) setLoaded({ path, value: kept.value, at: kept.at }); + markStale(staleKey, { message, lastSuccessAt: kept.at ?? Date.now(), refresh }); } }, delay); return () => { @@ -338,7 +399,7 @@ export function useRemoteData(path: string | undefined, revision: number): Re clearTimeout(retryTimer); controller.abort(); }; - }, [path, revision, visible, retryTick]); + }, [path, revision, visible, retryTick, staleKey, refresh]); if (!path) return LOADING; const value = diff --git a/src/lib/storage/storage.config.ts b/src/lib/storage/storage.config.ts index 07ff0af..87dd790 100644 --- a/src/lib/storage/storage.config.ts +++ b/src/lib/storage/storage.config.ts @@ -55,11 +55,21 @@ export const SNAPSHOT_CONNECTION = { disconnected: "disconnected", /** The server answered with a snapshot this page can't read (version mismatch). */ incompatible: "incompatible", + /** The stream dropped after data arrived; the last snapshot stays on screen while it reconnects. */ + reconnecting: "reconnecting", +} as const; + +/** Re-opening a closed event stream (a proxy 502 closes an EventSource for good). */ +export const STREAM_RECONNECT = { + /** Wait before each attempt; the last delay repeats until the server answers. */ + delaysMs: [1_000, 3_000, 10_000, 30_000], } as const; export const REMOTE_DATA = { /** Query parameter naming the account a request is about; data never carries across it. */ scopeParam: "account", + /** Debug label of the key each `useRemoteData` reports stale loads under. */ + staleKeyDescription: "remote-data", /** Wait this long after a revision-only change before refetching, so bursts of pushes collapse into one request. */ revisionDebounceMs: 600, /** Most responses kept for instant re-display; the oldest is evicted first. */ diff --git a/src/lib/storage/storage.types.ts b/src/lib/storage/storage.types.ts index 876c9f7..e8867a4 100644 --- a/src/lib/storage/storage.types.ts +++ b/src/lib/storage/storage.types.ts @@ -127,3 +127,21 @@ export type RemoteData = readonly retry: () => void; } | { readonly status: typeof REMOTE_STATUS.ready; readonly data: T }; + +/** A page load whose refresh failed while its last good data stays on screen. */ +export type StaleLoad = { + readonly message: string; + /** When the data on screen was fetched (epoch ms). */ + readonly lastSuccessAt: number; + /** Fetches again at once, keeping the data on screen meanwhile. */ + readonly refresh: () => void; +}; + +/** What the stale-data banner needs: whether the live stream dropped and which loads failed. */ +export type StaleDataState = { + /** The live event stream dropped after data arrived and is being re-established. */ + readonly reconnecting: boolean; + /** When the last live snapshot arrived (epoch ms), or null before any did. */ + readonly lastSnapshotAt: number | null; + readonly staleLoads: readonly StaleLoad[]; +}; From 890d08ea72d8d365f0e433d066de8222f8ac3247 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:52:43 +1000 Subject: [PATCH 029/115] =?UTF-8?q?fix(organiser):=20=F0=9F=A9=B9=20say=20?= =?UTF-8?q?'add=20a=20rule'=20or=20'nothing=20to=20move'=20instead=20of=20?= =?UTF-8?q?blaming=20problems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../organiser-panel/organiser-panel.component.tsx | 10 +++++++--- .../organiser-panel/organiser-panel.config.ts | 3 +++ src/lib/organiser/organiser.plan.test.ts | 11 +++++++++++ src/lib/organiser/organiser.plan.ts | 1 + src/lib/organiser/organiser.types.ts | 2 ++ 5 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/components/organiser-panel/organiser-panel.component.tsx b/src/components/organiser-panel/organiser-panel.component.tsx index f68ab4b..f86628d 100644 --- a/src/components/organiser-panel/organiser-panel.component.tsx +++ b/src/components/organiser-panel/organiser-panel.component.tsx @@ -54,9 +54,13 @@ export function OrganiserPanel({ ? ORGANISER_PANEL.busy : !itemsReady ? ORGANISER_PANEL.itemsUnavailable - : plan.executable - ? undefined - : ORGANISER_PANEL.runBlocked; + : rules.length === 0 + ? ORGANISER_PANEL.needsRules + : plan.blocked + ? ORGANISER_PANEL.runBlocked + : plan.executable + ? undefined + : ORGANISER_PANEL.nothingToMove; return (
    { const plan = computePlan({ items: [item("1", "inventory")], units: [unit("a", 0)], rules: [] }); assert.deepEqual(plan.steps, []); assert.ok(!plan.executable); + assert.ok(!plan.blocked); + }); + + it("marks a plan blocked only for error-level problems", () => { + const missing = computePlan({ + items: [item("1", "inventory")], + units: [unit("a", 0)], + rules: [rule("lost", "gone", category("Container"))], + }); + assert.ok(missing.blocked); + assert.ok(!missing.executable); }); }); diff --git a/src/lib/organiser/organiser.plan.ts b/src/lib/organiser/organiser.plan.ts index 4e1336a..994eb45 100644 --- a/src/lib/organiser/organiser.plan.ts +++ b/src/lib/organiser/organiser.plan.ts @@ -238,6 +238,7 @@ export function computePlan(input: PlanInput): MovePlan { unitChanges, ruleReports, problems, + blocked, executable: itemsToMove > 0 && !blocked, }; } diff --git a/src/lib/organiser/organiser.types.ts b/src/lib/organiser/organiser.types.ts index cacad81..6e95a44 100644 --- a/src/lib/organiser/organiser.types.ts +++ b/src/lib/organiser/organiser.types.ts @@ -111,6 +111,8 @@ export type MovePlan = { readonly unitChanges: readonly UnitChange[]; readonly ruleReports: readonly RuleReport[]; readonly problems: readonly PlanProblem[]; + /** True when an error-level problem stops the plan from running. */ + readonly blocked: boolean; /** True when there is something to move and no error-level problem. */ readonly executable: boolean; }; From a5a6d3fa4071f76a2a3659b8996add5a30fe48fe Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:52:51 +1000 Subject: [PATCH 030/115] =?UTF-8?q?fix(transfer-bar):=20=F0=9F=A9=B9=20giv?= =?UTF-8?q?e=20Move=20the=20same=20primary=20blue=20as=20every=20other=20a?= =?UTF-8?q?ction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/transfer-bar/transfer-bar.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/transfer-bar/transfer-bar.component.tsx b/src/components/transfer-bar/transfer-bar.component.tsx index e5c8507..cb210cb 100644 --- a/src/components/transfer-bar/transfer-bar.component.tsx +++ b/src/components/transfer-bar/transfer-bar.component.tsx @@ -119,7 +119,7 @@ export function TransferBar({ selected, units, accountName, busy, onClear }: Tra
    - ))} -
    - + <> + {pageTitle && } +
    +

    + {copy.chooseOneTitle} +

    +

    + {copy.chooseOneBody[pathname] ?? copy.chooseOneFallback} +

    +
    + {accounts.map((account) => ( + + ))} +
    +
    + ); } diff --git a/src/components/account-gate/account-gate.config.ts b/src/components/account-gate/account-gate.config.ts index 53798a7..c84d40c 100644 --- a/src/components/account-gate/account-gate.config.ts +++ b/src/components/account-gate/account-gate.config.ts @@ -1,4 +1,13 @@ +import { COPY } from "~/lib/copy/copy.config"; + export const ACCOUNT_GATE = { + /** Page headings for the routes that ask for one account, so the prompt keeps the page's name. */ + pageTitles: { + "/storage": COPY.storage.title, + "/terminals": COPY.terminal.pageTitle, + "/tradeup": COPY.tradeup.title, + "/cases": COPY.cases.title, + } as Readonly>, cardClass: "rounded-lg border bg-card p-4", /** Foreground text, so the link clears 4.5:1 on the tinted banner in both themes. */ settingsLinkClass: diff --git a/src/lib/copy/copy.config.ts b/src/lib/copy/copy.config.ts index fa1fd63..87643f6 100644 --- a/src/lib/copy/copy.config.ts +++ b/src/lib/copy/copy.config.ts @@ -148,8 +148,18 @@ export const COPY = { connecting: "Connecting to Steam…", signInAgain: "Sign in again", chooseOneTitle: "Choose an account", - chooseOneBody: - "This page works on one account at a time, so moves, renames and Terminal actions always go to the account you pick.", + /** Why the page needs one account, by route; read-only pages say so instead of warning about moves. */ + chooseOneBody: { + "/storage": + "Storage units belong to one account, so moves and renames always go to the account you pick.", + "/terminals": + "Terminal offers and activations belong to one account. Pick the one whose Terminals you want to see.", + "/tradeup": + "The calculator reads one account's skins at a time. It's read-only: nothing is traded or moved.", + "/cases": + "Case openings are recorded per account. Pick the one whose history you want to see.", + } as Readonly>, + chooseOneFallback: "This page shows one account at a time.", partialNotice: (names: readonly string[]) => `${names.join(", ")} ${names.length === 1 ? "isn't" : "aren't"} connected, so ${names.length === 1 ? "its" : "their"} items are left out of this view.`, partialSettings: "Open settings", From 2a4e4803621b15a5572e7db777552ff3125e2d18 Mon Sep 17 00:00:00 2001 From: 8liam Date: Sun, 8 Nov 2026 13:54:43 +1000 Subject: [PATCH 034/115] =?UTF-8?q?fix(items):=20=F0=9F=A9=B9=20give=20pho?= =?UTF-8?q?ne=20taps=2036px+=20targets=20on=20export,=20view=20and=20grid?= =?UTF-8?q?=20checkboxes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../export-items/export-items.component.tsx | 9 ++++++--- src/components/export-items/export-items.config.ts | 5 +++++ .../item-grid-card/item-grid-card.component.tsx | 2 +- .../item-grid-card/item-grid-card.config.ts | 3 +++ src/components/view-toggle/view-toggle.component.tsx | 11 ++--------- src/components/view-toggle/view-toggle.config.ts | 5 +++++ 6 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/components/export-items/export-items.component.tsx b/src/components/export-items/export-items.component.tsx index 913121a..467c7f8 100644 --- a/src/components/export-items/export-items.component.tsx +++ b/src/components/export-items/export-items.component.tsx @@ -11,7 +11,7 @@ export function ExportItems({ items, currency }: ExportItemsProps) { const isCsv = format === "csv"; const url = URL.createObjectURL( new Blob([isCsv ? itemsToCsv(items, currency) : itemsToJson(items, currency)], { - type: isCsv ? EXPORT_ITEMS.mimeType : "application/json;charset=utf-8", + type: isCsv ? EXPORT_ITEMS.mimeType : EXPORT_ITEMS.jsonMimeType, }), ); const link = document.createElement("a"); @@ -28,8 +28,9 @@ export function ExportItems({ items, currency }: ExportItemsProps) { disabled={items.length === 0} onClick={() => download("csv")} title={EXPORT_ITEMS.description} + className={EXPORT_ITEMS.buttonClass} > - + {EXPORT_ITEMS.label} ); diff --git a/src/components/export-items/export-items.config.ts b/src/components/export-items/export-items.config.ts index ba47848..38b20ab 100644 --- a/src/components/export-items/export-items.config.ts +++ b/src/components/export-items/export-items.config.ts @@ -1,5 +1,10 @@ export const EXPORT_ITEMS = { label: "Export CSV", + jsonLabel: "Export JSON", + jsonMimeType: "application/json;charset=utf-8", + iconSize: 12, + /** Small on desktop; 36px tall on phones so each is an easy tap. */ + buttonClass: "max-sm:h-9", description: "Download the items shown by the current filters", mimeType: "text/csv;charset=utf-8", /** Keeps the blob URL alive until the browser has started the download (Safari reads it late). */ diff --git a/src/components/item-grid-card/item-grid-card.component.tsx b/src/components/item-grid-card/item-grid-card.component.tsx index 9436995..8fd9858 100644 --- a/src/components/item-grid-card/item-grid-card.component.tsx +++ b/src/components/item-grid-card/item-grid-card.component.tsx @@ -57,7 +57,7 @@ export function ItemGridCard({ {selectable && (