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 diff --git a/.gitignore b/.gitignore index d1851dd..e3dddfb 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ dist/ # Firebase cache .firebase/ +# Temp config written by scripts/emulators.mjs +.firebase.emulators.json + # Firebase emulator persisted data (npm run emulators:persist) .emulator-data/ 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 diff --git a/dashboard/src/auth/AuthGate.tsx b/dashboard/src/auth/AuthGate.tsx index 25691a7..9bbdf60 100644 --- a/dashboard/src/auth/AuthGate.tsx +++ b/dashboard/src/auth/AuthGate.tsx @@ -1,10 +1,12 @@ import { useEffect, useState, type ReactNode } from 'react'; import { doc, getDoc, serverTimestamp, setDoc } from 'firebase/firestore'; -import type { User } from 'firebase/auth'; -import { db } from '../firebase'; +import { signOut, type User } from 'firebase/auth'; +import { auth, db } from '../firebase'; import { useAuthUser, WorkspaceProvider } from '../lib/hooks'; import { DEFAULT_WORKSPACE_SETTINGS } from '../lib/types'; import { RadarIcon } from '../components/EmptyState'; +import Button from '../components/Button'; +import ErrorState from '../components/ErrorState'; import SignIn from './SignIn'; import './auth.css'; @@ -59,11 +61,14 @@ async function bootstrapWorkspace(user: User): Promise { } /** Wraps the whole app: splash while auth resolves, SignIn when signed out, - * bootstrap-then-children when signed in. Also mounts the single shared - * workspace listener (WorkspaceProvider). */ + * bootstrap-then-children when signed in. A failed bootstrap blocks with a + * retry screen that can also sign out (the Sidebar isn't mounted yet). + * Also mounts the single shared workspace listener. */ export default function AuthGate({ children }: { children: ReactNode }) { const { user, loading } = useAuthUser(); const [bootstrappedUid, setBootstrappedUid] = useState(null); + const [bootError, setBootError] = useState(null); + const [attempt, setAttempt] = useState(0); const uid = user?.uid ?? null; @@ -73,22 +78,38 @@ export default function AuthGate({ children }: { children: ReactNode }) { return; } let cancelled = false; + setBootError(null); bootstrapWorkspace(user) + .then(() => { + if (!cancelled) setBootstrappedUid(user.uid); + }) .catch((err) => { - // Non-fatal: the app degrades to live-query errors rather than a - // hard wall; first write retries on next sign-in. console.error('[proscan] workspace bootstrap failed', err); - }) - .finally(() => { - if (!cancelled) setBootstrappedUid(user.uid); + if (!cancelled) setBootError(err); }); return () => { cancelled = true; }; - }, [user]); + }, [user, attempt]); if (loading) return ; if (!user || !uid) return ; + if (bootError) { + return ( +
+ setAttempt((n) => n + 1)} + extra={ + + } + /> +
+ ); + } if (bootstrappedUid !== uid) return ; return {children}; 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/env.d.ts b/dashboard/src/env.d.ts new file mode 100644 index 0000000..461c60e --- /dev/null +++ b/dashboard/src/env.d.ts @@ -0,0 +1,19 @@ +/// + +interface ImportMetaEnv { + /** 'true' or 'false'. Unset means: emulator in dev, production in a build. */ + readonly VITE_USE_EMULATOR?: string; + readonly VITE_AUTH_EMULATOR_PORT?: string; + 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/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 fdfc499..bb6afc4 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 ? ( - {/* 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{' '} @@ -204,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 */}
@@ -223,6 +224,10 @@ export default function HistoryDrawer({ wid, asin, onClose }: HistoryDrawerProps {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/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/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..d6a23a8 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) ─────────────────── */ @@ -126,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; @@ -190,9 +193,11 @@ function WatchlistRow({ {ident} - - {compactNumber(source.catalogSize)} - + {showCatalog ? ( + + {compactNumber(source.catalogSize)} + + ) : null} {relativeTime(source.lastScrapedAt)} @@ -288,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 (
@@ -297,7 +305,7 @@ export default function WatchlistTable({ wid, sources }: WatchlistTableProps) { - + {showCatalog ? : null} @@ -312,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/firebase.ts b/dashboard/src/firebase.ts index 79029b7..a883757 100644 --- a/dashboard/src/firebase.ts +++ b/dashboard/src/firebase.ts @@ -2,9 +2,17 @@ import { initializeApp } from 'firebase/app'; import { connectAuthEmulator, getAuth, GoogleAuthProvider } from 'firebase/auth'; import { connectFirestoreEmulator, getFirestore } from 'firebase/firestore'; -// In dev the app runs entirely against the local emulator suite. The 'demo-' -// project-id prefix is reserved by Firebase for emulator-only projects, so the -// SDK can never reach production from a dev build. +// Vite inlines VITE_* values at build time, so this is a literal in the +// bundle. `VITE_USE_EMULATOR=true npm run build:dashboard` gives a prod-shaped +// bundle that talks to the emulators, and dev can opt out with =false. +const flag = import.meta.env.VITE_USE_EMULATOR; +export const USE_EMULATOR = flag === 'true' || (flag !== 'false' && import.meta.env.DEV); + +const AUTH_PORT = Number(import.meta.env.VITE_AUTH_EMULATOR_PORT || 9099); +const FIRESTORE_PORT = Number(import.meta.env.VITE_FIRESTORE_EMULATOR_PORT || 8080); + +// The 'demo-' project-id prefix is reserved by Firebase for emulator-only +// projects, so this config can never reach production. const devConfig = { projectId: 'demo-proscan', apiKey: 'demo-key', @@ -22,7 +30,7 @@ const prodConfig = { messagingSenderId: '886322190589', }; -export const app = initializeApp(import.meta.env.DEV ? devConfig : prodConfig); +export const app = initializeApp(USE_EMULATOR ? devConfig : prodConfig); export const auth = getAuth(app); // Single shared Google provider. `select_account` forces the chooser every @@ -33,7 +41,7 @@ export const googleProvider = new GoogleAuthProvider(); googleProvider.setCustomParameters({ prompt: 'select_account' }); export const db = getFirestore(app); -if (import.meta.env.DEV) { - connectAuthEmulator(auth, 'http://127.0.0.1:9099', { disableWarnings: true }); - connectFirestoreEmulator(db, '127.0.0.1', 8080); +if (USE_EMULATOR) { + connectAuthEmulator(auth, `http://127.0.0.1:${AUTH_PORT}`, { disableWarnings: true }); + connectFirestoreEmulator(db, '127.0.0.1', FIRESTORE_PORT); } 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..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, @@ -142,9 +143,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 }); }, ); @@ -158,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 { @@ -196,6 +224,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 +243,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 +267,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 +279,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/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/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/Products.tsx b/dashboard/src/routes/Products.tsx index 07b0ad0..d0d96e1 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, @@ -29,6 +30,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'; @@ -38,12 +40,29 @@ 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'; -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 || (total !== null && total <= loaded)) { + return visible === loaded ? `${fmt(loaded)} ${noun}` : `${fmt(visible)} of ${fmt(loaded)} ${noun}`; + } + const span = + 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}`; } export default function Products() { @@ -59,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], @@ -98,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(), @@ -145,8 +181,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) @@ -163,10 +203,7 @@ export default function Products() { ); } else if (products.error) { body = ( - + ); } else if (scopedRows.length === 0) { if (view === 'movers') { 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/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() { + ); } 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/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/firestore.rules b/firestore.rules index a2beb18..a120a80 100644 --- a/firestore.rules +++ b/firestore.rules @@ -52,9 +52,11 @@ service cloud.firestore { request.resource.data.latest.dayKey is string && (!('p' in request.resource.data.latest) || request.resource.data.latest.p is int) && (!('v' in request.resource.data.latest) || request.resource.data.latest.v is int))) + // A lead with no stored stage is 'new' (the drawer never writes it). && (!('lead' in request.resource.data) - || request.resource.data.lead.stage in - ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived']); + || (request.resource.data.lead is map + && request.resource.data.lead.get('stage', 'new') in + ['new', 'reviewing', 'approved', 'purchased', 'rejected', 'archived'])); match /history/{seriesId} { allow read: if member(wid); diff --git a/index.html b/index.html index 3b005f5..84429b4 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,13 @@ - ProScan – Competitor Price Tracking for Amazon Sellers + ProScan – Amazon Product Research in Your Browser +
Nickname Type Seller / keywordCatalogCatalogLast scan Status Cadence