From b96e7fe7c146c5a4859bcf797505f285e81b2184 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:30:38 -0400 Subject: [PATCH 01/23] Gate the dashboard emulator wiring on a build-time VITE_USE_EMULATOR flag The flag is inlined by Vite, so a production-shaped bundle can be built against the emulators and dev can opt out. Emulator ports come from VITE_AUTH_EMULATOR_PORT and VITE_FIRESTORE_EMULATOR_PORT. --- dashboard/src/auth/SignIn.tsx | 4 ++-- dashboard/src/env.d.ts | 12 ++++++++++++ dashboard/src/firebase.ts | 22 +++++++++++++++------- 3 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 dashboard/src/env.d.ts diff --git a/dashboard/src/auth/SignIn.tsx b/dashboard/src/auth/SignIn.tsx index 2c3b3f9..d161943 100644 --- a/dashboard/src/auth/SignIn.tsx +++ b/dashboard/src/auth/SignIn.tsx @@ -6,7 +6,7 @@ import { signInWithPopup, } from 'firebase/auth'; import { FirebaseError } from 'firebase/app'; -import { auth, googleProvider } from '../firebase'; +import { auth, googleProvider, USE_EMULATOR } from '../firebase'; import Button from '../components/Button'; import { RadarIcon } from '../components/EmptyState'; import GoogleButton from './GoogleButton'; @@ -317,7 +317,7 @@ export default function SignIn() { Terms and Privacy Policy.

- {import.meta.env.DEV ? ( + {USE_EMULATOR ? ( + + ); +} diff --git a/dashboard/src/components/Toaster.tsx b/dashboard/src/components/Toaster.tsx new file mode 100644 index 0000000..c7930ad --- /dev/null +++ b/dashboard/src/components/Toaster.tsx @@ -0,0 +1,24 @@ +import { dismissToast, useToasts } from '../lib/errors'; +import './components.css'; + +export default function Toaster() { + const toasts = useToasts(); + if (toasts.length === 0) return null; + return ( +
+ {toasts.map((t) => ( +
+ {t.text} + +
+ ))} +
+ ); +} diff --git a/dashboard/src/components/components.css b/dashboard/src/components/components.css index d56a2b2..91c2cf0 100644 --- a/dashboard/src/components/components.css +++ b/dashboard/src/components/components.css @@ -401,3 +401,48 @@ font-family: var(--font-mono); font-variant-numeric: tabular-nums; } + +/* ── ErrorState / Toaster ───────────────────────────────────────── */ +.errstate { + max-width: 520px; +} +.errstate .empty__body { + overflow-wrap: anywhere; +} +.errstate__code { + font-family: var(--font-mono); + color: var(--red); + margin-right: var(--space-2); +} +.toaster { + position: fixed; + right: var(--space-4); + bottom: var(--space-4); + z-index: 1000; + display: flex; + flex-direction: column; + gap: var(--space-2); + max-width: min(420px, calc(100vw - 2 * var(--space-4))); +} +.toast { + display: flex; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--red); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + box-shadow: var(--shadow-drawer); + font-size: 13px; + overflow-wrap: anywhere; +} +.toast__close { + margin-left: auto; + border: 0; + background: none; + color: var(--muted); + font-size: 16px; + line-height: 1; + cursor: pointer; +} diff --git a/dashboard/src/features/drawer/HistoryDrawer.tsx b/dashboard/src/features/drawer/HistoryDrawer.tsx index fdfc499..28a6761 100644 --- a/dashboard/src/features/drawer/HistoryDrawer.tsx +++ b/dashboard/src/features/drawer/HistoryDrawer.tsx @@ -10,6 +10,8 @@ import { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import Chip from '../../components/Chip'; import Drawer from '../../components/Drawer'; import EmptyState from '../../components/EmptyState'; +import ErrorState from '../../components/ErrorState'; +import { errorLabel } from '../../lib/errors'; import KeyValue from '../../components/KeyValue'; import Skeleton from '../../components/Skeleton'; import Sparkline, { type SparklinePoint } from '../../components/Sparkline'; @@ -145,6 +147,8 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps {asin}} onClose={onClose}> {product.loading ? ( + ) : product.error ? ( + ) : !p ? ( {history.loading ? ( + ) : history.error ? ( +
+ Couldn't load history: {errorLabel(history.error)} +
) : pricePointCount < 2 ? (
History builds with each scan.
) : ( diff --git a/dashboard/src/features/drawer/LeadTriage.tsx b/dashboard/src/features/drawer/LeadTriage.tsx index c4fa114..b6ad1eb 100644 --- a/dashboard/src/features/drawer/LeadTriage.tsx +++ b/dashboard/src/features/drawer/LeadTriage.tsx @@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { appendEvent, leadStageUpdate } from '../../lib/queries'; import { LEAD_STAGES, type LeadStage, type ProductLead } from '../../lib/types'; import { leadFieldPatch } from './data'; +import { errorLabel, reportError } from '../../lib/errors'; import './drawer.css'; export const STAGE_LABELS: Record = { @@ -44,7 +45,10 @@ function useDebouncedSave(save: (value: string) => Promise, delayMs = 600) saveRef .current(value) .then(() => setStatus('saved')) - .catch(() => setStatus('error')); + .catch((err: unknown) => { + setStatus('error'); + reportError('save the lead note', err); + }); }, []); const queue = useCallback( @@ -127,10 +131,11 @@ export default function LeadTriage({ appendEvent(wid, asin, { type: 'stageChange', from: prev, to: next }), ]) .then(() => setBusy(false)) - .catch(() => { + .catch((err: unknown) => { + console.error('[proscan] stage change failed', err); onStageChange(prev); setBusy(false); - setError('Stage change failed — check your connection and retry.'); + setError(`Stage change failed: ${errorLabel(err)}`); }); }; diff --git a/dashboard/src/features/drawer/data.ts b/dashboard/src/features/drawer/data.ts index 8fb2b69..04e5669 100644 --- a/dashboard/src/features/drawer/data.ts +++ b/dashboard/src/features/drawer/data.ts @@ -67,7 +67,8 @@ export function useLatestOfferSnapshot( .then((snap) => { if (!cancelled) setState({ data: snap, loading: false }); }) - .catch(() => { + .catch((err: unknown) => { + console.error('[proscan] offer snapshot read failed', err); if (!cancelled) setState({ data: null, loading: false }); }); return () => { diff --git a/dashboard/src/features/export/ExportButton.tsx b/dashboard/src/features/export/ExportButton.tsx index a26374e..fb8949f 100644 --- a/dashboard/src/features/export/ExportButton.tsx +++ b/dashboard/src/features/export/ExportButton.tsx @@ -6,6 +6,7 @@ import { useEffect, useRef, useState } from 'react'; import Button from '../../components/Button'; import type { Product } from '../../lib/types'; import { exportProductsXlsx } from './exportXlsx'; +import { reportError } from '../../lib/errors'; import './export.css'; export interface ExportButtonProps { @@ -47,7 +48,7 @@ export default function ExportButton({ rows, disabled }: ExportButtonProps) { setPhase('idle'); }, CONFIRM_MS); } catch (err) { - console.error('[proscan] XLSX export failed', err); + reportError('export XLSX', err); if (mountedRef.current) setPhase('idle'); } }; diff --git a/dashboard/src/features/runs/runs.css b/dashboard/src/features/runs/runs.css index f64465e..7f229b0 100644 --- a/dashboard/src/features/runs/runs.css +++ b/dashboard/src/features/runs/runs.css @@ -134,16 +134,6 @@ button.run-card:hover { } /* ── inline query-error notice ──────────────────────────────────── */ -.runs-error { - max-width: 760px; - padding: var(--space-3) var(--space-4); - border: 1px solid var(--red); - border-radius: var(--radius-sm); - background: var(--red-soft); - color: var(--red); - font-size: 13px; -} - @media (prefers-reduced-motion: reduce) { .run-progress__fill { animation: none; diff --git a/dashboard/src/features/watchlist/NicknameCell.tsx b/dashboard/src/features/watchlist/NicknameCell.tsx index c809c5a..243167e 100644 --- a/dashboard/src/features/watchlist/NicknameCell.tsx +++ b/dashboard/src/features/watchlist/NicknameCell.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type KeyboardEvent } from 'react'; import { sourcePatch } from '../../lib/queries'; +import { reportError } from '../../lib/errors'; import type { Source } from '../../lib/types'; import './watchlist.css'; @@ -30,8 +31,7 @@ export default function NicknameCell({ wid, source }: NicknameCellProps) { const next = draft.trim(); if (next === (source.nickname ?? '')) return; sourcePatch(wid, source.sourceId, { nickname: next || null }).catch( - (err: unknown) => - console.error('[proscan:watchlist] nickname patch failed', err), + (err: unknown) => reportError('save the nickname', err), ); }; diff --git a/dashboard/src/features/watchlist/WatchlistTable.tsx b/dashboard/src/features/watchlist/WatchlistTable.tsx index a0f9c36..21cf818 100644 --- a/dashboard/src/features/watchlist/WatchlistTable.tsx +++ b/dashboard/src/features/watchlist/WatchlistTable.tsx @@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom'; import Chip from '../../components/Chip'; import Tag from '../../components/Tag'; import { sourcePatch } from '../../lib/queries'; +import { reportError } from '../../lib/errors'; import { compactNumber, relativeTime, @@ -50,7 +51,7 @@ function identity(source: Source): string { } function logPatchError(err: unknown): void { - console.error('[proscan:watchlist] source patch failed', err); + reportError('save the watchlist change', err); } /* ── inline icons (16px, stroke = currentColor) ─────────────────── */ diff --git a/dashboard/src/lib/errors.ts b/dashboard/src/lib/errors.ts new file mode 100644 index 0000000..53fbacc --- /dev/null +++ b/dashboard/src/lib/errors.ts @@ -0,0 +1,63 @@ +// Error plumbing: every failure is logged in every build, and write +// failures also raise a toast so they never disappear into the console. + +import { useSyncExternalStore } from 'react'; + +export interface ErrorInfo { + code: string | null; + message: string; + /** Firestore puts a "create this index" console link in failed-precondition errors. */ + indexUrl: string | null; +} + +export function describeError(err: unknown): ErrorInfo { + const e = err as { code?: unknown; message?: unknown } | null; + const code = typeof e?.code === 'string' ? e.code : null; + const message = + typeof e?.message === 'string' && e.message ? e.message : String(err ?? 'Unknown error'); + const link = message.match(/https:\/\/console\.firebase\.google\.com\/\S+/); + return { code, message, indexUrl: link ? link[0] : null }; +} + +/** Short form for inline text: the Firestore code, else the message. */ +export function errorLabel(err: unknown): string { + const info = describeError(err); + return info.code ?? info.message; +} + +/* ── toasts ─────────────────────────────────────────────────────── */ + +export interface Toast { + id: number; + text: string; +} + +let toasts: Toast[] = []; +let nextId = 1; +const listeners = new Set<() => void>(); +const emit = () => listeners.forEach((fn) => fn()); + +export function dismissToast(id: number): void { + toasts = toasts.filter((t) => t.id !== id); + emit(); +} + +/** Log a failure and show it to the user. `what` reads as "Couldn't ". */ +export function reportError(what: string, err: unknown): void { + console.error(`[proscan] ${what} failed`, err); + const id = nextId++; + toasts = [...toasts, { id, text: `Couldn't ${what}: ${errorLabel(err)}` }].slice(-4); + emit(); + window.setTimeout(() => dismissToast(id), 8000); +} + +export function useToasts(): Toast[] { + return useSyncExternalStore( + (fn) => { + listeners.add(fn); + return () => listeners.delete(fn); + }, + () => toasts, + () => toasts, + ); +} diff --git a/dashboard/src/lib/hooks.ts b/dashboard/src/lib/hooks.ts index 72f5da1..dc93cb7 100644 --- a/dashboard/src/lib/hooks.ts +++ b/dashboard/src/lib/hooks.ts @@ -142,9 +142,7 @@ export function useSnapshotQuery( firstSnapshot = false; scanEnd(token); } - if (import.meta.env.DEV) { - console.debug(`[proscan:reads] ${debugLabel ?? 'query'} error`, error); - } + console.error(`[proscan] ${debugLabel ?? 'query'} listener failed`, error); setState({ data: [], loading: false, error }); }, ); @@ -196,6 +194,7 @@ export function useDocOnce(ref: DocumentReference | null): DocOnceState }); }) .catch((error: Error) => { + console.error(`[proscan] read ${ref.path} failed`, error); if (!cancelled) setState({ data: null, loading: false, error }); }); return () => { @@ -214,12 +213,14 @@ export interface WorkspaceState { wid: string | null; workspace: Workspace | null; loading: boolean; + error: Error | null; } const WorkspaceContext = createContext({ wid: null, workspace: null, loading: false, + error: null, }); /** Mounted ONCE by AuthGate after sign-in: owns the single doc listener on @@ -236,10 +237,11 @@ export function WorkspaceProvider({ wid: uid, workspace: null, loading: true, + error: null, }); useEffect(() => { - setState({ wid: uid, workspace: null, loading: true }); + setState({ wid: uid, workspace: null, loading: true, error: null }); const unsubscribe = onSnapshot( doc(db, 'workspaces', uid), (snap) => { @@ -247,9 +249,13 @@ export function WorkspaceProvider({ wid: uid, workspace: snap.exists() ? (snap.data() as Workspace) : null, loading: false, + error: null, }); }, - () => setState({ wid: uid, workspace: null, loading: false }), + (error) => { + console.error('[proscan] workspace listener failed', error); + setState({ wid: uid, workspace: null, loading: false, error }); + }, ); return unsubscribe; }, [uid]); diff --git a/dashboard/src/routes/Products.tsx b/dashboard/src/routes/Products.tsx index 07b0ad0..c6ae365 100644 --- a/dashboard/src/routes/Products.tsx +++ b/dashboard/src/routes/Products.tsx @@ -29,6 +29,7 @@ import { import type { Product } from '../lib/types'; import PageHeader from '../components/PageHeader'; import EmptyState from '../components/EmptyState'; +import ErrorState from '../components/ErrorState'; import Skeleton from '../components/Skeleton'; import Button from '../components/Button'; import HistoryDrawer from '../features/drawer/HistoryDrawer'; @@ -163,10 +164,7 @@ export default function Products() { ); } else if (products.error) { body = ( - + ); } else if (scopedRows.length === 0) { if (view === 'movers') { diff --git a/dashboard/src/routes/Runs.tsx b/dashboard/src/routes/Runs.tsx index afb6b46..55866f6 100644 --- a/dashboard/src/routes/Runs.tsx +++ b/dashboard/src/routes/Runs.tsx @@ -11,6 +11,7 @@ import { runsRecent, sources } from '../lib/queries'; import type { Source } from '../lib/types'; import PageHeader from '../components/PageHeader'; import EmptyState from '../components/EmptyState'; +import ErrorState from '../components/ErrorState'; import Button from '../components/Button'; import { CWS_URL } from '../auth/SignIn'; import RunCard, { RunCardSkeleton } from '../features/runs/RunCard'; @@ -56,9 +57,7 @@ export default function Runs() { ); } else if (runsState.error) { body = ( -
- Could not load runs: {runsState.error.message} -
+ ); } else if (runsState.data.length === 0) { body = ( diff --git a/dashboard/src/routes/Watchlist.tsx b/dashboard/src/routes/Watchlist.tsx index 3aca747..54b01a6 100644 --- a/dashboard/src/routes/Watchlist.tsx +++ b/dashboard/src/routes/Watchlist.tsx @@ -8,6 +8,7 @@ import { sources } from '../lib/queries'; import { staleness } from '../lib/format'; import PageHeader from '../components/PageHeader'; import EmptyState from '../components/EmptyState'; +import ErrorState from '../components/ErrorState'; import Skeleton from '../components/Skeleton'; import Button from '../components/Button'; import { CWS_URL } from '../auth/SignIn'; @@ -48,10 +49,7 @@ export default function Watchlist() { ); } else if (error) { body = ( - + ); } else if (!wid || data.length === 0) { body = ( diff --git a/dashboard/src/shell/Layout.tsx b/dashboard/src/shell/Layout.tsx index f45c243..58992e1 100644 --- a/dashboard/src/shell/Layout.tsx +++ b/dashboard/src/shell/Layout.tsx @@ -1,6 +1,7 @@ import { Outlet, useLocation } from 'react-router-dom'; import Sidebar from './Sidebar'; import Topbar from './Topbar'; +import Toaster from '../components/Toaster'; import './shell.css'; /** App shell grid: 232px chrome sidebar + topbar + light content area. @@ -19,6 +20,7 @@ export default function Layout() { + ); } From ec0d532179485537106b661a8bc6cf3d29650e1a Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:35:35 -0400 Subject: [PATCH 06/23] Say when the product board is a capped window The board listens to the first 100, 300 or 500 rows and used to show that as the product count. When the window is full the header now reads "Showing the first 300 of 1,234 products", with the total from getCountFromServer, or "more not loaded" if the count fails. Movers scoped to a source says it filters the global top 100. Adds a rules test for the count queries (F-45). --- dashboard/src/lib/hooks.ts | 30 ++++++++++++ dashboard/src/lib/queries.ts | 19 +++++--- dashboard/src/routes/Products.tsx | 59 ++++++++++++++++++----- package.json | 2 +- scripts/qa-board-count.mjs | 80 +++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 scripts/qa-board-count.mjs diff --git a/dashboard/src/lib/hooks.ts b/dashboard/src/lib/hooks.ts index dc93cb7..416ae5d 100644 --- a/dashboard/src/lib/hooks.ts +++ b/dashboard/src/lib/hooks.ts @@ -18,6 +18,7 @@ import { import { onAuthStateChanged, type User } from 'firebase/auth'; import { doc, + getCountFromServer, getDoc, onSnapshot, type DocumentReference, @@ -156,6 +157,35 @@ export function useSnapshotQuery( return state; } +/* ── server-side count (aggregate, no documents read) ───────────────── */ + +/** getCountFromServer for a query. `null` while unknown or when the count + * failed; callers fall back to "showing first N". Re-runs on dep change. */ +export function useServerCount( + queryFactory: () => Query | null, + deps: DependencyList, +): number | null { + const [count, setCount] = useState(null); + useEffect(() => { + const q = queryFactory(); + setCount(null); + if (!q) return; + let cancelled = false; + getCountFromServer(q) + .then((snap) => { + if (!cancelled) setCount(snap.data().count); + }) + .catch((error: unknown) => { + console.error('[proscan] count query failed', error); + }); + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + return count; +} + /* ── one-shot doc read (history / snapshots — NEVER listeners) ──────── */ export interface DocOnceState { diff --git a/dashboard/src/lib/queries.ts b/dashboard/src/lib/queries.ts index cd9db39..0afb005 100644 --- a/dashboard/src/lib/queries.ts +++ b/dashboard/src/lib/queries.ts @@ -57,30 +57,35 @@ function productsCol(wid: string) { /* ── product queries (ALWAYS scoped) ────────────────────────────────── */ +// Listener queries always pass a number. `null` drops the limit and is +// only for getCountFromServer, which counts without reading documents. + /** Per-source product board — the default table view. * Index: products(sourceIds contains, latest.at desc). */ export function productsBySource( wid: string, sourceId: string, - lim = 400, + lim: number | null = 400, ): Query { - return query( + const q = query( productsCol(wid), where('sourceIds', 'array-contains', sourceId), orderBy('latest.at', 'desc'), - limit(lim), ); + return lim === null ? q : query(q, limit(lim)); } /** Global Movers / Flip Radar: biggest price DROPS first (delta.pPct asc — * most-negative = best buying opportunity). Single-field index. */ -export function topMovers(wid: string, lim = 100): Query { - return query(productsCol(wid), orderBy('delta.pPct', 'asc'), limit(lim)); +export function topMovers(wid: string, lim: number | null = 100): Query { + const q = query(productsCol(wid), orderBy('delta.pPct', 'asc')); + return lim === null ? q : query(q, limit(lim)); } /** Most recently observed products across all sources. */ -export function recentProducts(wid: string, lim = 200): Query { - return query(productsCol(wid), orderBy('latest.at', 'desc'), limit(lim)); +export function recentProducts(wid: string, lim: number | null = 200): Query { + const q = query(productsCol(wid), orderBy('latest.at', 'desc')); + return lim === null ? q : query(q, limit(lim)); } /* ── runs / sources ─────────────────────────────────────────────────── */ diff --git a/dashboard/src/routes/Products.tsx b/dashboard/src/routes/Products.tsx index c6ae365..0241787 100644 --- a/dashboard/src/routes/Products.tsx +++ b/dashboard/src/routes/Products.tsx @@ -8,8 +8,9 @@ // source -> productsBySource(wid, sourceId, 500) // default -> recentProducts(wid, 300) // plus the sanctioned tiny sources listener for the scope dropdown. -// Search, sorting and the movers' has-delta filter are client-side over -// the loaded set only. +// Search, sorting, export and the movers' has-delta filter are client-side +// over the loaded set only, so the header says when that set is capped and +// shows the server-side total (F-45). Real pagination lands in Phase 4. import { useMemo, useState, type ReactNode } from 'react'; import { useSearchParams } from 'react-router-dom'; @@ -19,7 +20,7 @@ import { useReactTable, type SortingState, } from '@tanstack/react-table'; -import { useSnapshotQuery, useWorkspace } from '../lib/hooks'; +import { useServerCount, useSnapshotQuery, useWorkspace } from '../lib/hooks'; import { productsBySource, recentProducts, @@ -42,9 +43,26 @@ import BoardTable from '../features/board/BoardTable'; import { boardColumns } from '../features/board/columns'; import '../features/board/board.css'; -function countLabel(visible: number, total: number): string { - const noun = total === 1 ? 'product' : 'products'; - return visible === total ? `${total} ${noun}` : `${visible} of ${total} ${noun}`; +const LIMITS = { movers: 100, source: 500, recent: 300 } as const; + +const fmt = (n: number) => n.toLocaleString('en-US'); + +/** Header count. Never presents a capped window as the whole workspace. */ +function countLabel( + visible: number, + loaded: number, + capped: boolean, + total: number | null, +): string { + const noun = loaded === 1 ? 'product' : 'products'; + if (!capped) { + return visible === loaded ? `${fmt(loaded)} ${noun}` : `${fmt(visible)} of ${fmt(loaded)} ${noun}`; + } + const span = + total !== null && total > loaded + ? `first ${fmt(loaded)} of ${fmt(total)} ${noun}` + : `first ${fmt(loaded)} ${noun}, more not loaded`; + return visible === loaded ? `Showing the ${span}` : `${fmt(visible)} matches in the ${span}`; } export default function Products() { @@ -60,17 +78,32 @@ export default function Products() { const [search, setSearch] = useState(''); const [sorting, setSorting] = useState([]); + const limit = + view === 'movers' ? LIMITS.movers : sourceId ? LIMITS.source : LIMITS.recent; + const products = useSnapshotQuery( () => { if (!wid) return null; - if (view === 'movers') return topMovers(wid, 100); - if (sourceId) return productsBySource(wid, sourceId, 500); - return recentProducts(wid, 300); + if (view === 'movers') return topMovers(wid, limit); + if (sourceId) return productsBySource(wid, sourceId, limit); + return recentProducts(wid, limit); }, [wid, view, sourceId], view === 'movers' ? 'board:movers' : sourceId ? 'board:by-source' : 'board:recent', ); + // Only ask the server for a total once the window is actually full. + const capped = products.data.length >= limit; + const total = useServerCount( + () => { + if (!wid || !capped) return null; + if (view === 'movers') return topMovers(wid, null); + if (sourceId) return productsBySource(wid, sourceId, null); + return recentProducts(wid, null); + }, + [wid, view, sourceId, capped], + ); + const sourcesState = useSnapshotQuery( () => (wid ? sourcesQuery(wid) : null), [wid], @@ -146,8 +179,12 @@ export default function Products() { const subtitle = loading ? 'Loading the board…' : [ - countLabel(rows.length, scopedRows.length), - view === 'movers' ? 'biggest price drops first' : 'latest observations', + countLabel(rows.length, scopedRows.length, capped && !(view === 'movers' && sourceId), total), + view === 'movers' + ? sourceId + ? `biggest drops within the global top ${limit}` + : 'biggest price drops first' + : 'latest observations', sourceName ? `source: ${sourceName}` : null, ] .filter(Boolean) diff --git a/package.json b/package.json index 97845a0..f499c83 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "emulators": "node scripts/emulators.mjs start", "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data", "seed": "node scripts/seed-emulators.mjs", - "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs\"" + "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"" }, "dependencies": { "@fontsource-variable/archivo": "^5.2.8", diff --git a/scripts/qa-board-count.mjs b/scripts/qa-board-count.mjs new file mode 100644 index 0000000..b412d59 --- /dev/null +++ b/scripts/qa-board-count.mjs @@ -0,0 +1,80 @@ +// Rules test for the board's server-side count (F-45): the owner can run +// getCountFromServer on the same ordered queries the board listens to, and +// the count reaches past the listener's limit. Another tenant cannot. +// Run: npm run test:rules + +import { initializeApp, deleteApp } from 'firebase/app'; +import { getAuth, connectAuthEmulator, createUserWithEmailAndPassword } from 'firebase/auth'; +import { + getFirestore, + connectFirestoreEmulator, + collection, + doc, + getCountFromServer, + limit, + orderBy, + query, + where, + writeBatch, + Timestamp, +} from 'firebase/firestore'; + +const app = initializeApp({ projectId: 'demo-proscan', apiKey: 'demo-key' }, 'board-count'); +const auth = getAuth(app); +const db = getFirestore(app); +const AUTH_HOST = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099'; +const [FS_HOST, FS_PORT] = (process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080').split(':'); +connectAuthEmulator(auth, `http://${AUTH_HOST}`, { disableWarnings: true }); +connectFirestoreEmulator(db, FS_HOST, Number(FS_PORT)); + +const N = 320; // more than the 300-row recent window +let failed = 0; +const check = (name, ok, detail = '') => { + if (!ok) failed++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`); +}; + +try { + const cred = await createUserWithEmailAndPassword(auth, `qa-count-${Date.now()}@proscan.test`, 'qa-pass-123'); + const uid = cred.user.uid; + const col = collection(db, 'workspaces', uid, 'products'); + + const batch = writeBatch(db); + for (let i = 0; i < N; i++) { + const asin = `B0QACNT${String(i).padStart(3, '0')}`; + batch.set(doc(col, asin), { + asin, + sourceIds: [i % 2 ? 's_ODD' : 's_EVEN'], + latest: { p: 1000 + i, dayKey: '2026-09-01', at: Timestamp.fromMillis(1e12 + i) }, + }); + } + await batch.commit(); + + const recent = await getCountFromServer(query(col, orderBy('latest.at', 'desc'))); + check('recent count sees every product', recent.data().count === N, `count=${recent.data().count}`); + + const bySource = await getCountFromServer( + query(col, where('sourceIds', 'array-contains', 's_ODD'), orderBy('latest.at', 'desc')), + ); + check('per-source count', bySource.data().count === N / 2, `count=${bySource.data().count}`); + + const window = await getCountFromServer(query(col, orderBy('latest.at', 'desc'), limit(300))); + check('limited query stays capped', window.data().count === 300, `count=${window.data().count}`); + + let denied = false; + try { + await getCountFromServer(collection(db, 'workspaces', 'someOtherTenantUid_0000', 'products')); + } catch (e) { + denied = e.code === 'permission-denied'; + } + check('cross-tenant count denied', denied); +} catch (e) { + console.error('[fatal]', e); + failed++; +} finally { + console.log(failed === 0 ? '\nRESULT: PASS' : `\nRESULT: FAIL (${failed})`); + try { + await deleteApp(app); + } catch {} + process.exit(failed === 0 ? 0 : 1); +} From e6359eeffdb736bca169e52c87a849c817e1e7a3 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:36:21 -0400 Subject: [PATCH 07/23] Hide board columns that nothing writes yet Rank, Spread and Max Buy on the board, the Max Buy verdict in the drawer and Catalog on the watchlist are always empty for real users, because the extension's sync never writes those fields. Show them only once a loaded row carries the data, so the seed fixture and Phase 5 bring them back without a code change (F-25). --- dashboard/src/features/board/columns.tsx | 11 +++++++++++ dashboard/src/features/drawer/HistoryDrawer.tsx | 15 ++++++--------- dashboard/src/features/drawer/drawer.css | 5 ----- .../src/features/watchlist/WatchlistTable.tsx | 16 ++++++++++++---- dashboard/src/routes/Products.tsx | 6 ++++-- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/dashboard/src/features/board/columns.tsx b/dashboard/src/features/board/columns.tsx index 43a8a77..12f4320 100644 --- a/dashboard/src/features/board/columns.tsx +++ b/dashboard/src/features/board/columns.tsx @@ -227,6 +227,17 @@ function TagsCell({ p }: { p: Product }) { /* ── column definitions ─────────────────────────────────────────── */ +// Nothing writes rank, spread or scores yet (F-25): the extension's sync +// only sends price, rating and reviews. These columns stay hidden until a +// loaded row carries the field. Phase 5 (offers and spread) fills them. +export function dataColumnVisibility(rows: Product[]): Record { + return { + rank: rows.some((p) => p.latest?.rk !== undefined), + spread: rows.some((p) => p.spread !== undefined), + maxBuy: rows.some((p) => p.scores?.maxBuy !== undefined), + }; +} + /** Client-side sortable columns over the loaded set. `sortUndefined: 'last'` * keeps absent values (no delta / spread / scores) at the bottom. */ export const boardColumns: ColumnDef[] = [ diff --git a/dashboard/src/features/drawer/HistoryDrawer.tsx b/dashboard/src/features/drawer/HistoryDrawer.tsx index 28a6761..bb6afc4 100644 --- a/dashboard/src/features/drawer/HistoryDrawer.tsx +++ b/dashboard/src/features/drawer/HistoryDrawer.tsx @@ -195,9 +195,10 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps - {/* 2 · THE VERDICT BLOCK (signature) */} -
- {typeof maxBuy === 'number' && typeof spread?.md === 'number' ? ( + {/* 2 · THE VERDICT BLOCK (signature). Hidden without spread data, + which nothing writes until Phase 5 (F-25). */} + {typeof maxBuy === 'number' && typeof spread?.md === 'number' ? ( +

Buy below {money(maxBuy)} to clear{' '} @@ -208,12 +209,8 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps MAX BUY {money(maxBuy)}

- ) : ( -
- Run a spread analysis in the extension to get a Max Buy price. -
- )} -
+
+ ) : null} {/* 3 · price history */}
diff --git a/dashboard/src/features/drawer/drawer.css b/dashboard/src/features/drawer/drawer.css index 94e7d0f..f8578db 100644 --- a/dashboard/src/features/drawer/drawer.css +++ b/dashboard/src/features/drawer/drawer.css @@ -131,11 +131,6 @@ padding: 0 var(--space-4); font-size: 15px; } -.hd-verdict--empty { - font-size: 13px; - font-weight: 400; - color: var(--muted); -} /* ── 3 · price history chart tooltip ─────────────────────────────── */ .hd-tip { diff --git a/dashboard/src/features/watchlist/WatchlistTable.tsx b/dashboard/src/features/watchlist/WatchlistTable.tsx index 21cf818..d6a23a8 100644 --- a/dashboard/src/features/watchlist/WatchlistTable.tsx +++ b/dashboard/src/features/watchlist/WatchlistTable.tsx @@ -127,11 +127,13 @@ function WatchlistRow({ wid, source, stale, + showCatalog, onOpen, }: { wid: string; source: Source; stale: Staleness; + showCatalog: boolean; onOpen: (sourceId: string) => void; }) { const watched = source.watched === true; @@ -191,9 +193,11 @@ function WatchlistRow({ {ident} - - {compactNumber(source.catalogSize)} - + {showCatalog ? ( + + {compactNumber(source.catalogSize)} + + ) : null} {relativeTime(source.lastScrapedAt)} @@ -289,6 +293,9 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) { navigate(`/?source=${encodeURIComponent(sourceId)}`); }; + // The extension does not write catalogSize yet (F-25); hide until it does. + const showCatalog = sources.some((s) => s.catalogSize !== undefined); + return (
@@ -298,7 +305,7 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) { - + {showCatalog ? : null} @@ -313,6 +320,7 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) { wid={wid} source={source} stale={stale} + showCatalog={showCatalog} onOpen={openSource} /> ))} diff --git a/dashboard/src/routes/Products.tsx b/dashboard/src/routes/Products.tsx index 0241787..31a331c 100644 --- a/dashboard/src/routes/Products.tsx +++ b/dashboard/src/routes/Products.tsx @@ -40,7 +40,7 @@ import BoardToolbar, { type BoardView, } from '../features/board/BoardToolbar'; import BoardTable from '../features/board/BoardTable'; -import { boardColumns } from '../features/board/columns'; +import { boardColumns, dataColumnVisibility } from '../features/board/columns'; import '../features/board/board.css'; const LIMITS = { movers: 100, source: 500, recent: 300 } as const; @@ -132,10 +132,12 @@ export default function Products() { ); }, [scopedRows, query]); + const columnVisibility = useMemo(() => dataColumnVisibility(products.data), [products.data]); + const table = useReactTable({ data: rows, columns: boardColumns, - state: { sorting }, + state: { sorting, columnVisibility }, onSortingChange: setSorting, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), From 0c7e3088f24f47a657d7295f2086c0d856f262a3 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:39:11 -0400 Subject: [PATCH 08/23] Stop serving index.html for missing dashboard assets The /dashboard/** rewrite answered a request for an old chunk hash with index.html, and the assets header cached that for a year. The rewrite now only matches dotless paths, so a missing asset is a real 404. HTML and app routes are served no-cache. The router gets an errorElement and a stale chunk triggers one guarded reload. Adds npm run test:hosting, which runs on Linux because the Windows hosting emulator mangles paths (F-43). --- dashboard/src/main.tsx | 17 +++++++++ dashboard/src/router.tsx | 2 ++ dashboard/src/routes/RouteError.tsx | 11 ++++++ firebase.json | 7 ++-- package.json | 3 +- scripts/emulators.mjs | 17 +++++---- scripts/qa-hosting.mjs | 54 +++++++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 dashboard/src/routes/RouteError.tsx create mode 100644 scripts/qa-hosting.mjs diff --git a/dashboard/src/main.tsx b/dashboard/src/main.tsx index 2ec7af9..54b4bf9 100644 --- a/dashboard/src/main.tsx +++ b/dashboard/src/main.tsx @@ -11,6 +11,23 @@ import { createRoot } from 'react-dom/client'; import { RouterProvider } from 'react-router-dom'; import { router } from './router'; +// After a deploy, an open tab asks for chunk hashes that no longer exist. +// Reload once to pick up the new index.html; the guard stops a reload loop. +window.addEventListener('vite:preloadError', (event) => { + const KEY = 'proscan:preload-reload'; + let recent = true; + try { + recent = Date.now() - Number(sessionStorage.getItem(KEY) || 0) < 10_000; + sessionStorage.setItem(KEY, String(Date.now())); + } catch { + /* storage blocked: no guard, so let the error surface instead */ + } + if (!recent) { + event.preventDefault(); + window.location.reload(); + } +}); + const container = document.getElementById('root'); if (!container) { throw new Error('Root element #root not found'); diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index 79543df..0810c59 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -5,6 +5,7 @@ import Products from './routes/Products'; import Runs from './routes/Runs'; import Watchlist from './routes/Watchlist'; import NotFound from './routes/NotFound'; +import RouteError from './routes/RouteError'; // AuthGate wraps the shell: splash -> sign-in -> bootstrap -> app. // The Firebase Hosting rewrite sends every unknown /dashboard/* path into @@ -17,6 +18,7 @@ export const router = createBrowserRouter( ), + errorElement: , children: [ { index: true, element: }, { path: 'runs', element: }, diff --git a/dashboard/src/routes/RouteError.tsx b/dashboard/src/routes/RouteError.tsx new file mode 100644 index 0000000..c2cbdc4 --- /dev/null +++ b/dashboard/src/routes/RouteError.tsx @@ -0,0 +1,11 @@ +// Router errorElement: a render crash or a failed lazy chunk lands here +// instead of a blank page. + +import { useRouteError } from 'react-router-dom'; +import ErrorState from '../components/ErrorState'; + +export default function RouteError() { + const error = useRouteError(); + console.error('[proscan] route crashed', error); + return ; +} diff --git a/firebase.json b/firebase.json index 98e12ab..d531fc0 100644 --- a/firebase.json +++ b/firebase.json @@ -3,11 +3,14 @@ "public": "dist", "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [ - { "source": "/dashboard/**", "destination": "/dashboard/index.html" } + { "regex": "^/dashboard(/[^.]*)?$", "destination": "/dashboard/index.html" } ], "headers": [ { "source": "/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }, - { "source": "/dashboard/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] } + { "source": "/dashboard/assets/**", "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }, + { "regex": "^/[^.]*$", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }, + { "source": "**/*.html", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }, + { "source": "/version.json", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] }, "firestore": { "rules": "firestore.rules", "indexes": "firestore.indexes.json" }, diff --git a/package.json b/package.json index f499c83..09ed10a 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "emulators": "node scripts/emulators.mjs start", "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data", "seed": "node scripts/seed-emulators.mjs", - "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"" + "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"", + "test:hosting": "node scripts/emulators.mjs exec --only hosting \"node scripts/qa-hosting.mjs\"" }, "dependencies": { "@fontsource-variable/archivo": "^5.2.8", diff --git a/scripts/emulators.mjs b/scripts/emulators.mjs index 7b805a2..3d8abd6 100644 --- a/scripts/emulators.mjs +++ b/scripts/emulators.mjs @@ -2,12 +2,13 @@ // checkouts (or a stuck Java process on 8080) don't collide. // // node scripts/emulators.mjs start [extra firebase args] -// node scripts/emulators.mjs exec "" +// node scripts/emulators.mjs exec [--only auth,firestore] "" // // Ports: EMU_AUTH_PORT, EMU_FIRESTORE_PORT, EMU_HOSTING_PORT, EMU_UI_PORT, // EMU_HUB_PORT, EMU_LOGGING_PORT. Unset ones keep the firebase.json value. // `exec` also hands the ports to the child as FIREBASE_AUTH_EMULATOR_HOST / -// FIRESTORE_EMULATOR_HOST (set by firebase itself) and VITE_*_EMULATOR_PORT. +// FIRESTORE_EMULATOR_HOST (set by firebase itself), VITE_*_EMULATOR_PORT and +// EMU_HOSTING_PORT. import { execSync, spawn } from 'node:child_process'; import { readFileSync, writeFileSync, rmSync } from 'node:fs'; @@ -55,10 +56,11 @@ const [mode, ...rest] = process.argv.slice(2); let args; if (mode === 'start') { args = ['emulators:start', ...rest]; -} else if (mode === 'exec' && rest.length === 1) { - args = ['emulators:exec', '--only', 'auth,firestore', JSON.stringify(rest[0])]; +} else if (mode === 'exec' && (rest.length === 1 || (rest.length === 3 && rest[0] === '--only'))) { + const only = rest.length === 3 ? rest[1] : 'auth,firestore'; + args = ['emulators:exec', '--only', only, JSON.stringify(rest[rest.length - 1])]; } else { - console.error('usage: emulators.mjs start [args] | exec ""'); + console.error('usage: emulators.mjs start [args] | exec [--only list] ""'); process.exit(2); } args.push('--project', 'demo-proscan', '--config', '.firebase.emulators.json'); @@ -72,6 +74,7 @@ const child = spawn('firebase', args, { ...process.env, VITE_AUTH_EMULATOR_PORT: String(ports.auth), VITE_FIRESTORE_EMULATOR_PORT: String(ports.firestore), + EMU_HOSTING_PORT: String(ports.hosting), }, }); @@ -85,7 +88,9 @@ function freePorts() { } catch { return; } - const wanted = new Set([ports.firestore, ports.auth, ports.hub, ports.logging].map(String)); + const wanted = new Set( + [ports.firestore, ports.auth, ports.hosting, ports.hub, ports.logging].map(String), + ); const pids = new Set(); for (const line of out.split('\n')) { const m = line.trim().match(/^TCP\s+127\.0\.0\.1:(\d+)\s+\S+\s+LISTENING\s+(\d+)/); diff --git a/scripts/qa-hosting.mjs b/scripts/qa-hosting.mjs new file mode 100644 index 0000000..03b0396 --- /dev/null +++ b/scripts/qa-hosting.mjs @@ -0,0 +1,54 @@ +// Checks the built dist/ through the hosting emulator (F-43): SPA routes get +// the dashboard shell with no-cache, real assets are immutable, and a missing +// asset is a 404 instead of index.html served as JavaScript for a year. +// Run: npm run build && npm run test:hosting + +import { readdirSync } from 'node:fs'; + +// superstatic turns request paths into backslash paths on Windows, so the +// emulator there ignores every regex rewrite and glob header. Run this on +// Linux (CI does) or in WSL. +if (process.platform === 'win32') { + console.log('SKIP: the hosting emulator mangles paths on Windows; run in CI or WSL'); + process.exit(0); +} + +const BASE = `http://127.0.0.1:${process.env.EMU_HOSTING_PORT || 5000}`; +let failed = 0; +const check = (name, ok, detail = '') => { + if (!ok) failed++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` (${detail})`}`); +}; + +async function get(path) { + const res = await fetch(BASE + path, { redirect: 'manual' }); + const body = await res.text(); + return { + status: res.status, + type: res.headers.get('content-type') ?? '', + cache: res.headers.get('cache-control') ?? '', + body, + }; +} + +const dashAsset = readdirSync('dist/dashboard/assets').find((f) => f.endsWith('.js')); + +for (const path of ['/dashboard/', '/dashboard/runs', '/dashboard/watchlist']) { + const r = await get(path); + check(`${path} serves the dashboard shell`, r.status === 200 && r.body.includes('id="root"'), `${r.status}`); + check(`${path} is no-cache`, r.cache === 'no-cache', r.cache); +} + +const landing = await get('/'); +check('/ is no-cache', landing.status === 200 && landing.cache === 'no-cache', `${landing.status} ${landing.cache}`); + +const asset = await get(`/dashboard/assets/${dashAsset}`); +check('real asset is JavaScript', asset.status === 200 && asset.type.includes('javascript'), asset.type); +check('real asset is immutable', asset.cache.includes('immutable'), asset.cache); + +const missing = await get('/dashboard/assets/PriceHistoryChart-OLDHASH.js'); +check('missing asset is a 404', missing.status === 404, `${missing.status} ${missing.type}`); +check('missing asset is not the SPA shell', !missing.body.includes('id="root"')); + +console.log(failed === 0 ? '\nRESULT: PASS' : `\nRESULT: FAIL (${failed})`); +process.exit(failed === 0 ? 0 : 1); From 09975fbc1d93566819af1c8037d2fdd4af2f7e90 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:40:26 -0400 Subject: [PATCH 09/23] Stamp every build with its git commit Both builds inject a proscan-build meta tag and the landing build writes /version.json with the full sha, a dirty flag and the build time. The dashboard shows the short sha in the sidebar footer. build now cleans dist/ first, and build:landing no longer empties dist/ on its own, so running it alone can't delete the dashboard (F-40). --- dashboard/src/env.d.ts | 7 +++++ dashboard/src/shell/Sidebar.tsx | 6 ++++ dashboard/src/shell/shell.css | 8 +++++- package.json | 5 ++-- scripts/build-info.mjs | 49 +++++++++++++++++++++++++++++++++ vite.config.js | 7 ++++- vite.dashboard.config.ts | 3 +- 7 files changed, 80 insertions(+), 5 deletions(-) create mode 100644 scripts/build-info.mjs diff --git a/dashboard/src/env.d.ts b/dashboard/src/env.d.ts index 6058d7f..461c60e 100644 --- a/dashboard/src/env.d.ts +++ b/dashboard/src/env.d.ts @@ -7,6 +7,13 @@ interface ImportMetaEnv { readonly VITE_FIRESTORE_EMULATOR_PORT?: string; } +declare const __PROSCAN_BUILD__: { + sha: string; + short: string; + dirty: boolean; + builtAt: string; +}; + interface ImportMeta { readonly env: ImportMetaEnv; } diff --git a/dashboard/src/shell/Sidebar.tsx b/dashboard/src/shell/Sidebar.tsx index e27478d..f547b40 100644 --- a/dashboard/src/shell/Sidebar.tsx +++ b/dashboard/src/shell/Sidebar.tsx @@ -75,6 +75,12 @@ export default function Sidebar() { > Sign out + + build {__PROSCAN_BUILD__.short} + ); diff --git a/dashboard/src/shell/shell.css b/dashboard/src/shell/shell.css index 8d5359e..1e02d6d 100644 --- a/dashboard/src/shell/shell.css +++ b/dashboard/src/shell/shell.css @@ -128,6 +128,11 @@ color: var(--gold); border-color: var(--gold); } +.sidebar__build { + font-size: 10px; + color: var(--chrome-muted); + opacity: 0.7; +} /* ── Main column ────────────────────────────────────────────────── */ .shell__main { @@ -180,7 +185,8 @@ padding: var(--space-4) 0 var(--space-5); } .sidebar__wordmark, - .sidebar__email { + .sidebar__email, + .sidebar__build { display: none; } .nav-link { diff --git a/package.json b/package.json index 09ed10a..c6ea317 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev:dashboard": "vite --config vite.dashboard.config.ts", "build:landing": "vite build", "build:dashboard": "vite build --config vite.dashboard.config.ts", - "build": "npm run build:landing && npm run build:dashboard", + "build": "npm run clean && npm run build:landing && npm run build:dashboard", "typecheck": "tsc -p dashboard/tsconfig.json --noEmit", "preview": "vite preview", "deploy": "npm run build && firebase deploy --only hosting", @@ -17,7 +17,8 @@ "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data", "seed": "node scripts/seed-emulators.mjs", "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"", - "test:hosting": "node scripts/emulators.mjs exec --only hosting \"node scripts/qa-hosting.mjs\"" + "test:hosting": "node scripts/emulators.mjs exec --only hosting \"node scripts/qa-hosting.mjs\"", + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" }, "dependencies": { "@fontsource-variable/archivo": "^5.2.8", diff --git a/scripts/build-info.mjs b/scripts/build-info.mjs new file mode 100644 index 0000000..9f23101 --- /dev/null +++ b/scripts/build-info.mjs @@ -0,0 +1,49 @@ +// Build stamp shared by both Vite configs, so anyone can tell which commit +// is live: GET /version.json, the tag, or the +// dashboard sidebar footer. + +import { execSync } from 'node:child_process'; + +function git(cmd) { + try { + return execSync(`git ${cmd}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return ''; + } +} + +let cached; +export function buildInfo() { + if (cached) return cached; + const sha = process.env.GITHUB_SHA || git('rev-parse HEAD') || 'unknown'; + // dist/ and node_modules are ignored, so this only sees source edits. + const dirty = !process.env.GITHUB_SHA && git('status --porcelain') !== ''; + cached = { + sha, + short: sha.slice(0, 7) + (dirty ? '-dirty' : ''), + dirty, + builtAt: new Date().toISOString(), + }; + return cached; +} + +/** Vite plugin: stamps the HTML, exposes __PROSCAN_BUILD__ and, when + * `emitVersionJson` is set, writes version.json next to the HTML. */ +export function buildStamp({ emitVersionJson = false } = {}) { + const info = buildInfo(); + return { + name: 'proscan-build-stamp', + config: () => ({ define: { __PROSCAN_BUILD__: JSON.stringify(info) } }), + transformIndexHtml: () => [ + { tag: 'meta', attrs: { name: 'proscan-build', content: info.short }, injectTo: 'head' }, + ], + generateBundle() { + if (!emitVersionJson) return; + this.emitFile({ + type: 'asset', + fileName: 'version.json', + source: JSON.stringify(info, null, 2) + '\n', + }); + }, + }; +} diff --git a/vite.config.js b/vite.config.js index db21395..2f0d657 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,5 @@ import { defineConfig } from 'vite'; +import { buildStamp } from './scripts/build-info.mjs'; // Phase 0 layout: // index.html -> Vite entry (the marketing landing page) @@ -9,9 +10,13 @@ import { defineConfig } from 'vite'; // The static landing page stays byte-for-byte identical to what shipped before — // this only introduces a real source -> build pipeline so the revamp (auth + /dashboard) // has somewhere clean to grow. See REVAMP_PLAN.md. +// +// emptyOutDir is off so running build:landing alone can't delete +// dist/dashboard; `npm run build` cleans dist/ first. export default defineConfig({ + plugins: [buildStamp({ emitVersionJson: true })], build: { outDir: 'dist', - emptyOutDir: true, + emptyOutDir: false, }, }); diff --git a/vite.dashboard.config.ts b/vite.dashboard.config.ts index 672fa30..7ff3a51 100644 --- a/vite.dashboard.config.ts +++ b/vite.dashboard.config.ts @@ -1,10 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { buildStamp } from './scripts/build-info.mjs'; export default defineConfig({ root: 'dashboard', base: '/dashboard/', - plugins: [react()], + plugins: [react(), buildStamp()], build: { outDir: '../dist/dashboard', emptyOutDir: true, From 1a541483c504f8e50c2d713a484e3295ae69e91a Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:41:14 -0400 Subject: [PATCH 10/23] Deploy hosting, rules and indexes together, from a clean main npm run deploy used to ship hosting only, so rules and composite index changes never reached production. It now deploys hosting, firestore:rules and firestore:indexes to proscanbot in one step, refuses a dirty tree or a branch other than main, and checks the bundle first: version.json carries a real sha and the prod bundle has no emulator wiring. Adds npm test (F-41). --- package.json | 5 +++-- scripts/check-bundle.mjs | 35 +++++++++++++++++++++++++++++++++++ scripts/deploy-check.mjs | 15 +++++++++++++++ scripts/qa-hosting.mjs | 12 ++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 scripts/check-bundle.mjs create mode 100644 scripts/deploy-check.mjs diff --git a/package.json b/package.json index c6ea317..1a3e3a7 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,14 @@ "build": "npm run clean && npm run build:landing && npm run build:dashboard", "typecheck": "tsc -p dashboard/tsconfig.json --noEmit", "preview": "vite preview", - "deploy": "npm run build && firebase deploy --only hosting", + "deploy": "node scripts/deploy-check.mjs && npm run build && node scripts/check-bundle.mjs && firebase deploy --project proscanbot --only hosting,firestore:rules,firestore:indexes", "emulators": "node scripts/emulators.mjs start", "emulators:persist": "node scripts/emulators.mjs start --import=./.emulator-data --export-on-exit=./.emulator-data", "seed": "node scripts/seed-emulators.mjs", "test:rules": "node scripts/emulators.mjs exec \"node scripts/qa-sync-rules.mjs && node scripts/qa-lead-rules.mjs && node scripts/qa-board-count.mjs\"", "test:hosting": "node scripts/emulators.mjs exec --only hosting \"node scripts/qa-hosting.mjs\"", - "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" + "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"", + "test": "npm run typecheck && npm run build && node scripts/check-bundle.mjs && npm run test:rules" }, "dependencies": { "@fontsource-variable/archivo": "^5.2.8", diff --git a/scripts/check-bundle.mjs b/scripts/check-bundle.mjs new file mode 100644 index 0000000..d4a47e2 --- /dev/null +++ b/scripts/check-bundle.mjs @@ -0,0 +1,35 @@ +// Checks the built dist/ before a deploy: the build stamp is there, and the +// production dashboard bundle has no emulator wiring (and vice versa with +// --emulator, for a VITE_USE_EMULATOR=true build). + +import { readFileSync, readdirSync } from 'node:fs'; + +const emulator = process.argv.includes('--emulator'); +let failed = 0; +const check = (name, ok, detail = '') => { + if (!ok) failed++; + console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : ` (${detail})`}`); +}; + +const version = JSON.parse(readFileSync('dist/version.json', 'utf8')); +check('dist/version.json has a commit sha', /^[0-9a-f]{40}$/.test(version.sha), version.sha); + +const html = readFileSync('dist/dashboard/index.html', 'utf8'); +const meta = html.match(/ f.endsWith('.js')) + .map((f) => readFileSync(`dist/dashboard/assets/${f}`, 'utf8')) + .join('\n'); +if (emulator) { + check('emulator bundle targets demo-proscan', js.includes('demo-proscan')); + check('emulator bundle has no production project', !js.includes('proscanbot')); +} else { + check('prod bundle targets proscanbot', js.includes('proscanbot')); + check('prod bundle has no demo project', !js.includes('demo-proscan')); + check('prod bundle has no emulator host', !js.includes('127.0.0.1')); +} + +console.log(failed === 0 ? 'RESULT: PASS' : `RESULT: FAIL (${failed})`); +process.exit(failed === 0 ? 0 : 1); diff --git a/scripts/deploy-check.mjs b/scripts/deploy-check.mjs new file mode 100644 index 0000000..1d6d8fb --- /dev/null +++ b/scripts/deploy-check.mjs @@ -0,0 +1,15 @@ +// Refuses to deploy from a tree that doesn't match a commit, so the SHA in +// /version.json always names exactly what is live. + +import { execSync } from 'node:child_process'; + +const dirty = execSync('git status --porcelain', { encoding: 'utf8' }).trim(); +if (dirty) { + console.error('deploy: commit or stash these first, so version.json names a real commit:\n' + dirty); + process.exit(1); +} +const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8' }).trim(); +if (branch !== 'main' && !process.env.PROSCAN_DEPLOY_ANY_BRANCH) { + console.error(`deploy: on "${branch}", not main. Set PROSCAN_DEPLOY_ANY_BRANCH=1 to override.`); + process.exit(1); +} diff --git a/scripts/qa-hosting.mjs b/scripts/qa-hosting.mjs index 03b0396..115f9ce 100644 --- a/scripts/qa-hosting.mjs +++ b/scripts/qa-hosting.mjs @@ -50,5 +50,17 @@ const missing = await get('/dashboard/assets/PriceHistoryChart-OLDHASH.js'); check('missing asset is a 404', missing.status === 404, `${missing.status} ${missing.type}`); check('missing asset is not the SPA shell', !missing.body.includes('id="root"')); +const version = await get('/version.json'); +if (version.status === 200) { + let sha = null; + try { + sha = JSON.parse(version.body).sha; + } catch {} + check('/version.json has a sha', typeof sha === 'string' && sha.length >= 7, version.body.slice(0, 80)); + check('/version.json is no-cache', version.cache === 'no-cache', version.cache); +} else { + check('/version.json exists', false, `${version.status}`); +} + console.log(failed === 0 ? '\nRESULT: PASS' : `\nRESULT: FAIL (${failed})`); process.exit(failed === 0 ? 0 : 1); From 95560dd327336a7e5a4431fde6d890733778ab80 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:42:05 -0400 Subject: [PATCH 11/23] Add GitHub Actions CI On every PR and push to main: npm ci, typecheck, build, the production bundle check, the rules tests and the hosting test against the emulators, and a VITE_USE_EMULATOR build check. firebase-tools is pinned to 15.8.0 (F-40, F-83). --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..974f3ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,56 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + # The Firestore emulator needs Java. + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + + - name: Cache emulator jars + uses: actions/cache@v4 + with: + path: ~/.cache/firebase/emulators + key: firebase-emulators-15.8.0 + + - run: npm ci + - run: npm install -g firebase-tools@15.8.0 + + - name: Typecheck + run: npm run typecheck + + - name: Build + run: npm run build + + - name: Check the production bundle + run: node scripts/check-bundle.mjs + + - name: Rules tests (emulator) + run: npm run test:rules + + - name: Hosting rewrites and headers (emulator) + run: npm run test:hosting + + - name: Check an emulator build + env: + VITE_USE_EMULATOR: 'true' + run: npm run build && node scripts/check-bundle.mjs --emulator From 5b4048c4f1252c9ba00efc88c4927e0b2e5b3e9b Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:44:42 -0400 Subject: [PATCH 12/23] Let the e2e run use an already-installed Chromium via QA_CHROMIUM --- scripts/qa-e2e.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/qa-e2e.mjs b/scripts/qa-e2e.mjs index 7f00273..3c42ff1 100644 --- a/scripts/qa-e2e.mjs +++ b/scripts/qa-e2e.mjs @@ -71,7 +71,8 @@ async function signIn(page) { await page.click('.auth__cta'); } -const browser = await chromium.launch(); +// QA_CHROMIUM points at an already-installed Chromium when the pinned one is missing. +const browser = await chromium.launch({ executablePath: process.env.QA_CHROMIUM || undefined }); /* ════ DESKTOP ═══════════════════════════════════════════════════════ */ const ctx = await browser.newContext({ From 3637eddab1d5e0c58df9abf5775181107aec9920 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:45:01 -0400 Subject: [PATCH 13/23] Document the emulator ports, build stamp and deploy steps --- README.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b0af950..b780c07 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,30 @@ proscan-web/ ```bash npm install # one-time -npm run dev # local dev server with HMR -npm run build # build to dist/ -npm run preview # serve the built dist/ locally -npm run deploy # build + firebase deploy --only hosting (project: proscanbot) +npm run dev # landing page dev server +npm run dev:dashboard # dashboard dev server, talks to the emulators +npm run emulators # auth + firestore + hosting emulators (project demo-proscan) +npm run seed # demo data into the running emulators +npm run build # clean dist/, then build landing + dashboard +npm test # typecheck, build, bundle check, rules tests +npm run test:hosting # rewrites and cache headers (Linux or WSL only) +npm run deploy # hosting + firestore rules + indexes to proscanbot ``` +The dashboard talks to the emulators in dev and to production in a build. +`VITE_USE_EMULATOR=true` or `=false` overrides that at build time. + +Emulator ports come from `EMU_AUTH_PORT`, `EMU_FIRESTORE_PORT`, +`EMU_HOSTING_PORT`, `EMU_UI_PORT`, `EMU_HUB_PORT` and `EMU_LOGGING_PORT` +(defaults in `firebase.json`). `npm run test:rules` and `npm run emulators` +both go through `scripts/emulators.mjs`, which applies them. + +Every build is stamped with its commit: `GET /version.json`, the +`proscan-build` meta tag, or the sidebar footer in the dashboard. `deploy` +refuses a dirty tree or a branch other than `main`, so the stamp always +names a real commit. CI (`.github/workflows/ci.yml`) runs the same checks +on every pull request. + ## Revamp Adds sign-in, saving client/competitor data, and integration with the ProScan From 005d1e1e4debb9ff6cdebb00e8d8e53ce6113efd Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:48:00 -0400 Subject: [PATCH 14/23] Drop the capped label when the server count fits the window A board with exactly 300 products filled the window and read "more not loaded" even after the count came back 300. --- dashboard/src/routes/Products.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dashboard/src/routes/Products.tsx b/dashboard/src/routes/Products.tsx index 31a331c..d0d96e1 100644 --- a/dashboard/src/routes/Products.tsx +++ b/dashboard/src/routes/Products.tsx @@ -55,11 +55,11 @@ function countLabel( total: number | null, ): string { const noun = loaded === 1 ? 'product' : 'products'; - if (!capped) { + if (!capped || (total !== null && total <= loaded)) { return visible === loaded ? `${fmt(loaded)} ${noun}` : `${fmt(visible)} of ${fmt(loaded)} ${noun}`; } const span = - total !== null && total > loaded + total !== null ? `first ${fmt(loaded)} of ${fmt(total)} ${noun}` : `first ${fmt(loaded)} ${noun}, more not loaded`; return visible === loaded ? `Showing the ${span}` : `${fmt(visible)} matches in the ${span}`; From 1032db83b3849bd379f96f59799e71594355a1be Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Thu, 24 Sep 2026 01:50:12 -0400 Subject: [PATCH 15/23] Rewrite the privacy policy to match extension 2.1 Say that scans paginate and fetch offer pages with the user's Amazon session, that AI chat sends the question and scanned products to Gemini under the user's own key, and that sync is off in 2.1. Drop the 30 and 400 day retention promises, which nothing enforces, and name the processors. --- public/privacy/index.html | 104 ++++++++++++++++++++++---------------- 1 file changed, 60 insertions(+), 44 deletions(-) diff --git a/public/privacy/index.html b/public/privacy/index.html index be397e4..263f7a6 100644 --- a/public/privacy/index.html +++ b/public/privacy/index.html @@ -4,7 +4,7 @@ Privacy Policy · ProScan - +
Nickname Type Seller / keywordCatalogCatalogLast scan Status Cadence