---
name: shop-pro-quote
description: Develop and maintain the ShopProQuote auto repair shop management app — PocketBase backend, vanilla JS frontend (v1) and React/Vite SPA frontend (v2), Tailwind CSS, OCR scan features.
---

# ShopProQuote Development

PocketBase-backed auto repair shop management web app at `/mnt/seagate8tb/Websites/ShopProQuote/`.

## Stack

- **Backend**: PocketBase v0.39.1 (ghcr.io/muchobien/pocketbase:latest) on Docker at 127.0.0.1:8091
- **v1 Frontend** (legacy, live at port 443): Vanilla JS by Mike. Tailwind via CDN — do NOT attempt build-step Tailwind (dynamic classes in template literals would be purged). See `references/local-testing.md`.

**Conventions:** Phone #s stored as digits, displayed `555-555-5555` via `src/lib/phone.ts` (see `references/phone-formatting.md`). New quote services default to Pending. RO form extracted to `src/components/ROForm.tsx`. RO Details tab: see `references/ro-details-tab.md`. RO Detail Modal (4-tab viewer): see `references/ro-detail-modal.md`.
- **Auth**: users collection only (no superuser for app login; `pb.authStore.model.collectionName === 'users'`). Test user creation — see `references/pocketbase-test-users.md`.. Test user creation — see `references/pocketbase-test-users.md`.
- **Reverse proxy**: nginx on 443 SSL. DeepSeek via `/deepseek/` proxy.
- **Local dev**: Vite — see `references/local-dev-server.md` for proxy config, PocketBase SDK `import` keyword fix, admin API paths.
- **PDF gen**: jspdf 4.x `src/lib/pdf.ts` — see `references/pdf-generation.md` for array-text pitfall and page-break rules.
- **Served at**: `https://grajmedia.duckdns.org`

## Service Data Model

Services are stored in PocketBase `services` collection (pbc_863811952) with fields:
`id, userId, name, description, price, category, duration, maintenanceInterval, technicianNotes, createdAt, updatedAt, recommendation, explanation`

- `recommendation` = the REASON (e.g., "pads worn to 2mm") — displayed in blue uppercase in service cards
- `explanation` = the customer-facing EXPLANATION text — displayed in gray italic in service cards, used in PDFs, and populated by AI Write
- `maintenanceInterval` = mileage interval in miles (e.g., 5000 for oil changes). Optional. Used by AI Suggest and displayed in quote builder as "📅 Due every X,XXX miles — Your [vehicle] has YY,YYY miles"
- `technicianNotes` = internal-only notes for AI context. NEVER shown in customer-facing output (PDF, print). Shown in service cards as collapsible "🔧 Tech Notes (Internal)" details element and in Settings service list as "🔧 Notes: ..."
- These fields were added post-migration via PATCH to the PocketBase schema.

### State Normalization

When loading services into `quoteStateManager.setSelectedServices()`, normalize defaults:
```javascript
setSelectedServices(services) {
    const normalized = services.map(s => ({
        ...s,
        applyShopCharge: s.applyShopCharge !== false  // default ON
    }));
    this.updateState({ selectedServices: normalized }, ['selectedServices']);
}
```
Without this, services loaded from saved quotes lack `applyShopCharge` and render unchecked.

## Files (key pages)

See `references/file-map.md` for the full table.
Supporting references: `references/ai-features.md`, `references/deepseek-proxy.md`, `references/spq-v2-pitfalls.md` (query/React/date/nginx pitfalls), `references/pdf-layout-patterns.md` (jsPDF fill-before-text, dynamic heights, color visibility), `references/dropdown-teleport-pattern.md` (portal to dropdown-root), `references/pdf-generation.md` (jsPDF layout pitfalls), `references/ui-pitfalls.md` (dropdown stacking context, state clearing).

| Page | HTML | JS | Notes |
|------|------|----|-------|
| Appointments | `appointments.html` | `appointments.js` | OCR scan, create/edit/list |
| Repair Orders | `repair-orders.html` | `repair-orders.js`, `repair-orders-utils.js` | Tabs, modals, settings |
| Customers | `customers.html` | `customers.js` | User email population on auth |
| Login | `login.html` | — | Auth via PocketBase adapter |
| Shared | — | `pocketbase.js`, `ui.js`, `style.css`, `shared/`, `auto-save-draft.js` | Adapter, notifications, modal manager, form draft persistence |

## Architecture Notes

- `pocketbase.js` adapter maps PocketBase REST to Firebase-compatible API surface (`collection()`, `addDoc()`, `getDocs()`, `onAuthStateChanged()`, etc.)
- `shared/debug.js` — DEBUG-gated logging utility. Exports `log()` (only logs when `DEBUG=true`), `warn()`, `error()`. All JS files import from here instead of using raw `console.log`. Set `DEBUG = false` for production.
- `shared/skeleton.js` — Exports `skeletonCard(lines)` and `skeletonRow()` for pulse-animation loading placeholders. Replace raw "Loading..." text patterns with these.
- `shared/modal-manager.js` — Centralized modal utilities. Exports `confirmDialog(message, title)` (themed replacement for native `confirm()` — returns Promise<boolean>), `registerAccountSettingsModal()`, and `registerSiteSettingsModal()` for deduplicated settings handling across pages.
- `shared/confirm.js` — Exports `confirmDialog(message, title)` that replaces native `confirm()` with a styled, dark-mode-aware modal. Returns Promise<boolean>. All `confirm()` calls in the codebase have been replaced with `await confirmDialog(...)`.
- `api.js` exports `streamAIResponse(systemPrompt, userContent, onChunk, options)` for streaming LLM responses with typewriter rendering. Supports `AbortSignal` via `options.signal` for cancellation. Used by Daily Briefing.
- Module scripts (`type="module"`) use `import` from `pocketbase.js`. Inline `<script>` blocks (not modules) need globals exposed via `window.xxx = ...` or local helpers.
- Settings persisted via `settings` collection in PocketBase with `data` JSON field + `userId` + `name` for upsert.
- Model/agent routing for this project: main agent uses orchestrator model, subagents use worker model (configured in Hermes).
- **Inline IIFE modal handlers**: `repair-orders.html` has a capture-phase click handler (lines 1376-1427) that defines `window.openModal`/`window.closeModal` and intercepts modal button clicks BEFORE module code. See `references/inline-iife-modal-handlers.md` for the full event flow, selector escaping fix, and diagnostic approach.
- **PocketBase real-time subscriptions**: `onSnapshot()` in pocketbase.js uses PocketBase SSE subscriptions (`pb.collection(name).subscribe('*', callback)`) for live dashboard updates. Returns a proper unsubscribe function. Dashboard subscriptions are set up in `setupRealtimeSubscriptions()` and cleaned up on page unload. Collections subscribed: repairOrders, appointments, tasks, quotes.

## User Preferences

- **PREFER LOCAL SOLUTIONS OVER EXTERNAL API DEPENDENCIES.** If a feature can work without an external API, build it that way. Rule-based parsers, local LLMs (Ollama on RTX 2080 Ti FE 11GB), and regex are preferred over paid/external API calls. Exception: the appointment scan screenshot feature defaults to DeepSeek V4 Flash (cloud) by user preference — local Ollama remains available as a fallback in the model dropdown. The cost difference is negligible (~$0.15/yr vs ~$0.55/yr GPU power). See `references/deepseek-proxy-cost.md`.
- **Questions vs actions**: When the user asks a question (\"is there a database...?\", \"what are my options...?\"), answer it without taking action. Only implement when explicitly authorized (\"yes implement this\", \"go ahead\").
- Direct, concise responses. Stop and plan before executing.
- Prefer container restart over destroy-and-recreate (`docker compose restart`).
- Ask before container lifecycle operations.
- Dislikes VPNs on mobile; prefers nginx reverse proxy.

### Appointment Screenshot Extraction Architecture

**Two-stage pipeline: Tesseract.js OCR (client-side) → DeepSeek V4 Flash (cloud, default) or qwen2.5:14b (local fallback)**.

**CSP requirements for Tesseract.js v5** — the `<meta>` CSP tag must include:
- `script-src ... 'unsafe-eval'` (WASM compilation)
- `connect-src ... https://cdn.jsdelivr.net blob:` (WASM + traineddata downloads, worker comms)
- `worker-src blob:` (inline Web Workers)

Without these, Tesseract silently hangs during initialization ("Running OCR..." forever).

Vision models (LLaVA 7B/13B) were tried and abandoned — they struggle with dense tabular grids, hallucinate names, and can't follow rigid JSON schema instructions reliably.

**Current flow** (`appointments.html` inline script, ~line 1512):
1. **Tesseract OCR** with adaptive upscale (2×/3×) + mild contrast enhancement + PSM 4 (single column) → raw text
2. **Text LLM** receives the OCR text + detailed system prompt → returns JSON array

**Dual LLM backend** (added 2026-06-15): The scan feature now supports two backends selected via the model dropdown:
- **`deepseek-v4-flash`** (DEFAULT) → VPS nginx proxy at `/deepseek/v1/chat/completions` → `api.deepseek.com`. Uses OpenAI format, non-thinking mode (`thinking: {type: 'disabled'}`). Response parsed from `choices[0].message.content`. Cost: ~$0.0004/scan.
- **`qwen2.5:14b`** (Local fallback) → `/llm/api/chat` → Ollama at 127.0.0.1:11434. Uses Ollama format. Response parsed from `message.content`. Free but needs GPU VRAM.

The code auto-detects which backend to use based on the selected model, switching endpoint, request format, and response parsing accordingly. Both paths share the same retry logic (auto-retry on 502/503 with 5s delay for cold model loading).

**Model selector**: The dropdown in the scan modal (`id="vision-model-select"`) defaults to `deepseek-v4-flash`. The dropdown ID is a legacy name — it now selects text models, not vision models.

**Why this is better**: Tesseract handles pixel-to-text (what OCR is good at), and qwen2.5:14b handles structure understanding (what text LLMs are good at). Neither step requires a vision model.

**OCR preprocessing — critical for dealership schedule screenshots**: These screenshots are very wide (~1780px) with many narrow columns, making text hard to OCR. Three preprocessing steps dramatically improve accuracy:
- **Adaptive upscale**: Upscale 3× for small images (<1MP, ~1000×1000), 2× for larger screenshots. This gives Tesseract enough pixel data per character while avoiding the massive canvases (5760×3240) that choke browser-side OCR. The 2× threshold was added after discovering 3× on full-HD screenshots caused 60-180+ second hangs or silent Web Worker crashes.
- **Mild contrast enhancement** (NOT aggressive binarization): Only push extremes (>240→white, <30→black). Leave mid-tones alone — Tesseract handles them better than a hard binary cutoff at 128, which destroys anti-aliased edges on small text
- **PSM 4 (single column)**: `tessedit_pageseg_mode: '4'` tells Tesseract the page has variable-sized text blocks in a single column — critical for the dealership schedule's row-per-appointment layout. Without PSM 4, Tesseract defaults to auto mode which splits each appointment row into multiple disconnected chunks

**OCR + LLM pipeline** (`appointments.html` inline script):
```javascript
// Step 1: Adaptive upscale + mild contrast + PSM 4
const preprocessed = await new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');
        // Upscale for better OCR — 2x for large images, 3x for small ones
        const scale = (img.width * img.height > 1000000) ? 2 : 3;
        canvas.width = img.width * scale;
        canvas.height = img.height * scale;
        ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
        
        // Mild contrast enhancement (not aggressive binarization)
        const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
        const data = imageData.data;
        for (let i = 0; i < data.length; i += 4) {
            const avg = (data[i] + data[i+1] + data[i+2]) / 3;
            if (avg > 240) data[i] = data[i+1] = data[i+2] = 255;
            else if (avg < 30) data[i] = data[i+1] = data[i+2] = 0;
        }
        ctx.putImageData(imageData, 0, 0);
        
        canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas toBlob failed')), 'image/png');
    };
    img.onerror = () => reject(new Error('Image load failed'));
    img.src = URL.createObjectURL(file);
});

// Tesseract with FULL phase logging + 90s timeout
// CRITICAL: Tesseract.js v5 has 4 silent init phases (loading core, initializing,
// loading traineddata, initializing API) where no progress fires. The logger
// MUST show ALL phases, not just "recognizing text", or the user sees
// "Running OCR..." with zero feedback during the 10-30s init.
const ocrResult = await Promise.race([
    Tesseract.recognize(preprocessed, 'eng', {
        logger: m => {
            console.log(`[OCR] ${m.status} ${m.progress ? Math.round(m.progress*100)+'%' : ''}`);
            if(m.status === 'recognizing text') {
                progressText.textContent = `OCR: ${Math.round(m.progress*100)}%`;
            } else {
                progressText.textContent = `Tesseract: ${m.status}...`;
            }
        },
        tessedit_pageseg_mode: '4'
    }),
    new Promise((_, reject) => setTimeout(() => reject(new Error('OCR timed out after 90s')), 90000))
]);
const ocrText = ocrResult.data.text;

// Step 2: Date OCR pass (PSM 11) with the same pattern
const dateOcrResult = await Promise.race([
    Tesseract.recognize(preprocessed, 'eng', {
        logger: m => {
            progressText.textContent = `Date OCR: ${m.status}...`;
        },
        tessedit_pageseg_mode: '11'
    }),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Date OCR timed out')), 30000))
]);

// Step 3: Send OCR text to text LLM (NOT vision model) — with retry for cold model
const selectedModel = document.getElementById('vision-model-select')?.value || 'qwen2.5:14b';
let llmResp, lastErr;
for (let attempt = 0; attempt < 2; attempt++) {
    try {
        llmResp = await fetch('/llm/api/chat', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                model: selectedModel,
                messages: [{ role: 'user', content: systemPrompt + '\n\n### OCR TEXT FROM SCREENSHOT:\n' + ocrText }],
                stream: false,
                options: { temperature: 0.0 }
            })
        });
        if (llmResp.ok) break;
        if (llmResp.status !== 502 && llmResp.status !== 503) throw new Error('LLM error: ' + llmResp.status);
        console.log(`LLM attempt ${attempt+1} failed with ${llmResp.status}, retrying...`);
        progressText.textContent = 'AI warming up, retrying...';
        await new Promise(r => setTimeout(r, 5000));
    } catch(e) {
        if (attempt === 1) throw e;
        console.log(`LLM attempt ${attempt+1} error, retrying...`);
        await new Promise(r => setTimeout(r, 3000));
    }
}
if (!llmResp.ok) throw new Error(lastErr);
// Step 4: Robust JSON extraction with indexOf('{')...lastIndexOf('}')
const llmData = await llmResp.json();
let rawJson = llmData.message.content;
const startIdx = rawJson.indexOf('{');
const endIdx = rawJson.lastIndexOf('}');
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
    rawJson = rawJson.substring(startIdx, endIdx + 1);
}
const parsed = JSON.parse(rawJson);
```

**System prompt**: The full extraction prompt with column layout, OCR artifact recovery rules, and VIN digit mappings is in `references/appointment-system-prompt.md`. Key features:
- Explicit column order: Time | Customer(s) | Phone | Advisor | OpCode | Service | Duration | RO# | VehicleInfo | VIN
- OCR artifact decoding: time garbling patterns (ESCAM→8:00AM), VIN digit swaps (8↔B, 5↔S, 0↔O), duration garbling (CS→0.5, FS→3.5)
- Honda VIN prefix hints for recovery (1HG, 5FN, JHM, 19X)
- Advisor tag stripping from service descriptions

**Text LLM server**: Ollama running `qwen2.5:14b` (~9GB) on `127.0.0.1:11434`, proxied through nginx at `/llm/` (120s timeout, `proxy_buffering off`). This is the SAME endpoint as the AI Write / Priority Analysis features — no separate vision endpoint needed for appointments.

**System prompt** is defined as `const systemPrompt` before the try block — instructs the text LLM to parse OCR text (not an image) into the appointment JSON schema. The full prompt text is in `references/appointment-system-prompt.md`.

**Parser (rules fallback)**: `parseWithRules()` handles phone, VIN, date, time, duration, vehicle, name/service extraction. See `references/ocr-parser.md` for the full parser code and test cases.

**OCR-to-fields strategy** (for building similar parsers):
1. **Phase 0 — Extract header date**: Before any stripping, scan the first 15 lines for a date pattern (e.g., "Monday, Jun 09, 2026", "Jun 09", "6/9/2026"). Save as `headerDate`. This is critical because screenshots put the appointment date in the top-middle header.
2. Split input into blocks by blank lines
3. Strip separator lines (`---`, `===`) and short ALL-CAPS header lines. Strip schedule metadata (day-of-week + date like "Monday Jun 09") — but only AFTER Phase 0 captured it.
4. Try multi-line block as table: if >2 lines and first line has header words (`name|phone|date`), parse each line individually
5. For each block: extract structured fields (phone, date, time, VIN, duration) by regex FIRST, replacing matches with spaces to preserve column gaps
6. Split remaining on multi-spaces (preserves column structure) — if that fails, fall back to single-space word heuristics
7. **Customer name extraction**: Skip parts matching service words (`isServiceWord()` blocklist), year-prefixed vehicle descriptions, or long all-caps strings before grabbing the name. Never carry a service word as a name across blocks.
8. **Phase 4 — Apply header date**: After all appointments are parsed, any appointment missing `appointmentDate` gets `headerDate` assigned automatically.

### Service Word Blocklist

A blocklist of 28 common shop/service words prevents OCR confusion where service descriptions bleed into customer name fields:

```javascript
var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1,DIAG:1,
    REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1,BALANCE:1,
    FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1,REQUEST:1,
    CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1};
function isServiceWord(w) { w = w.toUpperCase().replace(/[^A-Z]/g,''); return serviceWords[w] || w.length > 12; }
```

Three guards use this blocklist:
- **CarryName validation**: When a name is carried from a previous block, reject it if `isServiceWord()`
- **Step 7 name extraction**: Skip `parts[0]` if it matches a service word before assigning to `customerName`
- **Step 10 cross-block carry**: Don't carry trailing all-caps words if they match the blocklist or are >20 chars

## Auto-Save Drafts

`auto-save-draft.js` is a shared module that saves form field values to localStorage when the user is filling out a form, then restores them if the page reloads or the user navigates away.

### How it works
- Watches specified field IDs for `input`/`change` events
- Debounces saves (2s after last keystroke) to `spq-draft-<key>` in localStorage
- On page load, restores saved values and dispatches `input`/`change` events so app state updates
- Clears draft automatically on form submit (for `<form>` elements) or via `window.autoSaveDraft.clearDraft(key)`
- Respects `window.appSettings.autoSaveForms` — toggling off clears all drafts immediately

### Wiring it up

```html
<!-- Add script before inline blocks -->
<script type="module" src="auto-save-draft.js"></script>
```

```javascript
// Call after form elements exist in DOM
if (window.autoSaveDraft) {
    window.autoSaveDraft.init('#form-selector', 'storage-key', [
        'field-id-1', 'field-id-2', ...
    ]);
}
```

For forms without a `<form>` tag (e.g., modal buttons), clear the draft manually on success:
```javascript
if (window.autoSaveDraft) window.autoSaveDraft.clearDraft('storage-key');
```

### Capturing dynamic state (services, non-input data)

For forms with dynamic non-input state (e.g., services added/removed in the quote tab, which live in `quoteStateManager` not DOM inputs), use three features:

**`getExtraData` callback** — returns an object merged into the draft under `_extra`:
```javascript
getExtraData: function() {
    const services = quoteStateManager.getSelectedServices();
    return { services: services.map(s => ({ name, price, partsNotInStock, aftermarketAvailable })) };
}
```

**`onRestoreExtra` callback** — receives `_extra` on page load to restore complex state:
```javascript
onRestoreExtra: function(extra) {
    if (extra && extra.services) quoteStateManager.setSelectedServices(extra.services);
}
```

**`triggerSave()` method** — call when non-input state changes (e.g., in a state subscription):
```javascript
if (changedKeys.includes('selectedServices')) {
    renderSelectedServices();
    updateQuoteSummary();
    if (window._quoteAutoSave && window._quoteAutoSave.triggerSave) {
        window._quoteAutoSave.triggerSave();  // debounced 2s
    }
}
```

Store the init return value as `window._quoteAutoSave` so the state subscription handler can access it.

**Important**: The `getExtraData` callback runs at save time, not init time, so `quoteStateManager` is always available. But `onRestoreExtra` may fire during `waitForForm()` → `restore()` which can happen before `quoteStateManager` finishes initializing. Guard with `typeof quoteStateManager !== 'undefined'` and wrap in try/catch.

### Currently wired forms
| Page | Storage Key | Form | Fields |
|------|-------------|------|--------|
| Repair Orders | `ro-create` | `#create-ro-form` | create-customer-name, create-customer-phone, create-vehicle-info, create-mileage, create-vin, create-ro-number, create-estimated-time, create-status, create-services |
| Appointments | `appointment-create` | `#create-appointment-modal` | customer-name, customer-phone, vehicle-info, vin-number, appointment-date, appointment-time, duration, reason, notes |
| Quote Tab | `quote-tab` | `#generate-quote-content` | customerName, customerPhone, serviceAdvisor, repairOrderNumber, mileage, vehicleInfo, vin + **services** (via `getExtraData`/`onRestoreExtra`) |

## AI Features (Implemented)

Four LLM-powered features run via the local Ollama endpoint (`/llm/v1/chat/completions`, model `qwen2.5:14b`). All follow the same fetch pattern used in `api.js`.

### Daily Briefing (dashboard.js → index.html panel)

Generates a natural-language shop briefing on dashboard load. Key implementation details:

- **Data gathered**: today's appointments (with names+times), active ROs (with overdue customer details and promised times), pending quotes (total value), quotes with unaddressed per-service customer decisions (customer names + pending service counts)
- **Time-of-day greeting**: computed from `new Date().getHours()` — `< 12` = "Good morning", `< 17` = "Good afternoon", else "Good evening". Never hardcode "Good morning."
- **Anti-Team system prompt**: `"You are speaking directly to [Name], a service advisor. You are NOT a foreman addressing a team. ALWAYS address [Name] by their first name. Never say 'Team' or 'everyone.'"` The user's name comes from `window.appSettings?.serviceAdvisor`.
- **Output**: bullet-point format with conversational opening, highlights overdue ROs and pending customer decisions first, ends with encouraging sentence
- **Rendering**: detects `- ` / `• ` / `* ` prefixes and styles as blue-dot bullet points with proper spacing
- **Cache**: 5-minute localStorage TTL; "Refresh" button bypasses cache
- **Panel title**: "Daily Briefing" (NOT "Morning Briefing" — time-agnostic)
- **max_tokens**: 400 (room for bullet-point format)

**Pitfalls:**
- LLM ignores soft "address by name" instructions → must explicitly say "You are NOT a foreman. Never say Team."
- Hardcoded "Good morning" in user prompt → compute from `getHours()`
- Quote analysis only checked quote-level status, missing per-service `customerDecision` → now counts `pending` services within each quote and reports customer names

### Smart Upsell (quote-tab-manager.js → repair-orders.html)

"AI Suggest" button in Quote Generator Actions panel. Reads vehicle info + selected services + available catalog → LLM suggests complementary services.

- **System prompt includes explicit mileage tiers**: 30K-60K (transmission, coolant, filters), 60K-90K (timing belt, water pump, suspension), 90K+ (comprehensive, fuel system), ANY 50K+ (brake inspection, alignment, rotation). Must suggest 2-4 services.
- **Catalog data sent**: name, category, price, duration — gives LLM rich context
- **Rendering**: suggestion cards with service name, AI reasoning, price, one-click "Add" button (dims to "Added ✓")
- **max_tokens**: 500

**Pitfalls:**
- Generic "suggest 1-3 services" prompt returns 0-1 suggestions → must include mileage-specific thresholds and require "at least 2"
- Catalog without prices/durations → LLM can't make informed suggestions → include price+duration in catalog data
- **CRITICAL: LLM fabricates vehicle conditions it can't know** — the original prompt said "this mileage/condition warrants this service" and "be proactive." The LLM took this as permission to invent inspection findings like "shocks are leaking," "brakes are worn to 2mm," "cabin filter is dirty." It has NO inspection data. **Fix**: the system prompt must explicitly say "You have NOT inspected this vehicle and do NOT know its actual condition. NEVER invent or assume a condition. Do NOT say anything is leaking, worn, cracked, failing, low, dirty, or due unless referencing a manufacturer mileage interval. Every reason MUST cite a mileage milestone or complementary pairing." Also add explicit complementary pairings (brake pads → rotors, timing belt → water pump) and only suggest struts/shocks when mileage exceeds 80K (manufacturer interval, not a fabricated condition).
- `setSelectedServices()` must normalize `customerDecision` defaults for any service added via upsell

### Tech Note Translation (repair-orders.js → Create RO form)

"Technician Notes" textarea + "Translate Notes" button on Create RO form. Technician types raw notes → LLM returns structured JSON with findings, severity, customer explanations.

- **Output format**: JSON array with `finding`, `severity` (CRITICAL/RECOMMENDED/PREVENTIVE), `explanation`, `estimatedCost`
- **Rendering**: color-coded severity badges (red/amber/blue), explanation text, cost estimate, "Add to Services" button per finding
- **Persistence**: `technicianNotes` field saved to PocketBase with the RO; auto-save draft includes `technician-notes` field
- **max_tokens**: 600

### Customer Decision Tracking

See "Customer Decision Tracking" section above for full implementation. Key interaction with other features:
- Daily Briefing counts unaddressed services per quote
- PDF groups into "SERVICES APPROVED" / "POSTPONED SERVICES" sections when decisions are mixed
- `setSelectedServices()` normalizes `customerDecision: 'pending'` for all services loaded from PocketBase

**Reference**: `references/settings-modal-architecture.md` — for the full settings modal element-ID inventory and build pattern. `references/settings-field-addition-pattern.md` — for the step-by-step checklist when adding a new setting across 2 JS files + 4 HTML files. `references/ocr-parser.md` — for the OCR parser code. `references/quote-save-flow.md` — for the quote save/load data model and flow. `references/pocketbase-schema-patch.md` — for adding missing fields to PocketBase collections. `references/dashboard-overview.md` — for the complete dashboard layout. `references/appointment-system-prompt.md` — the full OCR-text-to-JSON system prompt. `references/element-id-mismatches.md` — recurring element ID mismatch patterns and the diagnostic approach when buttons silently do nothing. `references/ai-prompt-rules.md` — AI prompt engineering rules that prevent fabrication, force use of context, and ensure helpful outputs. `references/nginx-permission-pitfall.md` — the recurring 600→403 nginx permission bug, prevention checklist, and diagnostic commands. `references/form-validator-silent-failure.md` — FormValidator using wrong element IDs causing silent no-op saves. `references/pocketbase-json-field-guard.md` — three-step guard for PocketBase nested JSON fields returning as strings vs arrays. `references/model-lineup.md` — current model inventory and GPU fit. `references/ai-prompt-rules.md` — AI prompt engineering rules (no fabricated conditions, mileage-only, occurrence counts). `references/ai-features.md` — architecture, prompt patterns, and data flow for AI features.

### 1. AI Write (`api.js:handleAiWrite()`)

Custom service modal: user types a service name + recommendation reason → LLM generates a professional customer-facing explanation with priority level.

**Enhanced capabilities (added 2026-06-13):**
- Accepts optional `technicianNotesEl` (textarea element) and `vehicleContext` ({vehicleInfo, mileage, maintenanceInterval}) parameters
- Technician notes are fed to the LLM as additional context ("Technician's internal notes: ...") but NEVER appear in customer-facing output
- When mileage + maintenance interval are provided, the prompt includes ordinal calculations: "This would be the 2nd time. Next service due at 60,000 miles (0 miles ago)." Uses an `ordinal(n)` helper function
- Every AI-generated explanation now requires: (1) what the service involves, (2) why it's needed (using recommendation + tech notes + mileage context), (3) what could happen if left unaddressed (1 sentence, using qualifying language like "may potentially lead to")
- Explanation stays 3-5 sentences — clear enough for customers, concise enough to be read

**Call sites (4 total — update ALL when changing signature):**
1. `settings.js` Edit Service modal (line ~821) — passes `service-edit-technician-notes` element, null vehicleContext
2. `settings.js` Add Service modal (line ~833) — passes `add-service-technician-notes` element, null vehicleContext
3. `quote-tab-manager.js` `setupServiceEditModal()` (line ~1549) — passes technician notes + vehicleCtx from DOM
4. `quote-tab-manager.js` `setupCustomServiceModalEventListeners()` (line ~1336) — same, dynamic import

```javascript
// Payload pattern (enhanced)
fetch('/llm/v1/chat/completions', {
    body: JSON.stringify({
        messages: [
            {role:'system', content: longPromptWithFormattingRules},
            {role:'user', content: `Service: ${name}\nRecommendation: ${reason}${additionalContext}`}
        ],
        temperature: 0, max_tokens: 400
    })
});
// Response parsed for: LEVEL: [...] and EXPLANATION: [...]
```

### Technician Notes (Internal-Only AI Context)

A `technicianNotes` textarea on all service forms (Add Custom Service modal, Edit Service modal, Settings service editor). This field:

- **Purpose**: Gives the AI additional context when generating customer explanations. Example: "Last done at 27K miles — fluid was dark. Recommend drain and fill, not flush."
- **Visibility**: SHOP-ONLY. Displayed in quote builder service cards as a collapsible `<details>` element with amber styling. Displayed in Settings service list as "🔧 Notes: ...". Explicitly EXCLUDED from PDF/print output.
- **Storage**: PocketBase `services` collection, `technicianNotes` field (text, not required)
- **AI Write integration**: Notes are passed to `handleAiWrite()` as the 5th parameter. The LLM prompt includes them under "Technician's internal notes:" and uses them to generate more specific, context-aware explanations
- **Pitfall**: When adding new fields to the services collection via PocketBase API PATCH, the field won't appear in the app until settings.js `saveServiceEdits()`/`saveNewService()` includes it in the payload. Check all save handlers.

### Maintenance Interval on Services

A `maintenanceInterval` (number, miles) field on each service. Used to show mileage-aware context.

- **Settings form**: "Maintenance Interval (miles)" input with step=1000, placeholder "e.g., 5000". Shown after the price field in both Add and Edit service forms.
- **Quote builder display**: When a service has an interval AND the vehicle has mileage filled in, shows: "📅 Due every 5,000 miles — Your 2018 Honda Accord has 48,000 miles"
- **AI Suggest integration**: Interval data is included in the catalog sent to the LLM. Prompt instructs: "If a service in the catalog has a maintenanceInterval, recommend it when the vehicle mileage is approaching or past that interval"
- **AI Write integration**: When mileage + interval are present, the LLM computes `floor(mileage / interval)` to determine the occurrence number (1st, 2nd, 3rd) and how many miles past due the service is. These appear in the generated explanation.

### 2. Priority Analysis (`api.js:getPriorityAnalysis()`)

Takes an array of service objects → returns JSON ranked by safety criticality. Used by the quote builder.

### 3. Generate Priorities (`quote-tab-manager.js`)

Same concept as #2 but different entry point — "Generate Priorities" button in the quote tab. Uses `SERVICE_N: PRIORITY_LEVEL - reason` format with robust fallback parsing in `parseAndApplyPriorities()` that overrides LLM misclassifications with keyword-based rules (lines 2168-2251).

### Response format

All three use the same fetch pattern:
```javascript
const response = await fetch('/llm/v1/chat/completions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        messages: [
            {role:'system', content: instructions},
            {role:'user', content: data}
        ],
        temperature: 0,
        max_tokens: 500
    })
});
const result = await response.json();
const text = result.choices[0].message.content;
```

**Note**: This is the OpenAI-compatible path (`choices[0].message.content`), NOT the Gemini path (`candidates[0].content.parts[0].text`). The nginx proxy at `/llm/` handles routing to Ollama at `127.0.0.1:11434`.

## AI Features

See `references/ai-features.md` for AI integration details (DeepSeek proxy, Ollama fallback, scan-to-quote).

### Daily Briefing (`dashboard.js` → `generateDailyBriefing()`)
Dashboard panel showing appointment count, active ROs (overdue + waiters), and quotes. Uses per-service `customerDecision` field to split quotes into "awaiting decisions" vs "decided but not converted." Time-of-day greeting (morning/afternoon/evening). Cached 5 min in localStorage. Streaming typewriter rendering via `streamAIResponse()`.

### Smart Upsell (`quote-tab-manager.js` → `aiSuggestServices()`)
"AI Suggest" button in Quote Generator Actions panel. Sends vehicle profile + selected services + available catalog + maintenance intervals → LLM returns 2-4 suggestions with reasons.

### AI Write (`api.js` → `handleAiWrite()`)
Generates customer-facing explanations from service name + recommendation reason + technician notes + vehicle/mileage context. Computes occurrence count (floor(mileage ÷ interval)) and past-due status. Requires: describe service, explain why needed, state consequence of inaction. 3-5 sentences max.

### Priority Analysis (`api.js` → `getPriorityAnalysis()`)
Ranks services by safety criticality. Uses keyword-based fallback in `parseAndApplyPriorities()`.

### AI Prompt Rules (critical — the LLM fabricates otherwise)
- **NEVER invent conditions.** Do not say "leaking," "worn," "cracked," "failing" unless referencing a manufacturer mileage interval. The AI has NOT inspected the vehicle.
- Every reason MUST cite a mileage milestone or complementary pairing.
- Maintenance intervals: compute `floor(mileage ÷ interval)` for occurrence count. Use ordinals (1st, 2nd, 3rd). Mention "approaching" if within 2K miles of next interval.
- Always include consequence of inaction (1 sentence, use "may potentially" not "will").

### AI Write Call Sites (3 setup functions, different element IDs)
1. `setupCustomServiceModalEventListeners()` — Add Custom Service modal
2. Service Edit modal (inline, dynamically created) — IDs use `-quote` suffix
3. Settings → Edit/Add Service — IDs use `-settings` suffix
When debugging "AI Write does nothing," verify the element IDs match what the handler expects.

## Per-Service Customer Approval/Decline

Each service in quote builder has `customerDecision` field: `'pending'` (default) | `'approved'` | `'declined'`. 
- Green border + badge for approved, red + strikethrough for declined
- Quote summary shows approved subtotal
- PDF groups into "SERVICES APPROVED" and "POSTPONED SERVICES" sections when decisions are mixed
- Briefing counts only quotes with pending decisions as "needing decisions"

## Maintenance Intervals

`maintenanceInterval` field (number, miles) on services collection. Shown in service cards as "📅 Due every X,XXX miles — Your [vehicle] has YY,YYY miles" when mileage is filled in. AI Suggest uses interval data to compute occurrence counts.

## Technician Notes (Internal Only)

`technicianNotes` field (text) on services. Visible to shop in Settings and quote builder (collapsible `<details>`). Excluded from PDF/print. Fed to AI Write as additional context for generating explanations. Never shown to customers.

## Quote Save Bug

When updating an existing quote, use `updateDoc(quoteRef, quoteData)` NOT `setDoc()`. `setDoc()` searches by `name` field which doesn't exist on quotes collection → update silently fails. `updateDoc()` directly targets the PocketBase record ID.

## Clear Quote Confirmation

`resetQuoteState()` shows `confirm()` dialog when clearing an unsaved quote (no `currentQuoteId` AND has content). Saved quotes clear immediately.

## Pitfalls

New files default to 600 (owner-only). Nginx (www-data) returns 403. Fix: `chmod 644 <file>`. See also `references/ui-pitfalls.md` for dropdown, state-clearing, and PDF pitfalls.

- **Worker-created shared modules break entire site when 403**: When `shared/debug.js` returns 403, EVERY module that imports it silently fails. `repair-orders.js` → no tab handlers, no data loading. `dashboard.js` → briefing stuck. The failure is invisible — no console error (403 is a network error, not a JS error). Symptom: pages load but all JS features are dead. First diagnostic: `curl -sk -o /dev/null -w "%{http_code}" https://localhost/shared/debug.js`.

- **`console.log` → `log()` replacement creates import dependency**: The `shared/debug.js` module exports `log()`, `warn()`, `error()`. Every .js file that uses `log()` MUST import it. Non-module `<script>` blocks need `window.log = window.log || function(){};` as fallback. When replacing `console.log` with `log`, verify every file has the import.

- **Dynamic Tailwind classes get purged by build tools**: The codebase constructs Tailwind classes in JS template literals (`bg-${color}-600`, `${config.textColor}`). Build-step Tailwind purging destroys every dynamically-constructed class. **Keep the CDN approach** (`cdn.tailwindcss.com`) — do not attempt a compiled/purged Tailwind build for this project. Attempted once and reverted — site was completely broken. If switching to a build step, every dynamic class must be safelisted.

- **AI Write duplicate event listener accumulation (race condition)**: `setupServiceEditEventListeners()` was called every time the Edit Service modal opened, attaching a new click handler each time via `addEventListener()`. After N opens, clicking AI Write fired N+1 simultaneous API calls. The last response to arrive overwrote the textarea — appearing as the explanation "reverting on its own." **Fix**: add a guard at the top of the function: `if (modal.hasAttribute('data-edit-event-listeners-attached')) return; modal.setAttribute('data-edit-event-listeners-attached', 'true');`. Also check for duplicate handlers in `setupServiceEditModal()` — it had a SECOND AI Write handler that added a permanent extra listener. Remove duplicate handlers, keep only one.

- **AI Write element ID mismatches — handler looks for wrong IDs**: The edit modal AI Write handler at `setupServiceEditEventListeners()` originally looked for button `ai-write-btn-main` or `ai-write-btn-fallback`, explanation `service-edit-explanation-main` or `service-edit-explanation`, and technician notes `custom-service-technician-notes`. But the actual HTML IDs are `ai-write-btn-quote`, `service-edit-explanation-quote`, and `service-edit-technician-notes-quote`. The handler was never finding any elements. **Fix**: always include the `-quote` suffix variants FIRST in fallback chains. The full chain should be: button = `ai-write-btn-main || ai-write-btn-fallback || ai-write-btn-quote`, explanation = `service-edit-explanation-quote || service-edit-explanation-main || service-edit-explanation`, notes = `service-edit-technician-notes-quote || service-edit-technician-notes || custom-service-technician-notes`.\n\n- **Save Changes button ID includes `btn` — handler misses it**: The Save Changes button in the edit modal has ID `service-edit-save-btn-quote` (notice `btn`). But `setupServiceEditEventListeners()` line 1254 originally looked for `service-edit-save` or `service-edit-save-quote`. Neither matches. The saveBtn was null, no click handler was attached, and clicking Save Changes silently did nothing. **Fix**: add `service-edit-save-btn-quote` to the fallback chain: `const saveBtn = document.getElementById('service-edit-save') || document.getElementById('service-edit-save-btn-quote') || document.getElementById('service-edit-save-quote');`\n\n- **AI Write button should sit below the explanation textarea, not overlaid**: The original HTML had the AI Write button as `absolute top-2 right-2` inside a `relative` container over the textarea. This causes the button to float over the text and can produce OCR artifacts in screenshots. **Fix**: remove the `relative` wrapper and `absolute` positioning. Place the button as a separate row below the textarea with `mt-2` spacing.

- **FormValidator silent failure — wrong element IDs block save**: `setupServiceEditEventListeners()` initialized a `FormValidator` with element IDs like `service-edit-form`, `service-edit-name`, `service-edit-price`, `service-edit-recommendation`. But the actual HTML modal uses `-quote` suffixes: `service-edit-form-quote`, `service-edit-name-quote`, etc. Every `document.getElementById()` returned null, so `validateForm()` silently returned `false` on every call. `saveServiceEdit()` was never reached — no error, no notification, just "nothing happens." **Fix**: bypass the FormValidator for the save path and call `saveServiceEdit()` directly, which has its own validation with user-visible error messages. Alternatively, fix all FormValidator IDs to match the HTML.

- **AI Write ignores technician notes — prompt structure issue**: The `additionalContext` (technician notes + vehicle data) was appended to the user message but the system prompt never instructed the LLM to read or use it. The LLM followed the prompt literally and only used the service name and recommendation reason. **Fix**: (1) Move the additional context INTO the system prompt body with a prominent "ADDITIONAL CONTEXT FROM THE TECHNICIAN:" header. (2) Add explicit numbered tasks: "3. If technician notes are provided, incorporate that specific information into the explanation." (3) Add a `WITH_NOTES` example showing exactly how to incorporate notes. (4) Increase sentence count from 2-4 to 3-5 when notes are present. Without all four changes, the LLM defaults to ignoring the notes.

- **AI Write element ID mismatches**: The edit modal AI Write handler looks for specific element IDs. When adding new fields to modals, verify the handler's `getElementById` calls match the actual HTML IDs. The setup uses `||` fallback chains — add new IDs to the start of the chain, not the end.

- **Hardcoded Ollama model names in `api.js`**: The AI Write, Priority Analysis, and Generate Priorities features all call `fetch('/llm/v1/chat/completions')` with `model: 'qwen2.5:14b'` hardcoded. When you swap, upgrade, or reinstall the LLM via Ollama, these hardcoded strings break silently (Ollama returns a model-not-found error, but the browser catches it as a generic failure). Update ALL occurrences in api.js whenever the LLM model name changes.

- **Hardcoded Ollama model names in `api.js`**: The AI Write, Priority Analysis, and Generate Priorities features all call `fetch('/llm/v1/chat/completions')` with `model: 'qwen2.5:14b'` hardcoded. When you swap or upgrade the LLM via Ollama, update ALL occurrences in api.js, dashboard.js (daily briefing), and quote-tab-manager.js (smart upsell). Ollama returns a model-not-found error caught as a generic failure.

- **Tailwind build-step destroys dynamically-constructed classes — DO NOT USE**: The codebase constructs Tailwind classes in JS template literals (e.g., `bg-${color}-600`, `${config.textColor}`, conditional `bg-green-600 text-white`). A compiled/purged Tailwind build strips every class not appearing as a complete string literal in source files. Attempted once and reverted — site was completely broken. The CDN approach (`<script src=\"https://cdn.tailwindcss.com\"></script>`) is the correct approach for this codebase. Never attempt another build-step migration unless every dynamic class pattern is safelisted in tailwind.config.js.

- **Fragile LLM JSON parsing in browser**: Simple regexes like `.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '')` will fail if the LLM includes conversational preambles/postambles or extra newlines outside the code fence, causing a `SyntaxError` and a silent fallback to OCR. **Fix**: Use a robust `indexOf('{')` and `lastIndexOf('}')` slice to extract the JSON payload, and dynamically inspect keys in case the array wrapper varies (e.g., lowercase vs capitalized array keys, or raw arrays).
- **Whitespace collapse order matters**: Split on `\s{2,}` BEFORE collapsing with `\s+`. Collapsing first destroys column boundaries.
- **Inline scripts can't import modules**: Functions needed by both module and inline code must be exposed via `window.xxx`.
- **`onclick` attributes can fire before ES modules load**: `<button onclick="saveSiteSettings()">` depends on `window.saveSiteSettings` being set by settings.js (a deferred module script). If the user clicks before the module finishes loading, the onclick silently fails (ReferenceError). **Fix**: handle the click in the IIFE capture handler (which also runs as a non-module script, same execution timing as onclick) with a localStorage fallback, OR delay-click-guard by checking `typeof window.saveSiteSettings === 'function'`.
- **`escapeHtml` not globally available**: The module-only export from `shared/sanitize.js` isn't accessible in non-module scripts. Define locally or add `window.escapeHtml`.
- **`closeModal` must be on `window` for `onclick` handlers**: Inline `onclick="closeModal(...)"` needs a global definition.
- **Function declaration hoisting**: Two `handleFile` function declarations in same scope — the second silently overwrites the first. Delete dead code, don't rely on hoisting.
- **Nginx 301 redirect**: HTTP→HTTPS redirect doesn't affect browser flow (browser loads from HTTPS port 3447 directly). Testing with `curl localhost` gives 301; use `curl -k https://localhost:3447/...`.
- **Nginx must proxy `/llm/` to Ollama**: All AI features (appointment scan, AI Write, priority analysis) call `fetch('/llm/...')` — a relative URL that only works because nginx serves the static site AND proxies `/llm/` to Ollama on the same port. Without the `location /llm/` block (`proxy_pass http://127.0.0.1:11434/`), all AI calls fail silently. Use `proxy_buffering off;` and `proxy_read_timeout 120s`. The `/vision/` block is legacy and no longer required — it was only used by the old llava vision model path which has been replaced with OCR + text LLM.
- **Ollama auto-unloads models after 5 min idle → 502 transient errors**: By default (`OLLAMA_KEEP_ALIVE=5m`), Ollama evicts models from VRAM after 5 minutes of inactivity. Set explicitly via `Environment="OLLAMA_KEEP_ALIVE=5m"` in `/etc/systemd/system/ollama.service`. When a cold model needs to reload (3-6s load time), the VPS nginx proxy can return 502 if the load exceeds the upstream connect timeout, or connection refused from the Ollama port. The `/llm/api/chat` fetch in `appointments.html` now includes a retry loop: on 502/503, waits 5 seconds (enough for the model to finish loading) and retries once. Any other HTTP error is thrown immediately. Console logs show `LLM attempt N failed with 502, retrying...` during the retry. GPU power difference (loaded vs unloaded) is only ~15W — the idle timer is for freeing VRAM, not saving electricity. Dashboard auto-refresh was previously preventing the timer from ever expiring (see \"Dashboard auto-refresh fires LLM\" pitfall).

- **CSP meta tag blocks Tesseract.js — the silent killer**: The `<meta http-equiv="Content-Security-Policy">` tag on every SPQ page originally had `connect-src 'self'`. Tesseract.js v5 Web Workers need to download WASM core files and `eng.traineddata` from `cdn.jsdelivr.net` — ALL blocked by `connect-src 'self'`. The `script-src` allowed the CDN (so the main tesseract.min.js loaded), but the worker's internal `fetch()` calls to the CDN were silently rejected. Three CSP changes are REQUIRED for Tesseract.js to work: (1) `connect-src 'self' https://cdn.jsdelivr.net blob:` — allows worker to download WASM + traineddata and use blob URLs for internal messaging, (2) `worker-src blob:` — allows creation of inline Web Workers via blob URLs, (3) `'unsafe-eval'` in `script-src` — WASM compilation uses `new Function()`-like operations. When any of these are missing, the symptom is "Running OCR..." stuck forever with zero progress — Tesseract silently fails to initialize and the promise never resolves. Diagnostic: check browser console for CSP violation messages (`"violates the following Content Security Policy directive"`). The correct CSP for pages using Tesseract.js: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net blob:; img-src 'self' data: blob:; worker-src blob:; frame-src 'self';`

- **`const` / `let` redeclaration in single scope causes silent script failure**: Vanilla JS does not allow redeclaring `const` or `let` variables in the same block. If you write `const text = ...` twice inside a function (e.g., `generateDailyBriefing()`), the entire JS file throws a fatal `SyntaxError` at parse time and fails to execute. The app appears to hang on "Loading..." because the polling fallback never finds the function. **Fix**: rename one of the variables. **Diagnostic technique (when node/linters are unavailable)**: Use `grep -P "^\\s*const \\w+ =" file.js | awk '{print $2}' | sort | uniq -d` to statically find duplicate declarations in a script.

- **Function calls inside try/catch silently skipped — use `window._flag` + post-catch placement**: If a critical function call sits inside a `try` block, any error thrown by earlier lines skips the call. The function is never reached — symptom: feature stuck on initial loading state with no error visible. This happened with `generateDailyBriefing()` inside `loadDashboardData()`'s try block. **Fix**: place fire-and-forget calls AFTER the try/catch block with `.catch(e => console.warn(...))` to prevent unhandled rejections. Use a `window._featureFired` flag to prevent duplicates on auto-refresh. The flag also survives page navigation (set to `true` on first load, persists for the session). **Inline fallback pattern**: when a module-loaded function might not be available in time (ES modules load asynchronously), add an inline `<script>` that polls for `typeof window.theFunction === 'function'` with `setTimeout(check, 500)` and a 15-second max timeout. This is the safety net that fires even if the ES module chain breaks. Place it directly in the HTML after the element the function populates.

- **Dashboard auto-refresh fires briefing LLM every tick → Ollama never unloads**: `generateDailyBriefing()` was originally inside `loadDashboardData()`, which fires every ~6 min on auto-refresh. This kept the local LLM permanently loaded in VRAM. **Fix**: moved briefing to fire once via `window._briefingGenerated` flag, placed AFTER the try/catch block of `loadDashboardData()` (not inside it — errors before it would skip the call). Switched to DeepSeek API (`deepseek-v4-flash` via nginx proxy) so no local GPU is used. Auto-refresh only updates stats cards.

- **Function calls inside try/catch silently skipped**: If a critical function call sits inside a `try` block, any error thrown by earlier lines skips the call. Symptom: feature stuck on initial loading state with no error visible. **Fix**: place fire-and-forget calls AFTER the try/catch block with `.catch(e => console.warn(...))` to prevent unhandled rejections. Use a `window._featureFired` flag to prevent duplicates on auto-refresh.

- **DeepSeek nginx proxy for cloud LLM calls**: Location `/deepseek/` at `/etc/nginx/sites-enabled/shopproquote` proxies to `api.deepseek.com` with server-side API key. REQUIRES `proxy_ssl_server_name on;` (SNI) and `proxy_ssl_protocols TLSv1.2 TLSv1.3;` for SSL upstream handshake — without these nginx returns 502 with `SSL routines::ssl/tls alert handshake failure`. Browser calls `/deepseek/v1/chat/completions` with model `deepseek-v4-flash`. Use `thinking: {type: 'disabled'}` to keep costs at the lower non-thinking rate (~$0.0004/scan). See `references/deepseek-proxy-cost.md` for cost comparison with local Ollama.
- **"Running OCR..." stuck — Tesseract.js silent init phases + large image hang**: Tesseract.js v5 has FOUR silent phases before actual OCR begins: `loading tesseract core` (downloads WASM from CDN), `initializing tesseract`, `loading language traineddata` (downloads eng.traineddata), `initializing api`. The original logger only showed progress during `recognizing text` — so during the 10-30 second init phase the user sees "Running OCR..." with zero feedback. If any CDN download hangs (network blip, CDN outage), the promise never resolves and the UI freezes forever. Once OCR does start, the 3× upscale on full-HD screenshots (1920×1080 → 5760×3240) can take 60-180+ seconds, or silently crash the Web Worker (promise never resolves/rejects). **Diagnostic**: check VPS nginx access logs for `/llm/api/chat` hits — zero hits = stall is at Tesseract.js client-side (before any network call). `ssh root@51.81.84.34 'grep "/llm/api/chat" /var/log/nginx/access.log | tail -5'`. **Three-part fix**: (1) **Adaptive upscale** — 2× for images >1MP, 3× only for small images. Cuts pixel count by ~56% on HD screenshots. (2) **Full-phase progress logging** — logger shows ALL statuses, not just "recognizing text". UI updates to "Tesseract: loading tesseract core..." etc. so user knows what's happening. (3) **90s Promise.race timeout** — breaks out of hangs with a clear error instead of freezing forever. Same 30s timeout on the date OCR pass (PSM 11). Without all three fixes, the feature is effectively broken for real-world screenshots at typical resolutions. Console logs prefixed `[OCR]` and `[OCR-Date]` show phase + timing for debugging.

- **PSM 4 misses header dates — requires two-pass OCR**: PSM 4 (single column) is essential for the tabular appointment data but completely misses isolated header text like the date at the top-middle of the screenshot (e.g., "Wednesday Jun 10, 2026"). Running only PSM 4 means the OCR text sent to the LLM never contains the date, so `appointmentDate` is always blank. **Fix**: after the main PSM 4 OCR pass, run a second Tesseract pass with PSM 11 (sparse text) on the same preprocessed image. PSM 11 reliably captures the header date. Extract it with the same date regex the rule-based parser uses, then apply it as a fallback in the appointment mapping (`a.appointmentDate || headerDate`). Wrap the PSM 11 pass in try/catch — it's a best-effort enhancement. Also strip weekday prefixes ("Wednesday ") from the matched date before parsing, since PSM 11 captures them but the month-name parse branch expects the month to start the string. Example Tesseract.js call: `Tesseract.recognize(preprocessed, 'eng', { tessedit_pageseg_mode: '11' })`. See `appointments.html` lines ~1552-1578 for the working implementation.\n\n- **RO number double-prefix display — \"RO# RO-904680\"**: `createRepairOrderFromAppointment()` in `appointments.js` defaults the modal to `RO-${Date.now()...}` and `cleanRoNumber` forcibly prepends `RO-` if missing. Every display template prepends `RO# ` to `ro.roNumber`, producing \"RO# RO-904680\". Three-part fix: (a) modal default: strip `RO-` from the value — just `${Date.now().toString().slice(-6)}`; (b) `cleanRoNumber` logic: flip from adding `RO-` to stripping it — `roNumber.replace(/^RO[-#]?\\s*/i, '')`; (c) display: strip `RO-` in the three primary display locations for backward compatibility with existing data — `ro.roNumber.replace(/^RO-/, '')` in `ro-active.js` line 75, `repair-orders.js` lines 2476 and 4236.\n\n- **RO-VIN-Mileage data flow gaps — three files to update**: Two gaps in the appointment → RO → quote pipeline: (1) The "Create RO from Appointment" modal (`promptRepairOrderDetails()` in `appointments.js`) asked for RO number, estimated hours, mileage, and service type — but NOT VIN. VIN was silently pulled from appointment data with no chance to verify. (2) When creating a quote from an RO (`generateQuoteFromRO()` → `populateQuoteFromRO()`), mileage was never passed through, even though the quote form has a mileage field. **Fix**: (a) Add a VIN input to the modal HTML (after mileage, with `maxlength="17"`), pre-fill from `appointment.vin`, include in `resolve()` and `createRepairOrderFromAppointment()` destructuring. (b) Add `data-generate-quote-mileage` attribute to the generate-quote button rendering, read it in the event handler, pass to `generateQuoteFromRO()`, store in localStorage, and set in `populateQuoteFromRO()` via `safeSetValue('mileage', roData.mileage)`. See `appointments.js` (VIN modal field + flow), `repair-orders.js` (mileage dataset + generateQuoteFromRO signature), `main.js` (populateQuoteFromRO mileage setter).

- **Service advisor not populating when creating quote from RO**: `populateROData()` in `quote-tab-manager.js` populated customerName, customerPhone, vehicleInfo, mileage, vin, and repairOrderNumber — but never touched the `serviceAdvisor` field. The quote form's service advisor stayed blank even though `appSettings.serviceAdvisor` had a configured value. `resetQuoteState()` already set the advisor from settings, but the RO-to-quote path bypassed reset. **Fix**: add `if (elements.serviceAdvisor) { elements.serviceAdvisor.value = appSettings.serviceAdvisor || ''; }` to `populateROData()`. Always set it (not conditional on RO data) because ROs don't carry advisor info — the settings default is the source of truth.

- **RO number key mismatch between localStorage writer and reader**: `generateQuoteFromRO()` in `repair-orders.js` stored the RO number in localStorage under the key `roNumber`, but `populateROData()` in `quote-tab-manager.js` checked for `repairOrderNumber`. The dedicated RO number field on the quote form (`id="repairOrderNumber"`) was never populated. **Fix**: (a) store both keys in `generateQuoteFromRO()` — `roNumber` (for backward compat with notes/reference) and `repairOrderNumber` (for the quote form field); (b) in `populateROData()`, accept either key: `const roNum = roData.repairOrderNumber || roData.roNumber;`.

- **`populateQuoteFromRO` in main.js is dead code**: `main.js` has `populateQuoteFromRO()` and `initializeQuoteGenerator()` that read `quoteFromROData` from localStorage, but `main.js` is not loaded by any HTML file (no `<script src="main.js">` tag in index.html or repair-orders.html). The real RO-to-quote population path is `quote-tab-manager.js` → `initializeQuoteTab()` → `populateROData()`. Ignore `main.js` for RO-to-quote fixes — all changes must go in `quote-tab-manager.js`.\n\n- **Aggressive binarization destroys OCR on narrow-column screenshots**: The old code used `avg > 128 ? 255 : 0` which forces every pixel to pure black or white. Dealership schedule screenshots are ~1780px wide with 10+ narrow columns — text in the VIN and service columns is only a few pixels wide. Hard binary cutoff at 128 destroys the anti-aliased edges that Tesseract relies on for character recognition, turning VINs and service text into unreadable blobs. **Fix**: use mild contrast enhancement (only push extremes: >240→white, <30→black, leave mid-tones alone), 3x upscale the image before OCR, and use PSM 4 (single column). These three changes together reduce VIN errors from ~90% to ~10%.: When converting an appointment extraction feature from vision-model to OCR + text LLM, you must update ALL of these:
  1. Remove the `FileReader` base64 conversion — OCR uses `URL.createObjectURL(file)` directly
  2. Change the fetch endpoint from `/vision/api/chat` to `/llm/api/chat`
  3. Remove the `images: [base64]` field from the request body
  4. Update the system prompt to say "OCR text" instead of "image" (otherwise the LLM gets confused about what it's receiving)
  5. Update the model selector dropdown from vision models (llava) to text models (qwen2.5)
  6. Remove the try/catch fallback that triggered OCR on vision failure — OCR is now primary
  7. Keep `temperature: 0.0` — still critical for structured extraction
  8. Keep the `indexOf('{')` + `lastIndexOf('}')` JSON extraction — text LLMs also add conversational wrappers
- **Model swap procedure**: `ollama pull <model>` to download, `ollama rm <old-model>` to clean up. Update hardcoded model names: `api.js` lines 36 and 143 (AI Write, Priority Analysis) AND `appointments.html` model selector dropdown default (`qwen2.5:14b`). For LLM swaps, `api.js` and `appointments.html` both need updating. For the vision dropdown (legacy — only llava:13b remains, unused by appointments), no HTML changes needed.

- **AI Write button ID mismatch — three setup functions, inconsistent selectors**: The Quote Generator has THREE separate functions that wire AI Write click handlers to different modals. Each looks for a DIFFERENT button ID, and if a new ID is added or renamed, one of the three will silently fail to attach:
  1. `setupCustomServiceModalEventListeners()` — looks for `custom-service-ai-write-btn` (Add Custom Service modal, static HTML in `repair-orders.html`)
  2. `setupServiceEditEventListeners()` — looks for `ai-write-btn-main` || `ai-write-btn-fallback` (dynamically-created Edit Service modal from `createServiceEditModalHTML()`)
  3. `setupServiceEditModal()` — looks for `ai-write-btn` || `ai-write-btn-quote` (legacy/pre-existing Edit Service modal)
  **Bug encountered**: `setupServiceEditModal()` was missing the `ai-write-btn-fallback` fallback. The dynamically-created modal used that ID, so the handler never attached and clicking AI Write did nothing. Fix: add `|| document.getElementById('ai-write-btn-fallback')` to the chain at line 1543. When debugging "AI Write button does nothing," check which of these three functions ran and whether the button ID exists in any of them.
- **Ollama GPU acceleration**: Ollama auto-detects NVIDIA GPUs and uses CUDA by default. No manual Vulkan/CUDA configuration needed — just `ollama serve`. Verify GPU usage with `nvidia-smi` while a model is loaded. Current GPU: RTX 2080 Ti FE (11GB VRAM) — comfortably fits qwen2.5:14b (~9GB) and llava:13b (~8GB) individually.
- **Service search closes on click-outside while typing**: `quote-tab-manager.js` and `main.js` both have document-level click handlers that hide `#service-search-results`. If these fire while the user has typed a search term, the dropdown vanishes mid-search. Fix: only hide when the input is empty (`if (!serviceSearchInput.value.trim())`).
- **Notify Before Promised Time — new site config setting and dashboard notification**: A dropdown in Site Configuration → Notifications lets users choose: Off, 15 min, 30 min, or 1 hour before the promised time. The dashboard `checkForApproachingPromised()` function runs each refresh tick, finds ROs within the threshold window (promisedTime is in the future but within the threshold), and fires a desktop notification with the customer name, vehicle, and minutes remaining. Uses a `lastApproachingIds` Set to avoid duplicate notifications per RO. Honors both `desktopNotifications` and `soundAlerts` toggles. Setting defaults to `0` (Off) and is stored/loaded via the standard `appSettings` pipeline.
- **No multi-service add flow**: `addServiceById()` clears the input and hides results after each add, forcing the user to re-open search for every service. Fix: after adding, refocus the input, re-show all remaining available services (filter out already-added), and show a status indicator. See `quote-tab-manager.js` lines 1021-1045 for the working pattern.
- **Settings gear icon broken on repair-orders page**: `settings.js` `initializeSettings()` checks for `settings-modal` (tabbed settings — only on index.html). On repair-orders.html, this modal doesn't exist, so the function returns early and `settings-btn` gets zero click handlers. Additionally, the button had a long inline `onclick` that conflicted with `settings.js`'s `hasAttribute('onclick')` guard, preventing `settings.js` from wiring it even when the modal existed. **Three-part fix**: (1) remove the inline `onclick` from `#settings-btn` in the HTML, (2) add an `addEventListener('click', ...)` in `repair-orders.js` `setupEventListeners()` that calls `populateSiteSettingsForm()` then `openModal('site-settings-modal')`, (3) ensure `site-settings-modal` exists in the page with the card-layout settings (repair-orders uses a simplified card-layout version, not the full 5-tab version — `settings.js` handles missing tab elements gracefully). See `references/settings-modal-architecture.md` for element IDs and structure.

- **`handleUseExtractedData()` references 6 undefined variables (appointments.js, ~line 1899)**: The function tries to set `.value` on `customerName`, `customerPhone`, `vehicleInfo`, `vinNumber`, `appointmentDate`, `appointmentTime` but none of these variables are declared or retrieved from the DOM. Only `reason` and `notes` are properly defined. This throws a `ReferenceError` at runtime. **Fix**: add `const` declarations with `document.getElementById()` for each field using the correct HTML element IDs (`customer-name`, `customer-phone`, `vehicle-info`, `vin-number`, `appointment-date`, `appointment-time`). Also remove dead code: the entire AI import feature (import-appointment-btn, cancel-import-btn, use-extracted-data-btn, import-results, extracted-data) was removed from HTML but JS handlers remained.

- **Quick action SVG icons rendered as 8px dots (appointments.html)**: The four quick action button SVGs (New Appointment, Scan Screenshot, Settings, Expand All) use `class=\"w-2 h-2\"` (8px) — should be `w-8 h-8` (64px). Symptom: icons are invisible tiny dots. **Fix**: change `w-2 h-2` to `w-8 h-8` on all four SVGs (lines ~452, 459, 468, 476).

- **Dual `initializeApp()` calls cause potential duplicate event listeners (appointments.js)**: `initializeApp()` is called from both `DOMContentLoaded` (line 1573) and `onAuthStateChanged` (line 46). If auth fires synchronously with DOMContentLoaded, all listeners in `setupEventListeners()` register twice — double save notifications, double modal closes. **Fix**: add a `let initialized = false` guard at the top of `initializeApp()` that returns early on second call.

- **Global click handler swallows tab button clicks (repair-orders)**: The repair-orders IIFE has a capture-phase click handler that calls `e.stopPropagation()` on matching elements. This prevents bubble-phase event listeners on tab buttons and other interactive elements from ever firing. Symptom: clicking tabs produces no response; `element.click()` in console works because it bypasses capture. **Fix**: change `e.preventDefault(); e.stopPropagation()` to just `e.preventDefault()` — allow events to bubble so JS handlers can fire for state cleanup and tab switching.

- **Login error handling uses Firebase error codes for PocketBase (login.html)**: The catch blocks check `error.code` for `auth/user-not-found`, `auth/wrong-password`, `auth/invalid-email`, etc. — Firebase Auth error codes that never match PocketBase HTTP responses. All errors fall through to the generic `default` case showing only `error.message` ("Failed to authenticate."). Same pattern repeats in password reset, signup, and change-password handlers. **Fix**: replace `switch(error.code)` with `if(error.status)` checks: status 400 → invalid credentials, status 429 → rate limited, status 0 → network error, fallback to `error.data?.message || error.message`. Also fix dead code: `forgot-password-btn` ID changed to correct `open-change-password-modal`.
- **PocketBase silently drops fields not in schema (services collection)**: If a service is saved with `recommendation` or `explanation` fields but those aren't defined in the PocketBase `services` collection schema, the fields are silently dropped (HTTP 200, no error). Fix: PATCH `/api/collections/{id}` with complete fields array. See `references/pocketbase-schema-patch.md` for the Python script pattern.
- **Service explanation not visible in quote builder**: `renderSelectedServices()` only showed `service.recommendation` (the reason) but never rendered `service.explanation` (the customer explanation). Even when data was correctly saved to PocketBase, it was invisible in the UI. Fix: added an explanation line below the recommendation with italic styling and `line-clamp-3`.
- **Service cache stale after Settings save**: `repair-orders.js` loads services once at page init via `loadUserServices()`. Services saved from Settings → Services tab go to PocketBase but the in-memory `userServices` array is never refreshed. When the user switches to the Quote Generator tab, they get stale service data. Fix: call `await loadUserServices()` in `switchMainTab()` before `initializeQuoteTab()`. Also requires making `switchMainTab` an `async` function.
- **`applyShopCharge` not defaulting to ON**: New services via `addSelectedService()` default to `true`, but services loaded from saved quotes via `setSelectedServices()` pass through as-is — if the saved quote lacked the field, the checkbox renders unchecked. Fix: normalize in `setSelectedServices()` with `applyShopCharge: s.applyShopCharge !== false`.
- **Generate Priorities button missing from HTML**: `renderSelectedServices()` toggles `#generate-priorities-btn` visibility and `setupQuoteEventListeners()` attaches the click handler, but the button element didn't exist in `repair-orders.html`. The LLM-based priority analysis was unreachable. Fix: add the button in the Actions panel above Save Quote. Also requires lowering the visibility threshold from `> 1` services to `>= 1`.
- **PDF single-service page waste**: `generateQuotePDF()` had excessive padding: `yPos += 20` before pricing summary, `yPos += 40` before footer, and `-40` page-break margins. For a single service with explanation, the pricing summary and footer got pushed to page 2 leaving page 1 half-blank. Fix: reduce padding (20→10, 40→15), reduce page-break margins (40→20), and reduce after-service spacing (12→6). Recovers ~81 units on A4 (841 units = ~10% of page).
- **Add Custom Service modal had dropdown instead of text input**: The `#custom-service-recommendation` field was a `<select>` with priority LEVEL options (RECOMMENDED/CRITICAL/etc.) instead of a text input for the recommendation REASON (e.g., "pads worn to 2mm"). The AI Write button needs the reason text as input. Fix: replaced with `<input type=\"text\">` with placeholder "e.g., pads worn to 2mm, oil is overdue" and a help text explaining the AI will determine the priority level.
- **Settings field saved by wrong tab's handler**: The Shop Charge Explanation field lives in the Financial tab HTML but was only saved by the Messages tab's handler. When the user edits it and clicks "Save Financial," the changes are silently lost because Financial's handler doesn't include it. Fix: add the field to the Financial tab's save handler. **General pattern**: whenever a field appears in one settings tab, verify it's actually saved by that tab's handler — don't assume cross-tab coverage.
- **Patch tool corrupts JS regex escaping in HTML files**: The `patch` tool doubles backslashes in JavaScript regex literals inside HTML `<script>` tags — `\\n` → `\\\\n`, `\\s` → `\\\\s`, `\\d` → `\\\\d`, `\\b` → `\\\\b`, etc. This silently breaks regex patterns (e.g., `\\d{3}` becomes `\\\\d{3}` which matches literal backslash-d, not digits). **Fix**: use Python with raw strings via terminal to restore — `section.replace(r'\\\\n', r'\\n')` replaces the 3-char sequence `\\, \, n` with the 2-char `\, n`. Always wrap the function in `(function(){...}})()` and verify with `node --check` after fixes. Prefer terminal-based sed/Python for JS-in-HTML edits over the patch tool when regex literals are involved.
- **Auto-save-forms toggle was dead**: The checkbox saved/loaded its own state but no code actually performed auto-saving. The `auto-save-draft.js` module now implements the actual functionality behind the toggle. It captures static form fields AND dynamic state (services in quote tab via `getExtraData`/`onRestoreExtra`). If adding new forms to the app, call `window.autoSaveDraft.init()` to wire them up. For forms with dynamic non-DOM state, use the `getExtraData`/`onRestoreExtra`/`triggerSave` pattern.
- **Sound alerts toggle was dead**: `playAlertSound()` in dashboard.js played the 800Hz beep unconditionally, ignoring the `soundAlerts` setting. Fixed by adding `if (window.appSettings && window.appSettings.soundAlerts === false) return;` at the top of the function.
- **`initializeSettings()` not called on all pages**: `initializeSettings()` (which calls `loadSettings()`, `populateSiteSettingsForm()`, `setupUnifiedSiteSettingsModalHandlers()`, `ensureSiteSettingsCloseAttributes()`, etc.) must be explicitly called from every page's init code. It is NOT auto-called by loading `settings.js` as a module — it's an exported function. Pages currently calling it: repair-orders.js, customers.js, main.js (index), dashboard.js. **appointments.js was missing it**, causing settings to show HTML defaults (all unchecked) on the appointments page. Fix: add `import { initializeSettings } from './settings.js'` and call `initializeSettings()` inside the page's `initializeApp()` function. Also verify the HTML element IDs match what the JS expects — `appointments.js` was referencing `site-configuration-modal` and `save-site-configuration-btn` which didn't exist in the HTML (the HTML uses `site-settings-modal` and `save-site-settings`). Since the JS selectors returned null, appointments.js's custom save handler never attached (silent fail), and settings.js's handler works after calling `initializeSettings()`.

- **`handleUseExtractedData()` references 6 undefined variables (appointments.js)**: The function tries to set `.value` on `customerName`, `customerPhone`, `vehicleInfo`, `vinNumber`, `appointmentDate`, `appointmentTime` but none are declared or queried from the DOM. Only `reason` and `notes` are properly defined. Throws `ReferenceError` at runtime. **Fix**: add `const` declarations with `document.getElementById()` using correct IDs (`customer-name`, `customer-phone`, `vehicle-info`, `vin-number`, `appointment-date`, `appointment-time`). Also remove dead AI import code: handlers for `import-appointment-btn`, `cancel-import-btn`, `use-extracted-data-btn`, `import-results`, `extracted-data` — all HTML elements removed but JS handlers remained (safely guarded by `if(element)` but dead). Also remove `setupDayControlEventListeners()` — defined but never called.

- **Quick action SVG icons rendered as 8px dots (appointments.html)**: The four quick action button SVGs (New Appointment, Scan Screenshot, Settings, Expand All) use `class=\"w-2 h-2\"` (8px) instead of `w-8 h-8` (64px). Icons are invisible. **Fix**: change `w-2 h-2` to `w-8 h-8` on all four SVGs (lines ~452, 459, 468, 476).

- **Dual `initializeApp()` calls cause potential duplicate event listeners (appointments.js)**: `initializeApp()` is called from both `DOMContentLoaded` (line 1573) and `onAuthStateChanged` (line 46). If auth fires synchronously, all listeners in `setupEventListeners()` register twice — double save notifications, double modal closes, double form submissions. **Fix**: add a `let initialized = false` guard at the top of `initializeApp()` that returns early on second call. Also check other pages for the same pattern.

- **Global click handler swallows tab button clicks (repair-orders)**: The IIFE capture-phase click handler calls `e.stopPropagation()` on matching elements, preventing bubble-phase event listeners on tab buttons from firing. Symptom: clicking tabs does nothing visually; `element.click()` in console works because it bypasses capture. **Fix**: change `e.preventDefault(); e.stopPropagation()` to just `e.preventDefault()` — allow events to bubble so JS handlers fire for state cleanup and tab switching.

- **Login error handling uses Firebase error codes for PocketBase (login.html)**: Catch blocks check `error.code` for `auth/user-not-found`, `auth/wrong-password`, `auth/invalid-email` — Firebase Auth codes that never match PocketBase HTTP responses. All errors fall through to generic `default` showing only `error.message`. Same pattern in password reset, signup, and change-password handlers. **Fix**: replace `switch(error.code)` with `if(error.status)` checks: 400→invalid credentials, 429→rate limited, 0→network error, fallback to `error.data?.message || error.message`. Also fix dead code: `forgot-password-btn` ID → correct `open-change-password-modal`.
- **Desktop notifications were dead — now wired**: `showDesktopNotification(title, body)` in dashboard.js checks `appSettings.desktopNotifications`, requests `Notification.permission` on first use, and fires for new critical overdue repair orders. Exposed globally as `window.showDesktopNotification`. The function respects the toggle and silently degrades if the browser doesn't support the Notification API.
- **Conflicting site configuration save handlers**: Both `settings.js` (`setupUnifiedSiteSettingsModalHandlers`) and `dashboard.js` (`handleSaveSiteSettings`) attached click handlers to the same Save button. They saved to DIFFERENT PocketBase documents — `settings.js` → `appSettings` (with `data` wrapper), `dashboard.js` → `siteConfiguration` (flat). This caused toggles to revert after save because one handler's defaults (`false`) overwrote the other's saved values (`true`). Also made the modal appear broken (two handlers racing). Fix: remove the `handleSaveSiteSettings` listener from dashboard.js (line 651) and let settings.js handle everything. Dashboard.js `getDefaultSiteSettings()` defaults were also wrong (`false` → corrected to `true` for notifications/sound).
- **Site config dropdown inconsistencies across pages — check all 4 pages**: The user dropdown (`#user-dropdown`) must be identical on all pages. customers.html was missing: (a) `onclick="openModal('...')"` handlers on the Account Settings and Site Configuration buttons, (b) the divider + Sign Out button section. Symptom: clicking those menu items did nothing, and the user had no way to sign out from the customers page. Fix: copy the exact dropdown structure from repair-orders.html (including the 3 SVG icons and all onclick attributes) to customers.html. The Sign Out button uses `id="logoutBtn"` which `shared/header-functionality.js` already handles — no JS changes needed after adding the HTML.
- **Site config toggle defaults were wrong in dashboard.js**: `getDefaultSiteSettings()` returned `desktopNotifications: false` and `soundAlerts: false`, while `settings.js` defaults were `true`. Since dashboard.js had its own save handler (now removed), its defaults would overwrite saved values. Defaults should match across all files.
- **Modal close buttons unresponsive — three-layer debugging approach**: When X/Cancel/Save buttons on a modal do nothing visible when clicked, use this diagnostic pipeline:
  0. **Compare against a known-working page FIRST**: The fastest way to isolate the problem is to find a page where the same modal works (e.g., index.html) and diff the relevant sections — HTML structure, script tags, event handlers, module imports. In a known case, index.html has NO IIFE while repair-orders.html does, immediately pinpointing the IIFE as the culprit.
  1. **Check for duplicate id attributes** — getElementById returns the FIRST match. If there are two elements with the same modal id, the hidden first one matches and the visible second one stays open. grep -c should return 1.
  2. **Add a visual flash to closeModal** (lime outline, 500ms) to confirm the function is being called. Green flash + modal stays = CSS issue. No green flash = event never reached the handler.
  3. **Use style.setProperty with !important as the nuclear option** — inline style beats every CSS rule. Pair with removeProperty in openModal to restore.
  4. **Check for capture-phase interference**: The inline IIFE on repair-orders.html has a capture-phase click listener (addEventListener with true) that calls stopPropagation when it matches. This prevents bubble-phase handlers (including onclick attributes) from ever firing.
  - **Broken `aria-label` selector escaping**: The inline IIFE's close-button selector was `[aria-label=\\\"Close\\\"]` in the HTML source. After HTML/JS parsing, this became the CSS selector `[aria-label=\"Close\"]` which matches the literal attribute value `"Close"` (WITH quote characters), not `Close`. No element on the page has `aria-label='"Close"'` — they have `aria-label="Close"`. Fix: write `[aria-label="Close"]` directly in the inline `<script>` block (no backslash escaping). Also applies to `[role="dialog"]` selectors.
  - **Conflicting save handlers fixed**: `dashboard.js` had its own `handleSaveSiteSettings` listener on the same save button as `settings.js`'s `setupUnifiedSiteSettingsModalHandlers`. They saved to different PocketBase documents (`appSettings` vs `siteConfiguration`), causing toggles to revert. Removed the dashboard.js handler; only `settings.js` handles site config saves.
- **Site configuration modal had duplicate/dead toggles**: The `auto-save-forms` toggle appeared in both Account Settings AND Site Configuration — removed from Site Configuration (appointments.html, repair-orders.html, index.html, customers.html). The `site-dark-mode` toggle was also in Site Configuration (appointments.html, repair-orders.html, index.html) — removed since dark mode is handled by the header toggle. The `settings.js` `bind()` function gracefully handles missing elements (`if (!el) return;`), so no JS cleanup was needed.
- **stopPropagation() does not prevent other listeners on the SAME element**: Calling `e.stopPropagation()` during the target phase prevents the bubble phase, but does NOT stop other target-phase listeners on the current element. If `settings.js` adds a listener via `addEventListener('click', fn)` to the same button, and your onclick property handler calls `stopPropagation()`, BOTH fire. Use `e.stopImmediatePropagation()` instead — it prevents ALL remaining listeners on the current element regardless of phase.
- **hasAttribute('onclick') only checks HTML attributes, not DOM properties**: Setting `btn.onclick = fn` in JavaScript sets a DOM property — `btn.hasAttribute('onclick')` still returns false. Only `btn.setAttribute('onclick', '...')` or inline HTML `onclick="..."` makes `hasAttribute` return true. This matters because `settings.js` uses `!saveBtn.hasAttribute('onclick')` as a guard to skip adding its own listener — if you set onclick via JS property, the guard won't work and you'll get double-fires. Fix with `stopImmediatePropagation()` or set the HTML attribute explicitly.
- **Ollama Host and Origin checking**: Ollama returns `403 Forbidden` for any request with a `Host` or `Origin` header that doesn't match `localhost` or `127.0.0.1`. Since browsers attach the external `Origin` header (e.g., `https://grajmedia.duckdns.org`) to all non-GET requests, you MUST explicitly override the Origin header in nginx: `proxy_set_header Origin "http://localhost";` in the `/llm/` block. The `/vision/` block is legacy but should also include this header if kept. Test with: `curl -i -X POST -H "Origin: https://grajmedia.duckdns.org" -sk http://127.0.0.1:11434/api/chat`.
- **Diagnostic for Ollama 403 errors**: To isolate whether a 403 is from nginx or Ollama, test Ollama directly via curl: `curl -i -X POST -H "Origin: https://grajmedia.duckdns.org" -sk http://127.0.0.1:11434/api/chat`. If it returns 403, Ollama is enforcing Origin security.
- **Required Body Size Limit**: Set `client_max_body_size 50m;` in the nginx server block. Base64-encoded screenshots can easily exceed nginx's default 1MB body limit, causing uploads to fail silently with a `413 Request Entity Too Large` error before reaching Ollama.
- **Vision model pitfalls (HISTORICAL — no longer used for appointment extraction)**: These apply to the old two-tier architecture where llava:13b was the primary path. The current OCR + text LLM architecture avoids all of these issues entirely.
  - **VLM temperature**: Always set `temperature: 0.0` — Ollama defaults to 0.8, causing hallucinated names.
  - **Fragile VLM JSON parsing**: Smaller VLMs wrap responses in conversational text. Always use `indexOf('{')` + `lastIndexOf('}')` slice, not regex.
  - **VLM model selection**: llava:13b ~8GB fits on RTX 2080 Ti 11GB but produces unreliable structured output.
  - **Ollama 403**: Requires explicit `proxy_set_header Origin "http://localhost"` in both `/llm/` and `/vision/` nginx blocks.
  - **Body size**: Base64-encoded screenshots need `client_max_body_size 50m;` in nginx (still relevant — OCR can also process large images client-side).
- **CSP must be updated when removing CDN Tailwind**: When switching from CDN to build-step Tailwind, the CSP meta tags must have `cdn.tailwindcss.com` removed from both `script-src` and `style-src` directives. Otherwise the browser blocks the CDN (which is already removed from HTML) while the build-step CSS loads fine — but any residual CDN reference causes CSP violation noise in the console. All four production HTML pages (index.html, appointments.html, repair-orders.html, customers.html) need the update. Also add `https://cdnjs.cloudflare.com` to `script-src` for jsPDF dynamic imports. CSP template: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net blob:; img-src 'self' data: blob:; worker-src blob:; frame-src 'self';`\n\n- **Restoring HTML from backup wipes CSP meta tags**: When restoring HTML files from a pre-CSP backup, the CSP `<meta>` tags are lost. After restore, re-add CSP to all pages that lack it. Check with `grep -l 'Content-Security-Policy' *.html` — all 4 production pages should have it. Use sed to insert after `<meta charset=\"UTF-8\">`.\n\n- **PocketBase `services` field can return as JSON string, not array**: When loading quotes from PocketBase, the `services` field (an array of service objects) may come back as a JSON string instead of a parsed array — depends on how the collection field is typed in PocketBase. Always guard with: `let services = q.services || []; if (typeof services === 'string') { try { services = JSON.parse(services); } catch(e) { services = []; } } if (!Array.isArray(services)) services = [];` This is critical for the daily briefing's per-service decision counting and the unaddressed-quote enrichment. Apply this guard EVERYWHERE you read `q.services` from PocketBase-loaded data. The `convertFromPB` function in pocketbase.js tries to parse top-level JSON strings but doesn't recursively process nested fields in arrays.

- **`modal.remove()` destroys the DOM → subsequent opens break**: Some close/save handlers call `modal.remove()` which permanently deletes the modal from the DOM. The next time the modal is opened, the code has to dynamically recreate it from a fallback template — leading to missing elements and broken handlers. **Fix**: always use `modal.classList.add('hidden')` to hide modals. Never `remove()` unless the modal is truly ephemeral and recreated from scratch each time. Check all modal close handlers: close buttons, cancel buttons, outside-click handlers, AND save handlers. In `setupServiceEditEventListeners()` in `quote-tab-manager.js`, both `closeModal()` and `saveServiceEdit()` had `modal.remove()` — changed both to `modal.classList.add('hidden')`.

- **Save button closes modal without saving — IIFE captures `data-close-modal`**: `ensureSiteSettingsCloseAttributes()` and `dashboard.js` used to set `data-close-modal` on the Save button. The IIFE capture handler matches it, calls `stopPropagation()`, and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never set `data-close-modal` on save buttons; (2) use a direct `onclick` property override in the IIFE with `stopImmediatePropagation()` to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). See `references/inline-iife-modal-handlers.md` for full event flow with phases, property-vs-attribute distinction, and code. `ensureSiteSettingsCloseAttributes()` and `dashboard.js` used to set `data-close-modal` on the Save button. The IIFE capture handler matches it, calls `stopPropagation()`, and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never set `data-close-modal` on save buttons; (2) use a direct `onclick` property override in the IIFE with `stopImmediatePropagation()` to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). See `references/inline-iife-modal-handlers.md` for full event flow with phases, property-vs-attribute distinction, and code.
- **Saved quote delete buttons**: The saved quotes sidebar and modal both need trash-icon delete buttons. Add `<button>` with `data-delete-quote` attribute and a trash SVG (opacity-0, group-hover:opacity-100), stop click propagation so the delete doesn't trigger the load handler. The `deleteQuote()` function calls PocketBase `deleteDoc`, filters the local `allQuotesData` cache, and refreshes both the sidebar and modal (if open). Confirm with `confirm()` before deleting.
- **Quote edit-then-re-save silently fails — setDoc can't find existing records**: `saveQuote()` in `quote-tab-manager.js` used `setDoc()` for updating existing quotes. `setDoc()` in pocketbase.js (line 349) searches for records by filtering on `name = "${docId}"` — but `addDoc()` never sets a `name` field on the PocketBase record. So `setDoc` never finds the existing record and the update silently fails (error caught generically as "Error saving quote"). **Fix**: use `updateDoc()` instead of `setDoc()` for existing quotes. `updateDoc()` (pocketbase.js line 285) directly calls `pb.collection(collName).update(docId, pbData)` using the actual PocketBase record ID — no name-field lookup needed. Change the import from `setDoc` to `updateDoc` and replace the `setDoc(quoteRef, ...)` call with `updateDoc(quoteRef, quoteData)`. Both edits in `saveQuote()` at lines ~2818 and ~2824.\n\n- **Aftermarket parts selection invisible in PDF/print (quote-tab-manager.js)**: `generateQuotePDF()` only rendered `partsNotInStock` status (lines 3794-3806) but completely ignored `aftermarketAvailable`. When a user selected "Aftermarket Parts Available" on a service, the PDF showed nothing — no note, no parts list. Same gap in the height calculator (`calculateServiceHeight`). **Fix**: add aftermarket rendering block after the partsNotInStock block in BOTH the PDF service loop AND the height calculator. Aftermarket rendering shows bold "NOTE: Aftermarket parts are available for this service." plus the parts list if `aftermarketPartsList` exists. This is a recurring-class pitfall: any new service field added to the data model MUST be added to both the PDF renderer AND the page-break height calculator — they're separate code paths that diverge silently.

- **Button contrast — unselected toggle buttons invisible on service cards**: The customer decision buttons (✓ Approve / ✗ Decline / ↺ Reset) and parts toggle buttons (🔧 Labor Only / ✅ In Stock / ⚠️ Need Order / 💡 Aftermarket) originally used `bg-gray-200 text-gray-700` for unselected state. On the `bg-gray-50` service card background, these gray-on-gray buttons have near-zero contrast and are unreadable. **Fix**: unselected buttons use `bg-white text-gray-800 border border-gray-300 font-medium` — white fill with visible border, dark text, and a tinted hover matching the button's purpose (e.g. `hover:bg-green-50` for Approve). Selected buttons keep their saturated fills (`bg-green-600 text-white border-green-600`, etc.). This applies anywhere toggle-button groups appear in service cards. If adding new toggle groups, start with this pattern — never use `bg-gray-200` on a gray card background.

- **Clear quote without saving wipes work silently**: `resetQuoteState()` (bound to `#clear-quote-btn`) immediately cleared the form with no confirmation — even when the user had entered a customer name, vehicle info, and added services to an unsaved quote. One misclick lost all work. **Fix**: at the top of `resetQuoteState()`, check if the quote is unsaved (`!currentQuoteId`) AND has content (customer name, vehicle info, or any services selected). If both true, show `confirm('This quote has not been saved. Are you sure you want to clear it?')` before proceeding. Saved quotes (with a quote ID) clear immediately without asking. Pattern: `const hasContent = (elements.customerName?.value?.trim() || '') !== '' || (elements.vehicleInfo?.value?.trim() || '') !== '' || selectedServices.length > 0;`

- **PocketBase `quotes` collection has wrong/leftover schema — writes fail with "Error saving quote"**: The `quotes` collection existed but with fields from a different app use (deviceModel, deviceType, issue, etc.). `saveQuote()` sends vehicleInfo, mileage, vin, services, serviceAdvisor, repairOrderNumber, discountValue, discountType, lastUpdated — PocketBase rejects unknown fields. The HTTP error is caught generically with "Error saving quote" so the user has no clue it's a schema mismatch. Fix: PATCH `/api/collections/{id}` with the complete fields array (GET the current schema, append new fields, PATCH the whole array back). Do NOT use POST to `/api/collections/{name}/fields` — that endpoint returns 404. The correct API call is `PATCH /api/collections/{id}` with `{"fields": [...]}` as the body. Process: auth as superuser → GET collection schema → append `{name, type, required: false}` objects → PATCH. See `references/pocketbase-schema-patch.md` for the Python script pattern.

## Customer Decision Tracking (Per-Service Approval/Decline)

Each service in a quote can be marked as approved or declined by the customer. This allows the advisor to track what the customer authorized vs. postponed during the quote review process.

**Data model:** Each service object in `quoteStateManager.getSelectedServices()` carries a `customerDecision` field:
- `'pending'` (default) — not yet decided
- `'approved'` — customer authorized this service
- `'declined'` — customer declined this service

**UI (`renderSelectedServices()` in quote-tab-manager.js):**
- Three decision buttons per service card: ✓ Approve | ✗ Decline | ↺ Reset
- Approved: green left border on card, green "✓ Approved" badge
- Declined: red left border, strikethrough on name, red "✗ Declined" badge
- Global setter: `window.setCustomerDecision(index, decision)` updates the service, re-renders, and recalculates summary

**Quote Summary (`updateQuoteSummary()`):**
- Shows "Approved Subtotal" line when there's a mix of approved and non-approved services
- If nothing is explicitly approved, full total shows (avoids $0 shock)

**PDF Generation (`generateQuotePDF()`):**
- When decisions are mixed, services are grouped into two labeled sections:
  - "SERVICES APPROVED" (green header + underline) — only approved services
  - "POSTPONED SERVICES" (gray header + underline) — declined + pending services
- If all services share the same decision, no section headers (renders normally)
- Each service shows status badges: "✓ CUSTOMER APPROVED" or "✗ CUSTOMER DECLINED"
- Declined services get line-through on the name
- Pricing summary includes "Approved Subtotal" line
- Section headers intelligently page-break

**Persistence:** `customerDecision` is stored inside each service object in the quotes PocketBase collection. `setSelectedServices()` normalizes missing fields to `'pending'`. Save and reload preserves all decisions.

**Normalization pattern** (in `setSelectedServices()`):
```javascript
const normalized = services.map(s => ({
    ...s,
    customerDecision: s.customerDecision || 'pending',
    applyShopCharge: s.applyShopCharge !== false
}));
```

## PDF Generation

`generateQuotePDF()` in `quote-tab-manager.js` (line 3167) dynamically imports jsPDF 2.5.1 from Cloudflare CDN and builds a multi-page quote PDF with:

- Two-column header (business info + customer details with vehicle, VIN, RO#, mileage)
- Personalized message from `appSettings.headerMessage`
- Each service: name, price, priority level/reason, recommendation, explanation, parts status, customer decision badge (✓ Approved / ✗ Declined), aftermarket parts note
- **When customer decisions are mixed**: services split into "SERVICES APPROVED" (green header) and "POSTPONED SERVICES" (gray header) sections. Declined service names get line-through.
- Pricing summary: subtotal → approved subtotal (when mixed decisions) → discount → shop charge → tax → total
- Footer with shop charge explanation, footer message, quote validity note
- Page numbers on every page
- Automatic page breaks with height calculations

Dependencies: internet (jsPDF CDN), `appSettings` import from settings.js, `quoteStateManager.getSelectedServices()`, DOM elements from `getQuoteDomElements()`. Validation requires customer name + at least one service.

### PDF Spacing Tuning

The spacing constants were adjusted to prevent single-service PDFs from wasting a page. Original values were overly conservative:

| Section | Before | After | Reason |
|---|---|---|---|
| After services → before summary | `yPos += 20` | `yPos += 10` | Too much dead space |
| Summary page-break margin | `-40` | `-20` | Page numbers only need 15px |
| Between totals → footer | `yPos += 40` | `yPos += 15` | Massive gap, footer only needs ~20px |
| Footer page-break margin | `-40` | `-20` | Same — page numbers only |
| After last service | `yPos += 12` | `yPos += 6` | Minimal needed |

Total reclaimed: ~81 units on A4 (841 units = ~10% of usable page height).
