Files
2026-07-12 10:01:39 -04:00

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 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 <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 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 <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>
)}

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 the backgroundLocation block (with guard component)
  • src/components/Layout.tsx: Add <NavLink> with state.backgroundLocation
  • src/components/mobile/MobileLayout.tsx: Add <NavLink> 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