initial commit
This commit is contained in:
@@ -0,0 +1,276 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user