# AGENTS ## # SPQ-v2 Core Directive & Roadmap Execution # Objective: Implement Role-Based UI Architecture (Advisor, Tech, Mobile) ## **Your Execution Directives:** Verify Changes - `npm run build` is the closest thing to typecheck here. It runs `tsc -b && vite build`. - `npm run test` runs the full Vitest suite. - Run a focused test with `npx vitest run "src/store/__tests__/quote.test.ts"`. - `npm run lint` uses `oxlint`. As of now it reports 2 existing warnings in `src/components/ui/Toast.tsx` and `src/pages/Settings.tsx`; do not treat those as new ## regressions unless you touched them. * Permissions - Do not create or change or reset any type of passwords without my explicit permission - always ask if its ok - If I am asking you to do something that compromises my home server security please explain and ask for permission ## App Shape - This is a single Vite + React app, not a monorepo. App entrypoints are `src/main.tsx` and `src/App.tsx`. - Top-level pages are lazy-loaded in `src/App.tsx`. If you add a new page/route, wire it there. - Shared app shell is `src/components/Layout.tsx`; settings has special modal/background-route behavior plus preload via `src/lib/routePreload.ts`. ## Runtime / Backend Quirks - PocketBase is accessed only from the frontend. Central client/auth helpers live in `src/lib/pocketbase.ts`. - Prefer `useIsLoggedIn()` / `useAuth()` for reactive auth state. Do not read `pb.authStore.isValid` directly in render paths you expect to update live. - Dev proxy assumptions are in `vite.config.ts`: - `/pb` proxies to `http://127.0.0.1:8091` - `/deepseek` proxies to `https://localhost` - `vite.config.ts` excludes `pocketbase` from `optimizeDeps`; do not remove that casually. -Do not open this file without Explicitly asking for my permission before using this -- PocketBase login info is at /tmp/opencode/pb-admin.txt ## PocketBase Operations Memo - PocketBase runs in Docker. Find the current container with `docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}'` and look for the `pocketbase` container. On 2026-07-05 it was container `42d798cd1673`, named `pocketbase`, with `127.0.0.1:8091->8090/tcp`. - The app proxy in `vite.config.ts` points `/pb` to `http://127.0.0.1:8091`, which is the host port forwarded to the PocketBase container. - Inside the container, the PocketBase binary is `/usr/local/bin/pocketbase`. - Inside the container, the PocketBase data directory is `/pb_data`. - Inside the container, the active migrations directory is `/pb_data/migrations` (not `/pb_data/pb_migrations`). - The project migration source directory is `pb_migrations/` in this repo. - Before applying schema migrations, make a database backup inside the container: `docker exec cp /pb_data/data.db /pb_data/data.db.backup-before--$(date +%Y%m%d-%H%M%S)`. - Copy migration files into the container before applying them. Example: `docker cp "pb_migrations/.js" :/pb_data/migrations/`. - Apply migrations with: `docker exec /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations`. - Verify no pending migrations remain by running the same `migrate up` command again. Expected success message: `No new migrations to apply.` - PocketBase version in the container was `0.39.1` on 2026-07-05. Use field constructors such as `TextField`, `EmailField`, `SelectField`, `DateField`, and `JSONField`; do not rely on the older `CollectionField` global for new migrations. - Do not open `/tmp/opencode/pb-admin.txt` unless the user explicitly grants permission. Most migration work does not require admin credentials. ## Data Model Gotchas - `services` catalog records are consumed by `QuoteGenerator` as flat-price items. Keep `price` populated even when UI adds richer pricing inputs. - Catalog description fields are inconsistent across the app: `ServiceCatalog` reads `explanation || description`, while quote flows read `explanation`. Preserve compatibility when changing service description writes. - User-owned PocketBase collections are expected to be scoped by `userId = @request.auth.id` per `pb_migrations/1739999000004_user_scoped_api_rules.js`. Keep new collection fields and queries compatible with that model. - Quote ↔ RO links are plain text IDs, not PocketBase relation fields, by design. See `pb_migrations/1739999000006_quote_ro_link.js`. ## Shared State / Local Storage - Quote draft state is persisted in Zustand under the `spq-quote` key in `src/store/quote.ts`. Be careful changing store shape because old local data will be rehydrated. - Shop defaults belong in `src/lib/settings.ts`. Reuse `loadSettings()` / `saveSettings()` instead of duplicating fallback values in pages. - Dark mode and some UI preferences also live in localStorage; search before introducing new keys. ## Repair Order Conventions - RO totals should go through `src/lib/totals.ts`. - Some RO progress/audit features intentionally avoid PocketBase schema changes: - service sub-status lives on the `ROService` JSON shape in `src/types.ts` - RO event history is stored in the `financial` JSON blob, not a separate collection ## Tests - Vitest runs in `jsdom` with setup from `src/test/setup.ts`. - Existing tests are lightweight unit tests under `src/store/__tests__` and `src/lib/__tests__`; there is no broader integration-test harness or CI config in this repo. ## PopUp Modal Standard (Background-Location Overlay Pattern) Every pop-up / overlay modal MUST follow the pattern established by the Settings modal. There is no generic `` component; instead we use React Router's `backgroundLocation` technique so the background page stays rendered while the modal overlays it. ### Architecture Overview | Layer | File | What It Does | |-------|------|--------------| | Route setup | `src/App.tsx` | Registers the route in both the primary `` (for in-page rendering) and the secondary conditional `` (for modal overlay rendering) | | Trigger | `src/components/Layout.tsx` (desktop) and `src/components/mobile/MobileLayout.tsx` (mobile) | `` passes current `location` as `state.backgroundLocation` | | Page component | `src/pages/Settings.tsx` | Detects `isModal` from `location.state?.backgroundLocation` and conditionally wraps content in a fixed overlay | | Preload helper | `src/lib/routePreload.ts` | Lazy-load function for hover preloading (optional but recommended) | --- ### Step 1 -- App.tsx: Register Two Routes In `src/App.tsx`, you need three pieces: **A) A ``-style guard component** (see `SettingsModalRoute` at `src/App.tsx:143-153`): ```tsx function YourModalRoute() { // Add any role gating or auth checks needed return ( ); } ``` **B) A primary route** inside the main `` (line 168), nested under the appropriate layout (e.g. ``): ```tsx } /> ``` **C) A secondary modal route** inside the `{backgroundLocation && (...)}` block (line 195-199): ```tsx {backgroundLocation && ( } /> } /> {/* NEW */} )} ``` --- ### Step 2 -- Layout.tsx / MobileLayout.tsx: Pass backgroundLocation on NavLink In the sidebar/nav `` for your route, pass `state` with `backgroundLocation` set to the current location. See `src/components/Layout.tsx:81`: ```tsx ``` The `(location.state... || location)` fallback ensures the overlay works correctly even if the user is already inside another modal (unlikely but defensive). Repeat this in `src/components/mobile/MobileLayout.tsx` for the mobile nav. --- ### Step 3 -- Page Component: isModal Detection & Overlay Wrapper **A) Detect modal mode** at the top of your page component (see `Settings.tsx:479-480`): ```tsx const location = useLocation(); const modalBackground = (location.state as any)?.backgroundLocation; const isModal = Boolean(modalBackground); ``` **B) Build your normal page content** and assign it to a variable (e.g. `content` or `settingsContent`). **C) Conditional return** -- this is the critical pattern (see `Settings.tsx:1449-1471`): ```tsx if (!isModal) { return content; // normal in-page render } return (
{/* Backdrop -- clicking it closes the modal */}
); ``` ### Sizing the Modal Size is controlled 100% by Tailwind classes on the inner div (the one with `rounded-3xl`). To change the size for a specific modal: | Dimension | Class(es) | Current Default | Options | |-----------|-----------|-----------------|---------| | **Width** | `max-w-*` | `max-w-6xl` (72rem / 1152px) | `max-w-2xl` (42rem), `max-w-3xl` (48rem), `max-w-4xl` (56rem), `max-w-5xl` (64rem), `max-w-7xl` (80rem), `max-w-[900px]` (arbitrary) | | **Height** | `h-[min(...)]` | `h-[min(92vh,56rem)]` (92% viewport, 896px cap) | Adjust `92vh` and `56rem` as needed | | **Outer padding** | `p-3 sm:p-6` | 12px mobile / 24px desktop | Decrease for larger modals that need more screen real estate | **Each modal chooses its own size.** Do not extract a shared size prop -- just change the Tailwind classes on that modal's inner container div. For example, a small confirmation dialog would use `max-w-md h-auto`, while a full-width data table modal might use `max-w-7xl`. --- ### Step 4 -- Close / Escape Handler Every modal MUST implement close via backdrop click and Escape key. See `Settings.tsx:551-566`: ```tsx const closeModal = useCallback(() => { if (isModal) { navigate(-1); // pop the overlay URL, restoring the background page } else { navigate('/'); // fallback: go home from the full-page version } }, [isModal, navigate]); useEffect(() => { if (!isModal) return; const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') closeModal(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [isModal, closeModal]); ``` --- ### Step 5 -- Preloading (Optional) Add a preload helper in `src/lib/routePreload.ts` so the page chunk starts loading on hover: ```ts export const loadYourPage = () => import('../pages/YourPage'); export function preloadYourPage() { void loadYourPage(); } ``` Then use it in the NavLink: `onMouseEnter={preloadYourPage} onFocus={preloadYourPage}`. --- ### Step 6 -- Sticky Save Button (Top Bar) Every modal with a save/submit action **MUST** place the save button in a sticky bar pinned to the **top** of the scrollable content area inside the modal. No other save or submit buttons may appear anywhere else in the modal body. ```tsx
{/* Sticky save bar at top */}
{/* Modal form body -- no save buttons here */}
{children}
``` **Rule:** One save button at the top. Zero save buttons in the form body. --- ### Checklist for Adding a New Modal - [ ] `src/App.tsx`: Add primary route inside main `` - [ ] `src/App.tsx`: Add secondary modal route inside the `backgroundLocation` block (with guard component) - [ ] `src/components/Layout.tsx`: Add `` with `state.backgroundLocation` - [ ] `src/components/mobile/MobileLayout.tsx`: Add `` with `state.backgroundLocation` - [ ] `src/pages/YourPage.tsx`: Detect `isModal`, build content variable, return overlay when `isModal` - [ ] `src/pages/YourPage.tsx`: Implement `closeModal` with `navigate(-1)` + Escape key handler - [ ] Choose appropriate `max-w-*` and `h-[...]` Tailwind classes for the modal's size - [ ] Sticky save button at top of modal (only save button on the page) - [ ] Remove any duplicate save/submit buttons from the modal body - [ ] (Optional) `src/lib/routePreload.ts`: Add preload helper