13 KiB
Executable File
AGENTS
# SPQ-v2 Core Directive & Roadmap Execution
Objective: Implement Role-Based UI Architecture (Advisor, Tech, Mobile)
Your Execution Directives:
Verify Changes
npm run buildis the closest thing to typecheck here. It runstsc -b && vite build.npm run testruns the full Vitest suite.- Run a focused test with
npx vitest run "src/store/__tests__/quote.test.ts". npm run lintusesoxlint. As of now it reports 2 existing warnings insrc/components/ui/Toast.tsxandsrc/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.tsxandsrc/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 viasrc/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 readpb.authStore.isValiddirectly in render paths you expect to update live. - Dev proxy assumptions are in
vite.config.ts:/pbproxies tohttp://127.0.0.1:8091/deepseekproxies tohttps://localhost
vite.config.tsexcludespocketbasefromoptimizeDeps; 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 thepocketbasecontainer. On 2026-07-05 it was container42d798cd1673, namedpocketbase, with127.0.0.1:8091->8090/tcp. - The app proxy in
vite.config.tspoints/pbtohttp://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 <container_id> cp /pb_data/data.db /pb_data/data.db.backup-before-<feature>-$(date +%Y%m%d-%H%M%S). - Copy migration files into the container before applying them. Example:
docker cp "pb_migrations/<migration_file>.js" <container_id>:/pb_data/migrations/. - Apply migrations with:
docker exec <container_id> /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations. - Verify no pending migrations remain by running the same
migrate upcommand again. Expected success message:No new migrations to apply. - PocketBase version in the container was
0.39.1on 2026-07-05. Use field constructors such asTextField,EmailField,SelectField,DateField, andJSONField; do not rely on the olderCollectionFieldglobal for new migrations. - Do not open
/tmp/opencode/pb-admin.txtunless the user explicitly grants permission. Most migration work does not require admin credentials.
Data Model Gotchas
servicescatalog records are consumed byQuoteGeneratoras flat-price items. Keeppricepopulated even when UI adds richer pricing inputs.- Catalog description fields are inconsistent across the app:
ServiceCatalogreadsexplanation || description, while quote flows readexplanation. Preserve compatibility when changing service description writes. - User-owned PocketBase collections are expected to be scoped by
userId = @request.auth.idperpb_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-quotekey insrc/store/quote.ts. Be careful changing store shape because old local data will be rehydrated. - Shop defaults belong in
src/lib/settings.ts. ReuseloadSettings()/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
ROServiceJSON shape insrc/types.ts - RO event history is stored in the
financialJSON blob, not a separate collection
- service sub-status lives on the
Tests
- Vitest runs in
jsdomwith setup fromsrc/test/setup.ts. - Existing tests are lightweight unit tests under
src/store/__tests__andsrc/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 <Modal> 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 <Routes> (for in-page rendering) and the secondary conditional <Routes> (for modal overlay rendering) |
| Trigger | src/components/Layout.tsx (desktop) and src/components/mobile/MobileLayout.tsx (mobile) |
<NavLink> 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 <SettingsModalRoute>-style guard component (see SettingsModalRoute at src/App.tsx:143-153):
function YourModalRoute() {
// Add any role gating or auth checks needed
return (
<RequireAuth>
<YourPage />
</RequireAuth>
);
}
B) A primary route inside the main <Routes location={backgroundLocation || location}> (line 168), nested under the appropriate layout (e.g. <OwnerLayout>):
<Route path="/your-path" element={<YourPage />} />
C) A secondary modal route inside the {backgroundLocation && (<Routes>...</Routes>)} block (line 195-199):
{backgroundLocation && (
<Routes>
<Route path="/settings" element={<SettingsModalRoute />} />
<Route path="/your-path" element={<YourModalRoute />} /> {/* NEW */}
</Routes>
)}
Step 2 -- Layout.tsx / MobileLayout.tsx: Pass backgroundLocation on NavLink
In the sidebar/nav <NavLink> for your route, pass state with backgroundLocation set to the current location. See src/components/Layout.tsx:81:
<NavLink
to="/your-path"
state={{
backgroundLocation:
(location.state as { backgroundLocation?: unknown } | null)
?.backgroundLocation || location,
}}
onClick={handleNavClick}
onMouseEnter={preloadYourPage} // optional
onFocus={preloadYourPage} // optional
...
>
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):
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):
if (!isModal) {
return content; // normal in-page render
}
return (
<div className="fixed inset-0 z-50">
{/* Backdrop -- clicking it closes the modal */}
<button
type="button"
aria-label="Close"
className="absolute inset-0 bg-gray-950/45 backdrop-blur-sm"
onClick={closeModal}
/>
{/* Centered modal container */}
<div className="absolute inset-0 flex items-center justify-center p-3 sm:p-6">
{/* THE SIZING DIV -- adjust Tailwind classes here to control size */}
<div className="relative flex h-[min(92vh,56rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 shadow-2xl dark:border-gray-700 dark:bg-gray-950">
<div className="flex-1 overflow-y-auto">
{content}
</div>
</div>
</div>
</div>
);
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:
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:
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.
<div className="flex-1 overflow-y-auto">
{/* Sticky save bar at top */}
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-950">
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleSave}
>
Save
</button>
</div>
{/* Modal form body -- no save buttons here */}
<div className="p-6">
{children}
</div>
</div>
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<Routes>src/App.tsx: Add secondary modal route inside thebackgroundLocationblock (with guard component)src/components/Layout.tsx: Add<NavLink>withstate.backgroundLocationsrc/components/mobile/MobileLayout.tsx: Add<NavLink>withstate.backgroundLocationsrc/pages/YourPage.tsx: DetectisModal, build content variable, return overlay whenisModalsrc/pages/YourPage.tsx: ImplementcloseModalwithnavigate(-1)+ Escape key handler- Choose appropriate
max-w-*andh-[...]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