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

277 lines
13 KiB
Markdown
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`):
```tsx
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>`):
```tsx
<Route path="/your-path" element={<YourPage />} />
```
**C) A secondary modal route** inside the `{backgroundLocation && (<Routes>...</Routes>)}` block (line 195-199):
```tsx
{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`:
```tsx
<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`):
```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 (
<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`:
```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
<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