Files
2026-07-12 10:17:17 -04:00

98 KiB
Raw Permalink Blame History

name, description
name description
shop-pro-quote 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:

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), 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. 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/completionsapi.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):

// 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:

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

<!-- Add script before inline blocks -->
<script type="module" src="auto-save-draft.js"></script>
// 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:

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:

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:

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

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
// 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:

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.jsgenerateDailyBriefing())

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.jsaiSuggestServices())

"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.jshandleAiWrite())

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.jsgetPriorityAnalysis())

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.loglog() 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.jsinitializeQuoteTab()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.jsappSettings (with data wrapper), dashboard.jssiteConfiguration (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()):

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