98 KiB
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 — seereferences/pocketbase-test-users.md.. Test user creation — seereferences/pocketbase-test-users.md. - Reverse proxy: nginx on 443 SSL. DeepSeek via
/deepseek/proxy. - Local dev: Vite — see
references/local-dev-server.mdfor proxy config, PocketBase SDKimportkeyword fix, admin API paths. - PDF gen: jspdf 4.x
src/lib/pdf.ts— seereferences/pdf-generation.mdfor 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 cardsexplanation= the customer-facing EXPLANATION text — displayed in gray italic in service cards, used in PDFs, and populated by AI WritemaintenanceInterval= 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.jsadapter maps PocketBase REST to Firebase-compatible API surface (collection(),addDoc(),getDocs(),onAuthStateChanged(), etc.)shared/debug.js— DEBUG-gated logging utility. Exportslog()(only logs whenDEBUG=true),warn(),error(). All JS files import from here instead of using rawconsole.log. SetDEBUG = falsefor production.shared/skeleton.js— ExportsskeletonCard(lines)andskeletonRow()for pulse-animation loading placeholders. Replace raw "Loading..." text patterns with these.shared/modal-manager.js— Centralized modal utilities. ExportsconfirmDialog(message, title)(themed replacement for nativeconfirm()— returns Promise),registerAccountSettingsModal(), andregisterSiteSettingsModal()for deduplicated settings handling across pages.shared/confirm.js— ExportsconfirmDialog(message, title)that replaces nativeconfirm()with a styled, dark-mode-aware modal. Returns Promise. Allconfirm()calls in the codebase have been replaced withawait confirmDialog(...).api.jsexportsstreamAIResponse(systemPrompt, userContent, onChunk, options)for streaming LLM responses with typewriter rendering. SupportsAbortSignalviaoptions.signalfor cancellation. Used by Daily Briefing.- Module scripts (
type="module") useimportfrompocketbase.js. Inline<script>blocks (not modules) need globals exposed viawindow.xxx = ...or local helpers. - Settings persisted via
settingscollection in PocketBase withdataJSON field +userId+namefor 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.htmlhas a capture-phase click handler (lines 1376-1427) that defineswindow.openModal/window.closeModaland intercepts modal button clicks BEFORE module code. Seereferences/inline-iife-modal-handlers.mdfor 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 insetupRealtimeSubscriptions()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):
- Tesseract OCR with adaptive upscale (2×/3×) + mild contrast enhancement + PSM 4 (single column) → raw text
- Text LLM receives the OCR text + detailed system prompt → returns JSON array
Dual LLM backend (added 2026-06-15): The scan feature now supports two backends selected via the model dropdown:
deepseek-v4-flash(DEFAULT) → VPS nginx proxy at/deepseek/v1/chat/completions→api.deepseek.com. Uses OpenAI format, non-thinking mode (thinking: {type: 'disabled'}). Response parsed fromchoices[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 frommessage.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):
- 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. - Split input into blocks by blank lines
- 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. - Try multi-line block as table: if >2 lines and first line has header words (
name|phone|date), parse each line individually - For each block: extract structured fields (phone, date, time, VIN, duration) by regex FIRST, replacing matches with spaces to preserve column gaps
- Split remaining on multi-spaces (preserves column structure) — if that fails, fall back to single-space word heuristics
- 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. - Phase 4 — Apply header date: After all appointments are parsed, any appointment missing
appointmentDategetsheaderDateassigned 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 tocustomerName - 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/changeevents - Debounces saves (2s after last keystroke) to
spq-draft-<key>in localStorage - On page load, restores saved values and dispatches
input/changeevents so app state updates - Clears draft automatically on form submit (for
<form>elements) or viawindow.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 fromwindow.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 countspendingservices 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 normalizecustomerDecisiondefaults 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:
technicianNotesfield saved to PocketBase with the RO; auto-save draft includestechnician-notesfield - 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()normalizescustomerDecision: '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) andvehicleContext({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):
settings.jsEdit Service modal (line ~821) — passesservice-edit-technician-noteselement, null vehicleContextsettings.jsAdd Service modal (line ~833) — passesadd-service-technician-noteselement, null vehicleContextquote-tab-manager.jssetupServiceEditModal()(line ~1549) — passes technician notes + vehicleCtx from DOMquote-tab-manager.jssetupCustomServiceModalEventListeners()(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
servicescollection,technicianNotesfield (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.js → generateDailyBriefing())
Dashboard panel showing appointment count, active ROs (overdue + waiters), and quotes. Uses per-service customerDecision field to split quotes into "awaiting decisions" vs "decided but not converted." Time-of-day greeting (morning/afternoon/evening). Cached 5 min in localStorage. Streaming typewriter rendering via streamAIResponse().
Smart Upsell (quote-tab-manager.js → aiSuggestServices())
"AI Suggest" button in Quote Generator Actions panel. Sends vehicle profile + selected services + available catalog + maintenance intervals → LLM returns 2-4 suggestions with reasons.
AI Write (api.js → handleAiWrite())
Generates customer-facing explanations from service name + recommendation reason + technician notes + vehicle/mileage context. Computes occurrence count (floor(mileage ÷ interval)) and past-due status. Requires: describe service, explain why needed, state consequence of inaction. 3-5 sentences max.
Priority Analysis (api.js → getPriorityAnalysis())
Ranks services by safety criticality. Uses keyword-based fallback in parseAndApplyPriorities().
AI Prompt Rules (critical — the LLM fabricates otherwise)
- NEVER invent conditions. Do not say "leaking," "worn," "cracked," "failing" unless referencing a manufacturer mileage interval. The AI has NOT inspected the vehicle.
- Every reason MUST cite a mileage milestone or complementary pairing.
- Maintenance intervals: compute
floor(mileage ÷ interval)for occurrence count. Use ordinals (1st, 2nd, 3rd). Mention "approaching" if within 2K miles of next interval. - Always include consequence of inaction (1 sentence, use "may potentially" not "will").
AI Write Call Sites (3 setup functions, different element IDs)
setupCustomServiceModalEventListeners()— Add Custom Service modal- Service Edit modal (inline, dynamically created) — IDs use
-quotesuffix - Settings → Edit/Add Service — IDs use
-settingssuffix 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.jsreturns 403, EVERY module that imports it silently fails.repair-orders.js→ no tab handlers, no data loading.dashboard.js→ briefing stuck. The failure is invisible — no console error (403 is a network error, not a JS error). Symptom: pages load but all JS features are dead. First diagnostic:curl -sk -o /dev/null -w "%{http_code}" https://localhost/shared/debug.js. -
console.log→log()replacement creates import dependency: Theshared/debug.jsmodule exportslog(),warn(),error(). Every .js file that useslog()MUST import it. Non-module<script>blocks needwindow.log = window.log || function(){};as fallback. When replacingconsole.logwithlog, 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 viaaddEventListener(). 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 insetupServiceEditModal()— 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 buttonai-write-btn-mainorai-write-btn-fallback, explanationservice-edit-explanation-mainorservice-edit-explanation, and technician notescustom-service-technician-notes. But the actual HTML IDs areai-write-btn-quote,service-edit-explanation-quote, andservice-edit-technician-notes-quote. The handler was never finding any elements. Fix: always include the-quotesuffix 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 includesbtn— handler misses it: The Save Changes button in the edit modal has IDservice-edit-save-btn-quote(noticebtn). ButsetupServiceEditEventListeners()line 1254 originally looked forservice-edit-saveorservice-edit-save-quote. Neither matches. The saveBtn was null, no click handler was attached, and clicking Save Changes silently did nothing. Fix: addservice-edit-save-btn-quoteto 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 asabsolute top-2 right-2inside arelativecontainer over the textarea. This causes the button to float over the text and can produce OCR artifacts in screenshots. Fix: remove therelativewrapper andabsolutepositioning. Place the button as a separate row below the textarea withmt-2spacing. -
FormValidator silent failure — wrong element IDs block save:
setupServiceEditEventListeners()initialized aFormValidatorwith element IDs likeservice-edit-form,service-edit-name,service-edit-price,service-edit-recommendation. But the actual HTML modal uses-quotesuffixes:service-edit-form-quote,service-edit-name-quote, etc. Everydocument.getElementById()returned null, sovalidateForm()silently returnedfalseon every call.saveServiceEdit()was never reached — no error, no notification, just "nothing happens." Fix: bypass the FormValidator for the save path and callsaveServiceEdit()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 aWITH_NOTESexample 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
getElementByIdcalls 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 callfetch('/llm/v1/chat/completions')withmodel: '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 callfetch('/llm/v1/chat/completions')withmodel: '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}, conditionalbg-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 aSyntaxErrorand a silent fallback to OCR. Fix: Use a robustindexOf('{')andlastIndexOf('}')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. -
onclickattributes can fire before ES modules load:<button onclick="saveSiteSettings()">depends onwindow.saveSiteSettingsbeing 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 checkingtypeof window.saveSiteSettings === 'function'. -
escapeHtmlnot globally available: The module-only export fromshared/sanitize.jsisn't accessible in non-module scripts. Define locally or addwindow.escapeHtml. -
closeModalmust be onwindowforonclickhandlers: Inlineonclick="closeModal(...)"needs a global definition. -
Function declaration hoisting: Two
handleFilefunction 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 localhostgives 301; usecurl -k https://localhost:3447/.... -
Nginx must proxy
/llm/to Ollama: All AI features (appointment scan, AI Write, priority analysis) callfetch('/llm/...')— a relative URL that only works because nginx serves the static site AND proxies/llm/to Ollama on the same port. Without thelocation /llm/block (proxy_pass http://127.0.0.1:11434/), all AI calls fail silently. Useproxy_buffering off;andproxy_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 viaEnvironment="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/chatfetch inappointments.htmlnow 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 showLLM 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 hadconnect-src 'self'. Tesseract.js v5 Web Workers need to download WASM core files andeng.traineddatafromcdn.jsdelivr.net— ALL blocked byconnect-src 'self'. Thescript-srcallowed the CDN (so the main tesseract.min.js loaded), but the worker's internalfetch()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'inscript-src— WASM compilation usesnew 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/letredeclaration in single scope causes silent script failure: Vanilla JS does not allow redeclaringconstorletvariables in the same block. If you writeconst text = ...twice inside a function (e.g.,generateDailyBriefing()), the entire JS file throws a fatalSyntaxErrorat 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): Usegrep -P "^\\s*const \\w+ =" file.js | awk '{print $2}' | sort | uniq -dto 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 atryblock, 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 withgenerateDailyBriefing()insideloadDashboardData()'s try block. Fix: place fire-and-forget calls AFTER the try/catch block with.catch(e => console.warn(...))to prevent unhandled rejections. Use awindow._featureFiredflag to prevent duplicates on auto-refresh. The flag also survives page navigation (set totrueon 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 fortypeof window.theFunction === 'function'withsetTimeout(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 insideloadDashboardData(), which fires every ~6 min on auto-refresh. This kept the local LLM permanently loaded in VRAM. Fix: moved briefing to fire once viawindow._briefingGeneratedflag, placed AFTER the try/catch block ofloadDashboardData()(not inside it — errors before it would skip the call). Switched to DeepSeek API (deepseek-v4-flashvia 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
tryblock, 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 awindow._featureFiredflag to prevent duplicates on auto-refresh. -
DeepSeek nginx proxy for cloud LLM calls: Location
/deepseek/at/etc/nginx/sites-enabled/shopproquoteproxies toapi.deepseek.comwith server-side API key. REQUIRESproxy_ssl_server_name on;(SNI) andproxy_ssl_protocols TLSv1.2 TLSv1.3;for SSL upstream handshake — without these nginx returns 502 withSSL routines::ssl/tls alert handshake failure. Browser calls/deepseek/v1/chat/completionswith modeldeepseek-v4-flash. Usethinking: {type: 'disabled'}to keep costs at the lower non-thinking rate (~$0.0004/scan). Seereferences/deepseek-proxy-cost.mdfor 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 duringrecognizing 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/chathits — 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
appointmentDateis 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' }). Seeappointments.htmllines ~1552-1578 for the working implementation.\n\n- RO number double-prefix display — "RO# RO-904680":createRepairOrderFromAppointment()inappointments.jsdefaults the modal toRO-${Date.now()...}andcleanRoNumberforcibly prependsRO-if missing. Every display template prependsRO#toro.roNumber, producing "RO# RO-904680". Three-part fix: (a) modal default: stripRO-from the value — just${Date.now().toString().slice(-6)}; (b)cleanRoNumberlogic: flip from addingRO-to stripping it —roNumber.replace(/^RO[-#]?\\s*/i, ''); (c) display: stripRO-in the three primary display locations for backward compatibility with existing data —ro.roNumber.replace(/^RO-/, '')inro-active.jsline 75,repair-orders.jslines 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()inappointments.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, withmaxlength="17"), pre-fill fromappointment.vin, include inresolve()andcreateRepairOrderFromAppointment()destructuring. (b) Adddata-generate-quote-mileageattribute to the generate-quote button rendering, read it in the event handler, pass togenerateQuoteFromRO(), store in localStorage, and set inpopulateQuoteFromRO()viasafeSetValue('mileage', roData.mileage). Seeappointments.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()inquote-tab-manager.jspopulated customerName, customerPhone, vehicleInfo, mileage, vin, and repairOrderNumber — but never touched theserviceAdvisorfield. The quote form's service advisor stayed blank even thoughappSettings.serviceAdvisorhad a configured value.resetQuoteState()already set the advisor from settings, but the RO-to-quote path bypassed reset. Fix: addif (elements.serviceAdvisor) { elements.serviceAdvisor.value = appSettings.serviceAdvisor || ''; }topopulateROData(). 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()inrepair-orders.jsstored the RO number in localStorage under the keyroNumber, butpopulateROData()inquote-tab-manager.jschecked forrepairOrderNumber. The dedicated RO number field on the quote form (id="repairOrderNumber") was never populated. Fix: (a) store both keys ingenerateQuoteFromRO()—roNumber(for backward compat with notes/reference) andrepairOrderNumber(for the quote form field); (b) inpopulateROData(), accept either key:const roNum = roData.repairOrderNumber || roData.roNumber;. -
populateQuoteFromROin main.js is dead code:main.jshaspopulateQuoteFromRO()andinitializeQuoteGenerator()that readquoteFromRODatafrom localStorage, butmain.jsis 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 isquote-tab-manager.js→initializeQuoteTab()→populateROData(). Ignoremain.jsfor RO-to-quote fixes — all changes must go inquote-tab-manager.js.\n\n- Aggressive binarization destroys OCR on narrow-column screenshots: The old code usedavg > 128 ? 255 : 0which 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:- Remove the
FileReaderbase64 conversion — OCR usesURL.createObjectURL(file)directly - Change the fetch endpoint from
/vision/api/chatto/llm/api/chat - Remove the
images: [base64]field from the request body - Update the system prompt to say "OCR text" instead of "image" (otherwise the LLM gets confused about what it's receiving)
- Update the model selector dropdown from vision models (llava) to text models (qwen2.5)
- Remove the try/catch fallback that triggered OCR on vision failure — OCR is now primary
- Keep
temperature: 0.0— still critical for structured extraction - Keep the
indexOf('{')+lastIndexOf('}')JSON extraction — text LLMs also add conversational wrappers
- Remove the
-
Model swap procedure:
ollama pull <model>to download,ollama rm <old-model>to clean up. Update hardcoded model names:api.jslines 36 and 143 (AI Write, Priority Analysis) ANDappointments.htmlmodel selector dropdown default (qwen2.5:14b). For LLM swaps,api.jsandappointments.htmlboth 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:
setupCustomServiceModalEventListeners()— looks forcustom-service-ai-write-btn(Add Custom Service modal, static HTML inrepair-orders.html)setupServiceEditEventListeners()— looks forai-write-btn-main||ai-write-btn-fallback(dynamically-created Edit Service modal fromcreateServiceEditModalHTML())setupServiceEditModal()— looks forai-write-btn||ai-write-btn-quote(legacy/pre-existing Edit Service modal) Bug encountered:setupServiceEditModal()was missing theai-write-btn-fallbackfallback. 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 withnvidia-smiwhile 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.jsandmain.jsboth 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 alastApproachingIdsSet to avoid duplicate notifications per RO. Honors bothdesktopNotificationsandsoundAlertstoggles. Setting defaults to0(Off) and is stored/loaded via the standardappSettingspipeline. -
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. Seequote-tab-manager.jslines 1021-1045 for the working pattern. -
Settings gear icon broken on repair-orders page:
settings.jsinitializeSettings()checks forsettings-modal(tabbed settings — only on index.html). On repair-orders.html, this modal doesn't exist, so the function returns early andsettings-btngets zero click handlers. Additionally, the button had a long inlineonclickthat conflicted withsettings.js'shasAttribute('onclick')guard, preventingsettings.jsfrom wiring it even when the modal existed. Three-part fix: (1) remove the inlineonclickfrom#settings-btnin the HTML, (2) add anaddEventListener('click', ...)inrepair-orders.jssetupEventListeners()that callspopulateSiteSettingsForm()thenopenModal('site-settings-modal'), (3) ensuresite-settings-modalexists in the page with the card-layout settings (repair-orders uses a simplified card-layout version, not the full 5-tab version —settings.jshandles missing tab elements gracefully). Seereferences/settings-modal-architecture.mdfor element IDs and structure. -
handleUseExtractedData()references 6 undefined variables (appointments.js, ~line 1899): The function tries to set.valueoncustomerName,customerPhone,vehicleInfo,vinNumber,appointmentDate,appointmentTimebut none of these variables are declared or retrieved from the DOM. Onlyreasonandnotesare properly defined. This throws aReferenceErrorat runtime. Fix: addconstdeclarations withdocument.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 bew-8 h-8(64px). Symptom: icons are invisible tiny dots. Fix: changew-2 h-2tow-8 h-8on all four SVGs (lines ~452, 459, 468, 476). -
Dual
initializeApp()calls cause potential duplicate event listeners (appointments.js):initializeApp()is called from bothDOMContentLoaded(line 1573) andonAuthStateChanged(line 46). If auth fires synchronously with DOMContentLoaded, all listeners insetupEventListeners()register twice — double save notifications, double modal closes. Fix: add alet initialized = falseguard at the top ofinitializeApp()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: changee.preventDefault(); e.stopPropagation()to juste.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.codeforauth/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 genericdefaultcase showing onlyerror.message("Failed to authenticate."). Same pattern repeats in password reset, signup, and change-password handlers. Fix: replaceswitch(error.code)withif(error.status)checks: status 400 → invalid credentials, status 429 → rate limited, status 0 → network error, fallback toerror.data?.message || error.message. Also fix dead code:forgot-password-btnID changed to correctopen-change-password-modal. -
PocketBase silently drops fields not in schema (services collection): If a service is saved with
recommendationorexplanationfields but those aren't defined in the PocketBaseservicescollection schema, the fields are silently dropped (HTTP 200, no error). Fix: PATCH/api/collections/{id}with complete fields array. Seereferences/pocketbase-schema-patch.mdfor the Python script pattern. -
Service explanation not visible in quote builder:
renderSelectedServices()only showedservice.recommendation(the reason) but never renderedservice.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 andline-clamp-3. -
Service cache stale after Settings save:
repair-orders.jsloads services once at page init vialoadUserServices(). Services saved from Settings → Services tab go to PocketBase but the in-memoryuserServicesarray is never refreshed. When the user switches to the Quote Generator tab, they get stale service data. Fix: callawait loadUserServices()inswitchMainTab()beforeinitializeQuoteTab(). Also requires makingswitchMainTabanasyncfunction. -
applyShopChargenot defaulting to ON: New services viaaddSelectedService()default totrue, but services loaded from saved quotes viasetSelectedServices()pass through as-is — if the saved quote lacked the field, the checkbox renders unchecked. Fix: normalize insetSelectedServices()withapplyShopCharge: s.applyShopCharge !== false. -
Generate Priorities button missing from HTML:
renderSelectedServices()toggles#generate-priorities-btnvisibility andsetupQuoteEventListeners()attaches the click handler, but the button element didn't exist inrepair-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> 1services to>= 1. -
PDF single-service page waste:
generateQuotePDF()had excessive padding:yPos += 20before pricing summary,yPos += 40before footer, and-40page-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-recommendationfield 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
patchtool 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\\, \, nwith the 2-char\, n. Always wrap the function in(function(){...}})()and verify withnode --checkafter 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.jsmodule now implements the actual functionality behind the toggle. It captures static form fields AND dynamic state (services in quote tab viagetExtraData/onRestoreExtra). If adding new forms to the app, callwindow.autoSaveDraft.init()to wire them up. For forms with dynamic non-DOM state, use thegetExtraData/onRestoreExtra/triggerSavepattern. -
Sound alerts toggle was dead:
playAlertSound()in dashboard.js played the 800Hz beep unconditionally, ignoring thesoundAlertssetting. Fixed by addingif (window.appSettings && window.appSettings.soundAlerts === false) return;at the top of the function. -
initializeSettings()not called on all pages:initializeSettings()(which callsloadSettings(),populateSiteSettingsForm(),setupUnifiedSiteSettingsModalHandlers(),ensureSiteSettingsCloseAttributes(), etc.) must be explicitly called from every page's init code. It is NOT auto-called by loadingsettings.jsas 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: addimport { initializeSettings } from './settings.js'and callinitializeSettings()inside the page'sinitializeApp()function. Also verify the HTML element IDs match what the JS expects —appointments.jswas referencingsite-configuration-modalandsave-site-configuration-btnwhich didn't exist in the HTML (the HTML usessite-settings-modalandsave-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 callinginitializeSettings(). -
handleUseExtractedData()references 6 undefined variables (appointments.js): The function tries to set.valueoncustomerName,customerPhone,vehicleInfo,vinNumber,appointmentDate,appointmentTimebut none are declared or queried from the DOM. Onlyreasonandnotesare properly defined. ThrowsReferenceErrorat runtime. Fix: addconstdeclarations withdocument.getElementById()using correct IDs (customer-name,customer-phone,vehicle-info,vin-number,appointment-date,appointment-time). Also remove dead AI import code: handlers forimport-appointment-btn,cancel-import-btn,use-extracted-data-btn,import-results,extracted-data— all HTML elements removed but JS handlers remained (safely guarded byif(element)but dead). Also removesetupDayControlEventListeners()— 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 ofw-8 h-8(64px). Icons are invisible. Fix: changew-2 h-2tow-8 h-8on all four SVGs (lines ~452, 459, 468, 476). -
Dual
initializeApp()calls cause potential duplicate event listeners (appointments.js):initializeApp()is called from bothDOMContentLoaded(line 1573) andonAuthStateChanged(line 46). If auth fires synchronously, all listeners insetupEventListeners()register twice — double save notifications, double modal closes, double form submissions. Fix: add alet initialized = falseguard at the top ofinitializeApp()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: changee.preventDefault(); e.stopPropagation()to juste.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.codeforauth/user-not-found,auth/wrong-password,auth/invalid-email— Firebase Auth codes that never match PocketBase HTTP responses. All errors fall through to genericdefaultshowing onlyerror.message. Same pattern in password reset, signup, and change-password handlers. Fix: replaceswitch(error.code)withif(error.status)checks: 400→invalid credentials, 429→rate limited, 0→network error, fallback toerror.data?.message || error.message. Also fix dead code:forgot-password-btnID → correctopen-change-password-modal. -
Desktop notifications were dead — now wired:
showDesktopNotification(title, body)in dashboard.js checksappSettings.desktopNotifications, requestsNotification.permissionon first use, and fires for new critical overdue repair orders. Exposed globally aswindow.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) anddashboard.js(handleSaveSiteSettings) attached click handlers to the same Save button. They saved to DIFFERENT PocketBase documents —settings.js→appSettings(withdatawrapper),dashboard.js→siteConfiguration(flat). This caused toggles to revert after save because one handler's defaults (false) overwrote the other's saved values (true). Also made the modal appear broken (two handlers racing). Fix: remove thehandleSaveSiteSettingslistener from dashboard.js (line 651) and let settings.js handle everything. Dashboard.jsgetDefaultSiteSettings()defaults were also wrong (false→ corrected totruefor 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 usesid="logoutBtn"whichshared/header-functionality.jsalready handles — no JS changes needed after adding the HTML. -
Site config toggle defaults were wrong in dashboard.js:
getDefaultSiteSettings()returneddesktopNotifications: falseandsoundAlerts: false, whilesettings.jsdefaults weretrue. 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.
- 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.
- 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.
- Use style.setProperty with !important as the nuclear option — inline style beats every CSS rule. Pair with removeProperty in openModal to restore.
- 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-labelselector 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), notClose. No element on the page hasaria-label='"Close"'— they havearia-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.jshad its ownhandleSaveSiteSettingslistener on the same save button assettings.js'ssetupUnifiedSiteSettingsModalHandlers. They saved to different PocketBase documents (appSettingsvssiteConfiguration), causing toggles to revert. Removed the dashboard.js handler; onlysettings.jshandles site config saves.
-
Site configuration modal had duplicate/dead toggles: The
auto-save-formstoggle appeared in both Account Settings AND Site Configuration — removed from Site Configuration (appointments.html, repair-orders.html, index.html, customers.html). Thesite-dark-modetoggle was also in Site Configuration (appointments.html, repair-orders.html, index.html) — removed since dark mode is handled by the header toggle. Thesettings.jsbind()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. Ifsettings.jsadds a listener viaaddEventListener('click', fn)to the same button, and your onclick property handler callsstopPropagation(), BOTH fire. Usee.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 = fnin JavaScript sets a DOM property —btn.hasAttribute('onclick')still returns false. Onlybtn.setAttribute('onclick', '...')or inline HTMLonclick="..."makeshasAttributereturn true. This matters becausesettings.jsuses!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 withstopImmediatePropagation()or set the HTML attribute explicitly. -
Ollama Host and Origin checking: Ollama returns
403 Forbiddenfor any request with aHostorOriginheader that doesn't matchlocalhostor127.0.0.1. Since browsers attach the externalOriginheader (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 a413 Request Entity Too Largeerror 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).
- VLM temperature: Always set
-
CSP must be updated when removing CDN Tailwind: When switching from CDN to build-step Tailwind, the CSP meta tags must have
cdn.tailwindcss.comremoved from bothscript-srcandstyle-srcdirectives. 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 addhttps://cdnjs.cloudflare.comtoscript-srcfor 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 withgrep -l 'Content-Security-Policy' *.html— all 4 production pages should have it. Use sed to insert after<meta charset=\"UTF-8\">.\n\n- PocketBaseservicesfield can return as JSON string, not array: When loading quotes from PocketBase, theservicesfield (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 readq.servicesfrom PocketBase-loaded data. TheconvertFromPBfunction 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 callmodal.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 usemodal.classList.add('hidden')to hide modals. Neverremove()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. InsetupServiceEditEventListeners()inquote-tab-manager.js, bothcloseModal()andsaveServiceEdit()hadmodal.remove()— changed both tomodal.classList.add('hidden'). -
Save button closes modal without saving — IIFE captures
data-close-modal:ensureSiteSettingsCloseAttributes()anddashboard.jsused to setdata-close-modalon the Save button. The IIFE capture handler matches it, callsstopPropagation(), and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never setdata-close-modalon save buttons; (2) use a directonclickproperty override in the IIFE withstopImmediatePropagation()to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). Seereferences/inline-iife-modal-handlers.mdfor full event flow with phases, property-vs-attribute distinction, and code.ensureSiteSettingsCloseAttributes()anddashboard.jsused to setdata-close-modalon the Save button. The IIFE capture handler matches it, callsstopPropagation(), and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never setdata-close-modalon save buttons; (2) use a directonclickproperty override in the IIFE withstopImmediatePropagation()to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). Seereferences/inline-iife-modal-handlers.mdfor 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>withdata-delete-quoteattribute and a trash SVG (opacity-0, group-hover:opacity-100), stop click propagation so the delete doesn't trigger the load handler. ThedeleteQuote()function calls PocketBasedeleteDoc, filters the localallQuotesDatacache, and refreshes both the sidebar and modal (if open). Confirm withconfirm()before deleting. -
Quote edit-then-re-save silently fails — setDoc can't find existing records:
saveQuote()inquote-tab-manager.jsusedsetDoc()for updating existing quotes.setDoc()in pocketbase.js (line 349) searches for records by filtering onname = "${docId}"— butaddDoc()never sets anamefield on the PocketBase record. SosetDocnever finds the existing record and the update silently fails (error caught generically as "Error saving quote"). Fix: useupdateDoc()instead ofsetDoc()for existing quotes.updateDoc()(pocketbase.js line 285) directly callspb.collection(collName).update(docId, pbData)using the actual PocketBase record ID — no name-field lookup needed. Change the import fromsetDoctoupdateDocand replace thesetDoc(quoteRef, ...)call withupdateDoc(quoteRef, quoteData). Both edits insaveQuote()at lines ~2818 and ~2824.\n\n- Aftermarket parts selection invisible in PDF/print (quote-tab-manager.js):generateQuotePDF()only renderedpartsNotInStockstatus (lines 3794-3806) but completely ignoredaftermarketAvailable. 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 ifaftermarketPartsListexists. 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-700for unselected state. On thebg-gray-50service card background, these gray-on-gray buttons have near-zero contrast and are unreadable. Fix: unselected buttons usebg-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-50for 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 usebg-gray-200on 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 ofresetQuoteState(), check if the quote is unsaved (!currentQuoteId) AND has content (customer name, vehicle info, or any services selected). If both true, showconfirm('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
quotescollection has wrong/leftover schema — writes fail with "Error saving quote": Thequotescollection 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 isPATCH /api/collections/{id}with{"fields": [...]}as the body. Process: auth as superuser → GET collection schema → append{name, type, required: false}objects → PATCH. Seereferences/pocketbase-schema-patch.mdfor 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).