Files
hermes-config/skills/software-development/pocketbase-react-apps/SKILL.md
T
2026-07-12 10:17:17 -04:00

32 KiB

name, description, version, metadata
name description version metadata
pocketbase-react-apps Build and debug React SPA applications with PocketBase backend on self-hosted servers — proxy setup, routing, type patterns, and common pitfalls. 1.0.0
hermes
tags related_skills platforms
pocketbase
react
typescript
self-hosted
proxy
debugging
subagent-driven-development
web-ui-repair
writing-plans
linux

PocketBase React Applications

Overview

Build, debug, and port React + TypeScript + Vite SPAs backed by a local PocketBase server. Covers the local dev proxy pattern, PB URL handling, CORS, collection schema management, and common runtime pitfalls.

When to Use

  • Building a new React app with PocketBase backend
  • Porting a vanilla JS app to React (PocketBase stays the same)
  • Debugging "The requested resource wasn't found" or 400 errors from PocketBase
  • Setting up a local dev server that proxies /pb/* to PocketBase
  • Mass-building pages with delegation (porting from original source)

PocketBase Proxy Pattern

When the React app's PB URL is set to a relative path like /pb, the production nginx config handles proxying. For local dev, a lightweight Python proxy server serves static files AND proxies /pb/* to the real PocketBase instance.

Reference: references/spa-pb-proxy.py — a drop-in Python proxy server that:

  • Serves a Vite-built dist/ directory
  • Proxies /pb/* → PocketBase on a configurable port
  • Proxies /deepseek/* → api.deepseek.com (with API key)
  • Handles CORS headers for browser requests
  • Does OPTIONS preflight
  • SPA fallback (serves index.html for client-side routes)

Reference: references/ai-integration.md — complete DeepSeek API integration pattern: nginx proxy config, ai.ts module with three functions (AI Write, Generate Priorities, AI Suggest), response parsing safety, and Ollama fallback.

Reference: references/nginx-spa-config.md — production nginx config for React SPA with PocketBase proxy, including SPA routing (try_files $uri /index.html), PB proxy (/pb/), API proxy (/api/), DeepSeek proxy, and Ollama/LMM proxy blocks.

Reference: references/react-infinite-render-loop-hook-callbacks.md — debug and fix infinite re-fetch loops when custom hooks receive inline arrow functions as callbacks. Root cause analysis + two fix patterns (ref in hook vs memoize at call site).

Reference: references/nginx-key-safety.md — safe pattern for constructing nginx configs with API keys, avoiding the Hermes secrets filter truncation pitfall. Use this whenever deploying or updating an nginx config that includes API keys.

Reference: references/spq-pb-field-mapping.md — SPQ PocketBase field name translation table: frontend types (estimatedDuration, customerType) vs actual PB columns (estimatedTime, financial JSON). Critical for saves to persist correctly.

Reference: references/public-page-phone-verification.md — low-friction identity gate for public share/approval pages using last-4-digits of the customer's phone number. No email/SMS infrastructure needed. Applied to SPQ-v2's QuoteApprove.tsx.

Reference: references/customer-db-integration.md — customer DB integration for quote, appointment, and RO forms: first/middle/last name split with shared helper library, live customer matching with "Use Existing / Continue as New" prompt, auto-create customer records on save, and customerId propagation through quotes → ROs → appointments.

Reference: references/same-build-different-origin-state.md — debugging pattern for when the same SPA build behaves differently on two domains. Root cause is typically stale persisted client state (Zustand/Redux localStorage) that fails Zod validation on the newer schema. Covers investigation checklist, diagnosis steps, and code fixes.

Critical Pitfall: Double /api Prefix

The PocketBase JS SDK constructs URLs like:

/pb/api/collections/users/auth-with-password

Do NOT add another /api when proxying. The path already includes it. Double /api/api/api/... → PocketBase 404 "The requested resource wasn't found."

# CORRECT: strip /pb prefix, keep the rest as-is
if self.path.startswith('/pb'):
    url = PB + self.path[3:]  # /pb/api/... → /api/... on PocketBase

CORS Headers Required

PocketBase's built-in CORS doesn't apply through a proxy. The proxy must emit:

Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type

Plus handle OPTIONS preflight with a 204 response.

React UI Patterns

Live Countdown Timer (1-Second Tick)

For dashboards showing time-remaining and urgency (repair orders, tickets, delivery tracking):

const [now, setNow] = useState(Date.now());
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);

useEffect(() => {
  tickRef.current = setInterval(() => { setNow(Date.now()); }, 1000);
  return () => { if (tickRef.current) clearInterval(tickRef.current); };
}, []);

Pass now to helper functions that compute time-deltas on every render:

function calcDueTime(ro): number | null {
  const baseTime = ro.writeupTime || ro.created;
  const duration = ro.estimatedDuration || 60;
  if (baseTime) return new Date(baseTime).getTime() + duration * 60000;
  return null;
}

function formatTimeRemaining(ro, now): string {
  if (ro.status === 'completed' || ro.status === 'delivered') return '—';
  const due = calcDueTime(ro);
  if (!due) return '—';
  const diffMs = due - now;
  const hours = Math.floor(Math.abs(diffMs) / 3600000);
  const minutes = Math.floor((Math.abs(diffMs) % 3600000) / 60000);
  const sign = diffMs < 0 ? '-' : '';
  return hours > 0 ? `${sign}${hours}h ${minutes}m` : `${sign}${minutes}m`;
}

function getUrgencyClass(ro, now): string {
  if (ro.status === 'completed' || ro.status === 'delivered') return '';
  const due = calcDueTime(ro);
  if (!due) return '';
  const diff = due - now;
  if (diff < 0) return 'urgent';      // past due → red
  if (diff <= 30 * 60000) return 'warning';  // ≤30min → yellow
  return '';
}

Apply urgency classes to rows:

const rowClass = urgency === 'urgent'
  ? 'bg-red-50 hover:bg-red-100 dark:bg-red-900/15'
  : urgency === 'warning'
    ? 'hover:bg-gray-50'
    : 'hover:bg-gray-50';

Flexible Time Input Parser (Prompt)

When a window.prompt() asks for time in a non-modal context, support multiple input formats so the user can type however they prefer:

const input = window.prompt('Add extra time (e.g. 30m, 1h, 1h 30m):', '15');
if (!input) return;
let totalMinutes = 0;
const hMatch = input.match(/(\d+)\s*h/i);
const mMatch = input.match(/(\d+)\s*m/i);
if (hMatch) totalMinutes += parseInt(hMatch[1]) * 60;
if (mMatch) totalMinutes += parseInt(mMatch[1]);
if (!hMatch && !mMatch) totalMinutes = parseInt(input); // plain number = minutes

Accepted inputs: "30m", "1h", "1h 30m", "90", "1:30".

Hours + Minutes Dual Input (Form Fields)

When a form needs estimated duration, provide separate Hours and Minutes inputs instead of a single "minutes" input:

const [estimatedDuration, setEstimatedDuration] = useState(60); // total minutes

<div className="flex gap-2">
  <div className="flex-1">
    <input type="number" min="0"
      value={Math.floor(estimatedDuration / 60)}
      onChange={(e) => {
        const h = parseInt(e.target.value) || 0;
        setEstimatedDuration(h * 60 + (estimatedDuration % 60));
      }}
    />
    <p className="text-xs text-gray-400">Hours</p>
  </div>
  <div className="flex-1">
    <input type="number" min="0" max="59" step="5"
      value={estimatedDuration % 60}
      onChange={(e) => {
        const m = Math.min(parseInt(e.target.value) || 0, 59);
        setEstimatedDuration(Math.floor(estimatedDuration / 60) * 60 + m);
      }}
    />
    <p className="text-xs text-gray-400">Minutes</p>
  </div>
</div>

React + PB Patterns

ToastProvider Must Wrap the App

When any page uses useToast() from a Toast context, the entire app must be wrapped in <ToastProvider>. Missing this causes silent React crashes (blank pages) when navigating to those pages.

// In App.tsx
<BrowserRouter>
  <ToastProvider>
    <Routes>...</Routes>
  </ToastProvider>
</BrowserRouter>

PocketBase Type Patterns

Define PB record interfaces in types.ts. Match PocketBase collection field names exactly. Common PB fields:

  • id — auto-generated string
  • created / updated — auto-managed timestamps (BUT may not exist on all collections!)
  • userId — relation field to _pb_users_auth_

PB User Sign-Up Flow

// In Login.tsx — a tabbed Sign In / Sign Up form
await pb.collection('users').create({
  email: email.trim(),
  password,
  passwordConfirm: confirmPassword,
  name: name.trim() || '',
  emailVisibility: true,
});

Catch errors with err instanceof Error ? err.message — the PB SDK ClientResponseError extends Error and gives accurate messages ("Failed to create record." for dupes, validation messages for weak passwords).

SMTP must be configured in PocketBase admin for verification emails to send. Until then, accounts are created but unverified (can still log in). The "verified" field on users tracks verification status.

Collection Schema Pitfall: Missing System Fields

Some PocketBase collections may lack created / updated system fields (e.g., collections created from external imports). Queries with sort=-created will 400. Workarounds:

  • Sort by -id instead (always present)
  • Add the field via admin dashboard
  • Patch the built JS as a last resort

Collection Schema Pitfall: JSON Fields as Strings

PocketBase's json type fields may be returned as strings from the REST API, not parsed objects/arrays. Always normalize after fetching:

const records = await pb.collection('repairOrders').getList(1, 200, { filter, sort: '-id' });
const normalized = records.items.map((item: any) => ({
  ...item,
  services: typeof item.services === 'string' ? JSON.parse(item.services) : (item.services || []),
}));
setOrders(normalized);

Calling .reduce() or .map() on a string throws TypeError: e.reduce is not a function. Guard with Array.isArray() or normalize at the data layer.

Deploying Changes to Production

For nginx-served React SPAs (the common self-hosted pattern), editing source files does NOT update the live site. The pipeline is:

src/ changes → tsc + vite build → dist/ updated → nginx serves new dist/

Step-by-step:

  1. Make source edits in src/
  2. Verify with npx tsc --noEmit (TypeScript)
  3. Build with npm run build (runs tsc -b && vite build)
  4. Nginx picks up new files automatically from dist/ — no reload needed unless config changed

Build Failure: Pre-existing tsc Errors in Other Files

The project's build script (npm run build) runs tsc -b && vite build. If other source files in the project have pre-existing TypeScript errors (unused imports, type mismatches, etc.), tsc -b fails and blocks the entire build — even if your changes are correct.

Fix: Run vite build directly to skip the tsc check. Vite uses esbuild for TypeScript compilation which is more lenient and only reports real type errors, not warnings:

node ./node_modules/.bin/vite build
# or
npx vite build

Note: npx vite build may be mistaken for a long-running server by tooling. Use node ./node_modules/.bin/vite build as a fallback. If that also gets flagged, use background=true + notify_on_complete=true and wait for the process.

Verification: After the build, check that the dist was updated:

ls -lt dist/assets/ | head -5

Critical Pitfall: Verify the Active Nginx Root

Never assume source and served files live in the same place. Before making source changes to a React SPA, always check where nginx actually reads from:

# sites-enabled is the active config — sites-available may be outdated
grep -rn "listen.*443 ssl" /etc/nginx/sites-enabled/  # find the active block
cat /etc/nginx/sites-enabled/shopproquote             # read the actual config

The root directive tells you which directory nginx serves. If it points to dist/ inside a project directory, the source src/ is the right place but only the built output goes live.

Verifying Active vs Stale Configs

sites-available contains all config files; sites-enabled contains symlinks to the ones nginx actually reads. A file in sites-available without a corresponding symlink in sites-enabled is not active. Always resolve symlinks:

readlink -f /etc/nginx/sites-enabled/shopproquote

Verifying What's Actually Deployed

After a build, confirm the dist was updated:

ls -lt /path/to/project/dist/assets/  # check timestamps

Or check the live page for a specific feature to confirm the new code is serving.

Runtime Verification Checklist

Before declaring a React + PB page "done," verify:

  1. TypeScript compiles: npx tsc --noEmit (zero errors)
  2. Vite build succeeds: npx vite build
  3. Browser: login → navigate to page → check for errors
  4. Page handles all states: loading (spinner/skeleton), empty (CTA), error (retry), data
  5. PB collections exist before trying to query them

Secrets Filter Truncates API Keys in Nginx Configs

When reading an API key from an nginx config file (e.g., proxy_set_header Authorization "Bearer sk-..."), the Hermes secrets filter may truncate the displayed value. If you then write that truncated value back into a new nginx config, the API key becomes corrupted and all proxied API calls fail with Authentication Fails, Your api key is invalid.

Fix: Always source API keys from environment variables, not from reading existing config files:

# ❌ Key gets truncated by secrets filter
read_file /etc/nginx/sites-enabled/shopproquote

# ✅ Safe — use the env var directly
printenv AUXILIARY_APPROVAL_API_KEY
python3 -c "import os; print(len(os.environ['AUXILIARY_APPROVAL_API_KEY']))"

When constructing nginx configs that contain API keys, build them inline using the env var value rather than copying from a truncated source. The Hermes config uses ${DEEPSEEK_API_KEY} env-var references for this exact reason.

Vite Dev Proxy Pitfall: LLM Endpoints Must Route Through Production Proxy

When a production site uses nginx to proxy /deepseek/ → api.deepseek.com (with Authorization header), the Vite dev server MUST replicate this same path. Do NOT point the Vite proxy directly to the LLM API or to Ollama:

//  WRONG  Ollama doesn't have deepseek-v4-flash, API lacks auth header
// vite.config.ts  targets Ollama
'/deepseek': { target: 'http://127.0.0.1:11434', ... }

//  WRONG  Missing auth header, DeepSeek returns 401
'/deepseek': { target: 'https://api.deepseek.com', ... }

//  CORRECT  route through local nginx which has the API key
'/deepseek': {
    target: 'https://localhost',
    changeOrigin: true,
    secure: false,                    // localhost self-signed cert
    rewrite: (path) => path.replace(/^\/deepseek/, '/deepseek'),  // preserve prefix
},

Rationale: The production nginx config adds proxy_set_header Authorization "Bearer ..." when proxying /deepseek/ to api.deepseek.com. The Vite dev proxy must go through the same nginx to inherit this header. Without it, the DeepSeek API returns 401. Ollama lacks the model entirely.

Verification:

curl -s -X POST https://localhost/deepseek/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":5}' -k

Should return a valid DeepSeek response, not a 401 or model-not-found error.

Dual-Save Pattern: Top-Level + JSON Fields for PocketBase Records

When a PocketBase collection needs BOTH queryable top-level fields (for dashboard listings, filtering) AND a complete nested JSON blob (for full object loading), save the data twice:

// In the save handler:
const data = {
    userId: pb.authStore.model?.id,
    // Top-level fields — queryable by dashboard listings
    customerName: customerInfo.name,
    vehicleInfo: customerInfo.vehicleInfo,
    roNumber: customerInfo.roNumber,
    // Nested JSON blob — holds the full object with all fields
    customerInfo: JSON.parse(JSON.stringify(customerInfo)),
    services: JSON.parse(JSON.stringify(services)),
    // ...
};
await pb.collection('quotes').create(data);

On load (edit/import), merge both sources so a field can come from either:

const rawCustomerInfo = data.customerInfo
    ? { ...data, ...(typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo) }
    : data;

The spread order ({ ...data, ...customerInfo }) means the nested JSON blob takes priority, but any top-level field fills in gaps for records that lack the nested structure (e.g., records from a previous app version).

Why: The Dashboard queries fields: 'id,customerName,vehicleInfo,roNumber,total,status' — top-level fields that map directly to PB columns. If only the nested JSON is saved, these fields are empty in query results. The dual-save keeps both paths happy.

Corollary: keep query fields in sync. When you add a new top-level field to the save handler (like roNumber), you MUST also add it to the dashboard/list query's fields parameter AND to any TypeScript interface that represents the query result. Otherwise the field won't render in the UI even though it's stored correctly.

Settings Merge Pattern: Spread Instead of Field List

When loading saved settings from a PocketBase JSON field, never use an explicit field-by-field merge. Every time a new setting is added, the field list must be manually updated — and forgetting even one field silently resets it to defaults on reload.

// ❌ WRONG — fragile field list, new fields silently lost
const merged: ShopSettings = {
  ...DEFAULT_SETTINGS,
  businessName: (d.businessName as string) || '',
  businessAddress: (d.businessAddress as string) || '',
  // ... 12 more fields, ALL must be kept in sync manually
};

// ✅ CORRECT — spread picks up all fields automatically
const merged: ShopSettings = { ...DEFAULT_SETTINGS, ...d } as ShopSettings;

Corollary: Keep DEFAULT_SETTINGS in sync. When ShopSettings gains new fields:

  1. Add them with sensible defaults to the DEFAULT_SETTINGS object in Settings.tsx
  2. Duplicate the defaults in the DEFAULT_SETTINGS object in QuoteGenerator.tsx (used as fallback when PB is unavailable)
  3. The spread-based merge in the PB load path picks them up automatically — no manual field-list update needed

Pitfall: The QuoteGenerator.tsx DEFAULT_SETTINGS and the Settings.tsx DEFAULT_SETTINGS are separate copies. They can diverge if only one is updated. Always check both files when adding settings defaults.

DeepSeek API Proxy in Nginx

To proxy /deepseek/https://api.deepseek.com/ with auth:

location /deepseek/ {
    proxy_pass https://api.deepseek.com/;
    proxy_ssl_server_name on;
    proxy_ssl_name api.deepseek.com;
    proxy_http_version 1.1;
    proxy_set_header Host api.deepseek.com;
    proxy_set_header Authorization "Bearer YOUR_KEY_HERE";
    proxy_set_header Content-Type "application/json";
    proxy_buffering off;
    proxy_read_timeout 120s;
    proxy_connect_timeout 10s;
}

Verify with: curl -s -X POST $SITE/deepseek/v1/chat/completions -H 'Content-Type: application/json' -d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"hi"}],"temperature":0,"max_tokens":20}'

SPQ-v2: Duplicate Service Row Components (Critical Search Gap)

SPQ-v2 has three separate service-row editing components that share the same feature surface but live in different files. When adding a feature that affects how services are displayed, priced, or edited, you MUST exhaustively search for and update ALL of them:

Component File Line
RO Form ServiceRow src/components/ROForm.tsx ~28
RO Detail EditableServiceRow src/pages/RODetailModal.tsx ~169
Quote Generator ServiceRow src/components/quoteGenerator/ServiceRow.tsx ~1

Failure mode: You update only the first one you find, the user tests on the other page, the feature is missing, and they have to tell you. This happened with the Full/Split pricing toggle — the RO Form was updated but the RO Detail modal's independent component was missed.

Checklist when modifying any service-row feature:

  1. Search the entire src/ tree for the pattern — don't stop at the first hit
  2. Search by component name patterns: ServiceRow, EditableServiceRow, PricingBadge, roServiceTotal
  3. Search by type: ROService, QuoteService, Service
  4. Search by prop names: onChange, onUpdate, pricingMode
  5. When a feature involves state initialization (new-service defaults), also update:
    • handleAddService in both ROForm and RODetailModal
    • handleAddCatalogService in both files
    • Catalog mapping in ServiceSearch (src/components/quoteGenerator/ServiceSearch.tsx)
    • Zod schema in src/schemas/quote.ts
    • Type definitions in src/types.ts
    • Settings defaults in src/lib/settings.ts
    • Settings UI in src/pages/Settings.tsx

Settings-merge corollary: When adding a new ShopSettings field (like defaultPricingMode), follow the Settings Merge Pattern: add it to ShopSettings in types.ts, to DEFAULT_SETTINGS in lib/settings.ts, and to the Settings page UI. The spread-based merge in loadSettingsForUser picks it up automatically.

Example (this session): The defaultPricingMode: 'full' | 'split' setting was added across 8 files:

  • types.ts — interface field
  • lib/settings.ts — default value
  • pages/Settings.tsx — UI toggle
  • components/ROForm.tsx — 4 initialization points → loadSettings().defaultPricingMode
  • pages/RODetailModal.tsx — 2 initialization points → settings.defaultPricingMode
  • components/quoteGenerator/ServiceSearch.tsxhandleAdd fallback

Common Runtime Pitfalls

Stale closure in async event handlers (useRef for latest state)

Async functions defined inside React components capture state values from the render they were created in. If the user changes state (e.g., picks a date in a picker) and THEN triggers the async function, the function may still hold the OLD state value if a re-render hasn't recreated it yet. The symptom: the action uses stale data (e.g., always uses today's date instead of the date the user selected).

Fix: Use a ref alongside the state, and always read ref.current inside async handlers:

const [selectedDate, setSelectedDate] = useState(todayISO());
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;  // keep in sync on every render

const handleExtract = async () => {
  // ✅ Always reads the latest value, even in a stale closure
  const date = selectedDateRef.current;
  // ... use `date` in API calls and data normalization
};

This pattern applies to ANY async handler that reads state that the user may have changed just before clicking. Common victims: date pickers, search queries, filter dropdowns, form inputs in modals.

Nested component input focus loss (React)

Defining a child component as a nested function inside a parent (e.g., a sub-component function inside Settings()) causes React to treat it as a new component type on every parent re-render. This destroys and recreates the DOM, causing input fields to lose focus on every keystroke.

Symptom: The user must click the input field after every single character typed.

Fix: Extract the component to file level (outside the parent function). Use React.memo to prevent unnecessary re-renders. Pass callbacks as stable props rather than capturing parent closure variables.

Diagnosis — check for duplicate/shadowed definitions: When a subagent created both a standalone prop-based component AND an inline closure-based component with the same name, the inline one wins (JavaScript scope shadowing). Search the file for function <ComponentName>( — if two exist (one inside the parent function, one outside), delete the inline version. The outer standalone component with properly passed props is the correct one.

RangeError: Invalid time value from new Date(undefined)

When PocketBase records lack a date field, new Date(undefined) throws RangeError: Invalid time value when passed to Intl.DateTimeFormat().format(). Fix: Guard all date formatters with if (!iso) return '—';.

Missing onClick on buttons

Subagent-generated code often produces buttons without onClick handlers. Verify every button in generated code is wired.

colSpan Must Match Column Count on Expandable Tables

When a table has expandable rows (e.g., <td colSpan={N}> for an expanded detail section), the colSpan must be updated EVERY time columns are added, removed, or reorganized. Missing this causes layout breaks — the expanded content spills outside the table bounds.

Checklist when changing columns:

  1. Count the <th> headers
  2. Update EVERY <td colSpan={N}> in FragmentRow (or equivalent) to match
  3. Search for ALL occurrences — there may be both desktop and mobile views
// BEFORE: 7 columns (RO#, Customer, Vehicle, Status, Write-up, Time Remaining, chevron)
<td colSpan={7}>

// AFTER: added "Promised Time" column → 8 columns
<td colSpan={8}>

Critical Pitfall: Frontend Type Field Names ≠ PB Column Names

PocketBase silently drops unknown fields. When you save estimatedDuration: 75 but the PB column is named estimatedTime, the save APPEARS to succeed (no error) but the data is never persisted. On refresh, it's gone.

The pattern:

  1. Define your app's field names in TypeScript types however you want (e.g., estimatedDuration: number in minutes)
  2. On load: Normalize PB field names to app names in fetchOrders/fetchData
  3. On save: Translate app names back to PB field names before calling pb.collection().update()
  4. For inline saves (add-time, status toggle, customer-type toggle): Save directly using PB field names

Always verify the actual PB schema before assuming field names match. Query SQLite directly:

sqlite3 /path/to/pb_data/data.db "PRAGMA table_info(repairOrders)"

See references/spq-pb-field-mapping.md for the complete SPQ translation table.

Check-In Workflow (Appointment → Repair Order)

When converting a scheduled Appointment into an active Repair Order, use a dedicated modal that:

  1. Pre-fills all appointment data (customer name, phone, email, vehicle, advisor, notes)
  2. Prompts for missing RO fields: VIN, mileage, estimated duration (hours+minutes), customer type (waiter/drop-off)
  3. On submit: Creates a new RO with status: 'active', writeupTime: new Date().toISOString(), and translated field mapping
  4. Post-submit: Updates the appointment to status: 'checked_in', closes the modal

The pattern is:

// Main component state
const [showCheckInModal, setShowCheckInModal] = useState(false);
const [checkInAppointment, setCheckInAppointment] = useState<Appointment | null>(null);

// Handler opens modal
const handleCheckIn = (appt: Appointment) => {
  setCheckInAppointment(appt);
  setShowCheckInModal(true);
};

// Handler creates RO + updates appointment
const handleCheckInSubmit = async (data: CheckInFormData) => {
  // 1. Create RO with writeupTime, estimatedTime, customerType in financial JSON
  // 2. Update appointment to checked_in
  // 3. Refresh appointments list
};

Pass onCheckIn={handleCheckIn} through the component chain (DateGroup → AppointmentCard). The card shows a dedicated "Check In" button (only for scheduled status) that calls onCheckIn() instead of the old inline status change.

Customer DB Integration Pattern

When a quote, appointment, or repair order form needs to link customer entries to a customer database, use this three-layer pattern (see references/customer-db-integration.md for full SPQ-v2 implementation):

Layer 1 — UI: Search + Split-Name Form

Top section shows a customer search bar (debounced, queries customers collection by name or phone). Selecting a result fills all fields and locks them behind a green "linked" badge. Below the search bar (visible when unlinked), show first/middle/last name inputs instead of a single "name" field — this preserves name parts for database queries and display formatting.

Layer 2 — Duplicate Detection

When the user types first+last name or phone, a separate debounced check fires against the customers collection. If a match is found, show a non-blocking warning banner with "Use Existing" (fills data + sets customerId) and "Create New" (dismisses warning, keeps typing).

Layer 3 — Auto-Create on Save

On save (form submit, checkout, share), if no customerId is linked:

  1. Try phone match (strongest identity signal)
  2. Try exact name match
  3. If neither found, pb.collection('customers').create() with the form data
  4. Store the returned ID as customerId on the primary record

Propagate customerId through related records (orders → invoices, quotes → ROs) so the customer detail view in the separate Customers page can show complete history.

Auto-Compose Name in Store

The Zustand store's setCustomerInfo action auto-composes name from firstName + middleName + lastName, AND backward-compat splits a passed name into first/last when no split fields are provided. This keeps existing callers working while new code uses the split fields.

Live Countdown Timer (1-Second Tick)

When porting multiple pages/features from a vanilla JS app to React + PB, use parallel fan-out with delegate_task:

  1. First: Read the original source to understand features and data shapes
  2. Infrastructure: Set up routes, nav, types, PB collection schemas (you do this)
  3. Dispatch: All pages in parallel batches of 3 via delegate_task with tasks array
  4. Context: Each subagent gets: original source context, project structure, types, PB patterns to follow
  5. After: Build, test, fix missing ToastProvider/collections

Each subagent should get:

  • Exact file path to write
  • Original source summary (feature set, not raw code)
  • Project patterns to follow (imports, styling, state management)
  • PocketBase collection names and field schemas
  • Types from the project's types.ts

Collection Management

Creating Collections (requires admin)

PocketBase v0.23+ renamed "admins" to "superusers." The auth endpoint changed:

PB Version Endpoint Notes
≤ v0.22 POST /api/admins/auth-with-password Legacy path
≥ v0.23 POST /api/collections/_superusers/auth-with-password New path (old path returns 404)

The v0.39 admin UI at /_/ uses the new SDK under the hood. If curl to the legacy endpoint returns {"status":404,"message":"The requested resource wasn't found."}, try the _superusers collection path instead.

Verification:

curl -s -X POST 'http://localhost:8091/api/collections/_superusers/auth-with-password' \
  -H 'Content-Type: application/json' \
  -d '{"identity":"admin@example.com","password":"secret"}'
# Returns 400 "Failed to authenticate" if credentials wrong → endpoint is correct
# Returns 404 → wrong endpoint

Collection creation via REST API:

POST /api/collections
{
  "name": "collection_name",
  "type": "base",
  "schema": [
    {"name": "field_name", "type": "text"},
    {"name": "userId", "type": "relation",
     "options": {"collectionId": "_pb_users_auth_", "cascadeDelete": false, "maxSelect": 1}}
  ]
}

Fallback When Admin Unavailable

Pages should handle missing collections gracefully: catch 404 errors and show an empty state rather than crashing. Each page should have loading → error → empty → data state machine.