initial commit
This commit is contained in:
+57
@@ -0,0 +1,57 @@
|
||||
# Browser Inner-Scroll Debugging
|
||||
|
||||
## The Problem
|
||||
|
||||
The `browser_scroll` tool scrolls the document viewport. But many React SPAs (like SPQ-v2) have a fixed layout where the scrollable content lives inside an inner element (e.g., `<main>` with `overflow-y-auto`). When `browser_scroll` fires, the document moves but the inner content stays put — buttons below the fold remain invisible and unclickable.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Use `browser_console` expression to check if a target button is below the viewport:
|
||||
|
||||
```javascript
|
||||
(function(){
|
||||
var allBtns = document.querySelectorAll('button');
|
||||
for(var i=0; i<allBtns.length; i++) {
|
||||
var b = allBtns[i];
|
||||
if(b.textContent.includes('Save')) {
|
||||
return 'disabled=' + b.disabled +
|
||||
' top=' + b.getBoundingClientRect().top +
|
||||
' bottom=' + b.getBoundingClientRect().bottom +
|
||||
' windowH=' + window.innerHeight;
|
||||
}
|
||||
}
|
||||
return 'not found';
|
||||
})()
|
||||
```
|
||||
|
||||
If `bottom > windowH`, the button is off-screen and can't be clicked by the browser tool.
|
||||
|
||||
## Fix
|
||||
|
||||
Scroll the inner scrollable container to reveal the button:
|
||||
|
||||
```javascript
|
||||
(function(){
|
||||
var main = document.querySelector('main');
|
||||
if(main) {
|
||||
main.scrollTop = main.scrollHeight;
|
||||
return 'scrolled main to ' + main.scrollTop;
|
||||
}
|
||||
// Try other scrollable containers
|
||||
var candidates = document.querySelectorAll('[class*="overflow"]');
|
||||
for(var c of candidates) {
|
||||
if(c.scrollHeight > c.clientHeight) {
|
||||
c.scrollTop = c.scrollHeight;
|
||||
return 'scrolled ' + c.tagName + '.' + c.className.split(' ')[0] + ' to ' + c.scrollTop;
|
||||
}
|
||||
}
|
||||
return 'no scrollable container found';
|
||||
})()
|
||||
```
|
||||
|
||||
After scrolling, verify the button is now visible by re-running the diagnosis expression. A button is clickable when its `top` is between 0 and `windowH`.
|
||||
|
||||
## Pitfall
|
||||
|
||||
- The `browser_console` tool blocks access to `localStorage` but allows DOM queries like `querySelector` and `getBoundingClientRect` — use these for UI debugging.
|
||||
- Some SPAs have MULTIPLE scrollable containers (sidebar, main content, modal overlays). Find the right one by checking `scrollHeight > clientHeight`.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Compact Nav-Bar Header Redesign — Session Reference
|
||||
|
||||
## User Design Preferences (Ray)
|
||||
|
||||
- **Minimal header** — no logo, no title/subtitle section. Just the nav bar.
|
||||
- **Mobile page title** — show the current page name centered on mobile between the hamburger and controls.
|
||||
- **Dark mode toggle + user menu** — inline in the nav bar, right-aligned.
|
||||
- **Clean, readable colors** — white/dark cards with colored accent borders, never colored-text-on-colored-bg.
|
||||
- **High contrast** — bold dark text on light bg, bold light text on dark bg. No washed-out grays.
|
||||
- **Card depth** — subtle gradient + noticeable border in light mode, not flat white.
|
||||
|
||||
## Persistent UI Bugs Fixed This Session
|
||||
|
||||
### Dropdown Clipped by Content Below
|
||||
The user account dropdown appeared behind the "overdue tasks" cards on the dashboard.
|
||||
|
||||
**Root cause:** The outer header wrapper `<div>` had no `position` or `z-index` set. Even though the `#user-dropdown` had `z-index: 10001`, its parent wrapper was a sibling of the main content div in the DOM. Since main content comes after the header in DOM order, it painted on top. Additionally, the CSS variable `--z-critical` was used in `#user-dropdown { z-index: var(--z-critical); }` but never defined anywhere.
|
||||
|
||||
**Fix (3 parts):**
|
||||
1. Added `position: relative; z-index: 1000` to the outer header wrapper div (creates stacking context at body level)
|
||||
2. Added `:root { --z-critical: 999999; }` to `style.css`
|
||||
3. Added `.user-menu-container { overflow: visible !important; position: relative !important; }` to `style.css`
|
||||
4. Removed `overflow-hidden` Tailwind class from `.header-card` elements on all 4 pages
|
||||
|
||||
## Header HTML Template
|
||||
|
||||
```html
|
||||
<div class="bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||
<div class="container mx-auto p-2 md:p-4">
|
||||
<div class="header-card text-white rounded-2xl shadow-lg overflow-hidden relative overflow-visible-important">
|
||||
<div class="bg-white/10" id="main-navigation">
|
||||
<div class="px-3 md:px-4 lg:px-6">
|
||||
<div class="flex items-center justify-between py-2 md:py-2.5">
|
||||
<!-- Left: Mobile Hamburger + Desktop Nav Links -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button type="button" id="mobile-menu-btn" class="lg:hidden p-2 rounded-xl bg-white/10 hover:bg-white/20 transition-all duration-200 shadow-sm" aria-label="Toggle mobile menu">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="hidden lg:flex items-center gap-0.5">
|
||||
<!-- Nav links here with active/inactive classes -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Page Title -->
|
||||
<span class="lg:hidden text-sm font-semibold text-white">Page Name</span>
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-1.5 md:gap-2">
|
||||
<div class="hidden sm:flex items-center gap-1.5 p-1.5 rounded-xl bg-white/10">
|
||||
<span class="text-xs font-medium text-blue-100 whitespace-nowrap">Dark Mode</span>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="dark-mode-toggle">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="user-menu-container relative">
|
||||
<!-- User menu button + dropdown -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Navigation -->
|
||||
<div id="mobile-navigation" class="lg:hidden hidden py-2 border-t border-white/20">
|
||||
<div class="space-y-1">
|
||||
<!-- Mobile nav links here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Key Pattern: Active Link Per Page
|
||||
|
||||
When constructing the header for each page, only ONE nav link should use the active style (`bg-white/20 text-white shadow-sm`). All others use inactive (`text-blue-100 hover:...`). This applies to both desktop and mobile nav link lists.
|
||||
|
||||
## ⚠️ Critical: Z-Index for Dropdown After Header Redesign
|
||||
|
||||
After removing the tall header section, the user account dropdown will likely appear behind page content. **This must be fixed separately from the header HTML changes.** The outer header wrapper needs:
|
||||
|
||||
```html
|
||||
<div class="bg-white dark:bg-gray-900 border-b ..." style="position: relative; z-index: 1000;">
|
||||
```
|
||||
|
||||
Additionally:
|
||||
- Remove `overflow-hidden` from the header-card class list (conflicts with dropdown overflow)
|
||||
- Define `:root { --z-critical: 999999; }` in style.css if `var(--z-critical)` is referenced
|
||||
- Ensure `.user-menu-container { overflow: visible !important; }` is in the CSS
|
||||
|
||||
See section 7 of the SKILL.md for the full diagnosis and fix checklist.
|
||||
|
||||
## ⚠️ Duplicate CSS Selectors
|
||||
|
||||
Some pages accumulated multiple `.dashboard-card` definitions in the same `<style>` block across different edit sessions. The LAST definition wins (same specificity), which can cause override confusion. After making structural changes, grep for duplicate class definitions in the page's inline CSS and consolidate.
|
||||
|
||||
## Pages Converted This Session
|
||||
|
||||
All 4 pages at `/mnt/media/Web App Builds/Website - -v17 - 8-9-25 - Copy/`:
|
||||
|
||||
| Page | File | Outer Wrapper | Mobile Title |
|
||||
|------|------|--------------|--------------|
|
||||
| Dashboard | `index.html` | Gradient | Dashboard |
|
||||
| Repair Orders | `repair-orders.html` | White/dark solid | Repair Orders |
|
||||
| Customers | `customers.html` | Gradient | Customers |
|
||||
| Appointments | `appointments.html` | Gradient | Appointments |
|
||||
|
||||
## Key Pattern: Active Link Per Page
|
||||
|
||||
When constructing the header for each page, only ONE nav link should use the active style (`bg-white/20 text-white shadow-sm`). All others use inactive (`text-blue-100 hover:...`). This applies to both desktop and mobile nav link lists.
|
||||
@@ -0,0 +1,71 @@
|
||||
# Cross-Page Settings Desync: Missing initializeSettings
|
||||
|
||||
## Diagnostic Signal
|
||||
|
||||
User toggles a setting (desktop notifications, sound alerts, dark mode) on **page A**, saves — it sticks. Navigates to **page B** — the same toggle shows its **default/off state**. Toggling+saving on page B works on page B but page A reverts. Each page "remembers" only its own saves.
|
||||
|
||||
## Root Cause
|
||||
|
||||
A shared settings module (`settings.js`) exports `initializeSettings()`, which:
|
||||
1. Calls `loadSettings()` — fetches saved settings from PocketBase/localStorage
|
||||
2. Calls `populateSiteSettingsForm()` — sets checkbox/select values from loaded settings
|
||||
3. Calls `setupUnifiedSiteSettingsModalHandlers()` — wires close/save/cancel buttons
|
||||
4. Calls `subscribeToRealtimeSettings()` — sets up change listeners
|
||||
|
||||
If **any page** in the app loads `settings.js` as a `<script type="module">` but **never calls `initializeSettings()`**, that page runs with module defaults. The form controls show their raw HTML state (unchecked for `input[type=checkbox]` without a `checked` attribute).
|
||||
|
||||
## Common Pattern
|
||||
|
||||
```
|
||||
repair-orders.js: import { initializeSettings } from './settings.js'
|
||||
await initializeSettings(...); // ✅ called
|
||||
|
||||
customers.js: import { initializeSettings } from './settings.js'
|
||||
await initializeSettings(...); // ✅ called
|
||||
|
||||
main.js: import { initializeSettings } from './settings.js'
|
||||
initializeSettings(...); // ✅ called
|
||||
|
||||
dashboard.js: settingsModule.initializeSettings(...); // ✅ called
|
||||
|
||||
appointments.js: // ❌ imports settings.js? Yes (loaded as module)
|
||||
// ❌ calls initializeSettings()? NO
|
||||
// → settings desync on appointments page
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
Add the import and call to every page that has a site settings modal:
|
||||
|
||||
```javascript
|
||||
// At top of page's JS file:
|
||||
import { initializeSettings } from './settings.js';
|
||||
|
||||
// Inside the page's init function (after auth check):
|
||||
initializeSettings();
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# Check every page's JS for the initializeSettings call
|
||||
for js in repair-orders.js appointments.js customers.js main.js dashboard.js; do
|
||||
echo -n "$js: "
|
||||
grep -c 'initializeSettings' "$js"
|
||||
done
|
||||
# Every result should be ≥ 2 (import + call site). A result of 0 or 1 = bug.
|
||||
```
|
||||
|
||||
## PocketBase Pitfall: onSnapshot is One-Shot
|
||||
|
||||
The `onSnapshot` function in PocketBase adapter (`pocketbase.js`) is a **one-shot fetch**, not a realtime listener. It fires once when called, then never again:
|
||||
|
||||
```javascript
|
||||
function onSnapshot(queryOrDocRef, callback) {
|
||||
// Simple one-shot onSnapshot compatibility shim
|
||||
getDoc(queryOrDocRef).then(doc => callback(doc));
|
||||
return () => {}; // Unsubscribe is a no-op
|
||||
}
|
||||
```
|
||||
|
||||
This means `subscribeToRealtimeSettings()` does NOT provide realtime cross-page sync. Settings saved on page A are persisted to PocketBase, but page B won't see the change until it **reloads** (calling `loadSettings()` fresh). This is fine for navigation-based sync (each page loads settings on init), but means two open tabs won't stay in sync without a manual refresh.
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# Permanent Dark Mode Fix — 4-Page Web App
|
||||
|
||||
## Context from session (May 31, 2026)
|
||||
|
||||
A static web app with 4 pages (Dashboard, Repair Orders, Customers, Appointments) using Tailwind CSS with `darkMode: 'class'` config. The user wanted to permanently remove light mode and the dark mode toggle.
|
||||
|
||||
## Files Modified
|
||||
|
||||
### JS Files (9 toggle points → `classList.add('dark')`)
|
||||
|
||||
| File | Line | What Changed |
|
||||
|------|------|-------------|
|
||||
| `ui.js` | handleDarkModeToggle | Changed entire function to always add dark class |
|
||||
| `ui.js` | initializeDarkMode else branch | Changed from remove dark to add dark |
|
||||
| `shared/header-functionality.js` | Lines 123, 145 (×2) | `toggle('dark', e.target.checked)` → `add('dark')` |
|
||||
| `dashboard.js` | Line 236 | `toggle('dark', dark)` → `add('dark')` |
|
||||
| `customers.js` | Line 63 | `toggle('dark', val)` → `add('dark')` |
|
||||
| `settings.js` | applyDarkModeLocally | `toggle('dark', isDark)` → `add('dark')` |
|
||||
| `settings.js` | site-dark-mode change handler | `toggle('dark', checked)` → `add('dark')` |
|
||||
| `settings.js` | remote settings sync | `toggle('dark', dark)` → `add('dark')` |
|
||||
|
||||
### HTML Files (toggle removal)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `index.html` | Removed header toggle + mobile dropdown toggle |
|
||||
| `repair-orders.html` | Same |
|
||||
| `customers.html` | Same |
|
||||
| `appointments.html` | Same |
|
||||
|
||||
### CSS Files
|
||||
|
||||
`style.css` had aggressive override rules added at the top (body background, .bg-white, .text-gray-*, .border-gray-*, etc.) but these were ultimately not needed once all JS toggle points were patched. They were left in as a safety net but the real fix was the JS patches.
|
||||
|
||||
### Search Pattern for Finding All Toggle Points
|
||||
|
||||
```bash
|
||||
grep -rn "classList.*toggle.*dark\|classList.*remove.*dark" *.js shared/*.js
|
||||
```
|
||||
|
||||
Result showed 9 hits across 5 files (see table above).
|
||||
@@ -0,0 +1,68 @@
|
||||
# Dropdown Portal / Teleport Pattern
|
||||
|
||||
When a dropdown is clipped by a parent with `overflow: hidden` or trapped inside a stacking context, CSS z-index alone cannot fix it. The solution is to move the dropdown to a portal root at the end of `<body>` and position it with `position: fixed` + `getBoundingClientRect()`.
|
||||
|
||||
## Setup
|
||||
|
||||
Every page needs `<div id="dropdown-root"></div>` right before `</body>`. This is a portal target — a direct child of `<body>` with no clipping ancestors.
|
||||
|
||||
## Vanilla JS Implementation
|
||||
|
||||
```javascript
|
||||
function showDropdown(dropdown, anchorInput) {
|
||||
// 1. Move to portal
|
||||
const root = document.getElementById('dropdown-root');
|
||||
if (root && dropdown.parentElement !== root) {
|
||||
root.appendChild(dropdown);
|
||||
}
|
||||
|
||||
// 2. Position relative to anchor
|
||||
const rect = anchorInput.getBoundingClientRect();
|
||||
dropdown.style.position = 'fixed';
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = rect.width + 'px';
|
||||
dropdown.style.zIndex = '99999';
|
||||
dropdown.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 3. Reposition on scroll/resize
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!dropdown.classList.contains('hidden')) {
|
||||
const rect = anchorInput.getBoundingClientRect();
|
||||
dropdown.style.top = (rect.bottom + 4) + 'px';
|
||||
dropdown.style.left = rect.left + 'px';
|
||||
dropdown.style.width = rect.width + 'px';
|
||||
}
|
||||
}, { passive: true });
|
||||
```
|
||||
|
||||
## React Implementation
|
||||
|
||||
```tsx
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
function Dropdown({ open, anchorRect, children }) {
|
||||
if (!open) return null;
|
||||
return createPortal(
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: anchorRect.bottom + 4,
|
||||
left: anchorRect.left,
|
||||
width: anchorRect.width,
|
||||
zIndex: 99999,
|
||||
}}>
|
||||
{children}
|
||||
</div>,
|
||||
document.getElementById('dropdown-root')!
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Why z-index alone fails
|
||||
|
||||
A parent with `overflow: hidden`, `transform`, `will-change`, or `position: relative` creates a new stacking context. The dropdown's z-index is scoped within that parent — it can never escape to be above a sibling element that comes later in the DOM.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
If `z-index: 99999 !important` on the dropdown doesn't fix clipping, and the dropdown is inside any ancestor with `overflow: hidden` or `position: relative`, this is the fix.
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
# Event Propagation: IIFE Capture-Phase Handler vs Save Buttons
|
||||
|
||||
## The Bug
|
||||
|
||||
On pages with the [Universal Modal Manager](#) IIFE (Section 14), clicking a Save button in the site-settings-modal closes the modal without saving. The form appears to work (checkboxes toggle, dropdowns change) but clicking Save does nothing.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The IIFE adds a capture-phase listener on `document`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('click', function(e) {
|
||||
var closeBtn = e.target.closest('[aria-label="Close"],[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // ← KILLS all subsequent handlers
|
||||
// ... closes modal
|
||||
}
|
||||
}, true); // capture phase
|
||||
```
|
||||
|
||||
`ensureSiteSettingsCloseAttributes()` in `settings.js` sets `data-close-modal` on **all three** buttons: close, cancel, AND save. When the user clicks Save:
|
||||
|
||||
1. Capture phase: IIFE matches `[data-close-modal]` on save button
|
||||
2. Calls `e.stopPropagation()` — prevents target phase (inline `onclick`) and bubble phase (`addEventListener` handlers) from ever firing
|
||||
3. Calls `window.closeModal('site-settings-modal')` — modal closes
|
||||
4. The save handler (`window.saveSiteSettings` or `setupUnifiedSiteSettingsModalHandlers.saveSettings`) **never runs**
|
||||
|
||||
## Diagnosis Signal
|
||||
|
||||
The save works on pages WITHOUT the IIFE (e.g., index.html, appointments.html, customers.html) but silently fails on pages WITH it (repair-orders.html).
|
||||
|
||||
To confirm: check if `ensureSiteSettingsCloseAttributes()` sets `data-close-modal` on the save button, and whether the page has the capture-phase IIFE:
|
||||
|
||||
```bash
|
||||
# Check for data-close-modal on save button
|
||||
grep -n 'save-site-settings.*data-close-modal' page.html
|
||||
|
||||
# Check for capture-phase IIFE
|
||||
grep -n "addEventListener('click'" page.html | grep 'true)'
|
||||
```
|
||||
|
||||
## The Fix (Three Parts)
|
||||
|
||||
### Part 1: Don't tag Save buttons with `data-close-modal`
|
||||
|
||||
In `settings.js`, `ensureSiteSettingsCloseAttributes()`:
|
||||
|
||||
```javascript
|
||||
// BEFORE (broken): sets data-close-modal on save button too
|
||||
const map = [{
|
||||
modalId: 'site-settings-modal',
|
||||
closeId: 'close-site-settings',
|
||||
cancelId: 'cancel-site-settings',
|
||||
saveId: 'save-site-settings' // ← REMOVE THIS
|
||||
}];
|
||||
|
||||
// AFTER (fixed): only close and cancel
|
||||
const map = [{
|
||||
modalId: 'site-settings-modal',
|
||||
closeId: 'close-site-settings',
|
||||
cancelId: 'cancel-site-settings'
|
||||
}];
|
||||
```
|
||||
|
||||
Same fix in `dashboard.js` if it also sets `data-close-modal` on save.
|
||||
|
||||
### Part 2: Remove inline `onclick` from Save button
|
||||
|
||||
Remove `onclick="saveSiteSettings()"` from the HTML. This prevents a double-fire when both the inline handler and the module-level `addEventListener` handler run.
|
||||
|
||||
### Part 3: Set `onclick` property in the IIFE with `stopImmediatePropagation()`
|
||||
|
||||
At the end of the IIFE (after the capture-phase handler), set the save button's `onclick` property directly:
|
||||
|
||||
```javascript
|
||||
// In the IIFE, after the capture-phase addEventListener
|
||||
var saveSettingsBtn = document.getElementById('save-site-settings');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.onclick = function(e) {
|
||||
e.stopImmediatePropagation(); // prevents module-level addEventListener
|
||||
if (typeof window.saveSiteSettings === 'function') {
|
||||
window.saveSiteSettings();
|
||||
} else {
|
||||
// Fallback: direct localStorage save
|
||||
var s = {};
|
||||
try { var ex = localStorage.getItem('quoteGenSettings_v5');
|
||||
if (ex) s = JSON.parse(ex); } catch(ign) {}
|
||||
var gv = function(id, ck) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return ck ? false : '';
|
||||
return ck ? !!el.checked : el.value;
|
||||
};
|
||||
s.darkMode = false;
|
||||
s.refreshRate = parseInt(gv('refresh-rate')) || 60;
|
||||
s.itemsPerPage = parseInt(gv('items-per-page')) || 25;
|
||||
s.desktopNotifications = gv('desktop-notifications', true);
|
||||
s.soundAlerts = gv('sound-alerts', true);
|
||||
s.criticalAlerts = gv('critical-alerts', true);
|
||||
s.notifyBeforePromised = parseInt(gv('notify-before-promised')) || 0;
|
||||
s.timezone = gv('timezone-select') || 'America/New_York';
|
||||
s.autoSaveForms = gv('auto-save-forms', true);
|
||||
localStorage.setItem('quoteGenSettings_v5', JSON.stringify(s));
|
||||
}
|
||||
window.closeModal('site-settings-modal');
|
||||
return false;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Why `onclick` property instead of `addEventListener`?
|
||||
|
||||
Event phases execute in this order:
|
||||
1. **Capture phase** — IIFE on `document` (ignores save button now, no `data-close-modal`)
|
||||
2. **Target phase** — `onclick` property fires → saves → calls `stopImmediatePropagation()`
|
||||
3. **Bubble phase** — blocked by `stopImmediatePropagation()`; module-level `addEventListener` never fires
|
||||
|
||||
The `onclick` property fires in the target phase, AFTER the IIFE capture handler has already decided to ignore it, but BEFORE any bubbling-phase handlers. `stopImmediatePropagation()` then prevents duplicate saves from module-level listeners.
|
||||
|
||||
## Alternative: Add save handling to the capture-phase IIFE
|
||||
|
||||
Instead of Part 3 above, you can also handle the save in the capture-phase handler itself (before the close/cancel checks):
|
||||
|
||||
```javascript
|
||||
document.addEventListener('click', function(e) {
|
||||
// Check save button FIRST
|
||||
var saveBtn = e.target.closest('#save-site-settings');
|
||||
if (saveBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // prevents everything
|
||||
if (typeof window.saveSiteSettings === 'function') {
|
||||
window.saveSiteSettings();
|
||||
} else {
|
||||
// ... fallback save ...
|
||||
}
|
||||
window.closeModal('site-settings-modal');
|
||||
return;
|
||||
}
|
||||
// Then close/cancel checks...
|
||||
}, true);
|
||||
```
|
||||
|
||||
This is simpler but re-adding `data-close-modal` would be required, and the capture handler must be kept in sync with the save logic.
|
||||
|
||||
## Full Event Trace (After Fix)
|
||||
|
||||
For the Save button after all three fixes:
|
||||
|
||||
| Phase | Handler | Action |
|
||||
|-------|---------|--------|
|
||||
| Capture (document) | IIFE `addEventListener(..., true)` | No `data-close-modal` match → ignored |
|
||||
| Target | `saveSettingsBtn.onclick` | Reads form, saves to localStorage, calls `closeModal()` |
|
||||
| Bubble | Module `addEventListener` | Blocked by `stopImmediatePropagation()` |
|
||||
|
||||
For Close/Cancel (unchanged):
|
||||
|
||||
| Phase | Handler | Action |
|
||||
|-------|---------|--------|
|
||||
| Capture (document) | IIFE | Matches `[aria-label="Close"]` or `[data-close-modal]` → closes |
|
||||
| Target | `onclick` attribute | Blocked by `stopPropagation()` |
|
||||
| Bubble | Module `addEventListener` | Blocked by `stopPropagation()` |
|
||||
@@ -0,0 +1,171 @@
|
||||
---
|
||||
name: frontend-debugging
|
||||
title: Frontend Debugging Patterns
|
||||
description: Non-trivial debugging patterns for static HTML/CSS/JS web apps — JS-overrides-HTML, missing script imports, cache-busting, and common pitfalls.
|
||||
---
|
||||
|
||||
# Frontend Debugging Patterns
|
||||
|
||||
## When Static HTML Edits Don't Take Effect
|
||||
|
||||
**Check if JS dynamically generates the HTML at runtime.** This is the #1 cause of "I edited the file but nothing changed."
|
||||
|
||||
- Search the JS files for `innerHTML`, `createElement`, `textContent =`, or DOM `appendChild` calls that target the same element IDs/classes
|
||||
- The DOM inspector in DevTools shows live state — if it differs from your source file, JS is overwriting it
|
||||
- Fix: edit the JS template string or the JS rendering function, not just the static HTML
|
||||
|
||||
**Special case: color/readability fixes.** When a user reports "yellow on yellow" or "hard to read" and you fix the static HTML but they still see the old colors, the culprit is almost certainly a JS `innerHTML` template string that dynamically generates the colored elements. Search for `.innerHTML` in JS files and look for `text-yellow-*`, `bg-yellow-*`, or similar color classes — that's where the real fix lives.
|
||||
|
||||
## Missing Script/Module Imports
|
||||
|
||||
A page element exists in HTML but has no event handler → the JS file that wires it up probably isn't loaded.
|
||||
|
||||
- Check the `<script>` tags at the bottom of the page — compare against a page where the feature works
|
||||
- Common misses: shared module files like `shared/header-functionality.js`, `shared/dropdown-manager.js`
|
||||
- Also check that the JS actually targets the right element ID (typo in getElementById)
|
||||
|
||||
## When Inline onclick Exists but the Button Still Fails
|
||||
|
||||
An inline `onclick` handler is present on the element and the logic looks correct, but clicking does nothing. Two silent causes:
|
||||
|
||||
**Cause A — The target element is missing from this page.** The onclick calls `getElementById('some-modal')` but that modal div only exists on other pages. The `if(m)` guard silently returns — no error, no visual feedback. Fix: add the missing modal HTML to the page. Audit all `getElementById` targets across pages with:
|
||||
```bash
|
||||
for page in *.html; do
|
||||
grep -oP "getElementById\('([^']+)'\)" "$page" | \
|
||||
sed "s/getElementById('//;s/')//" | sort -u | while read id; do
|
||||
grep -q "id=\"$id\"" "$page" || echo "MISSING: $id in $page"
|
||||
done
|
||||
done
|
||||
```
|
||||
|
||||
**Cause B — File permissions blocking the module chain.** A shared dependency (e.g., `pocketbase.js`, `shared/sanitize.js`) has owner-only permissions (600). nginx runs as `www-data` and returns HTTP 403. The browser can't load it → every ES module that imports it silently fails → no JS handlers attach. Even inline onclick on other elements may work, but module-reliant features die. Fix: `find . -name "*.js" -exec chmod 644 {} +`, then `curl -skI https://site.com/key-file.js | head -1` to verify 200.
|
||||
|
||||
**Cause C — Module handler double-toggle conflict.** An inline `onclick` and a JS module handler (e.g., from `shared/header-functionality.js`) both attach to the same button and BOTH toggle the same `hidden` class. The inline fires first → toggles hidden OFF (visible). The module fires second → toggles hidden ON (hidden again). Net result: nothing happens. **This is especially common with hamburger menus and user dropdowns** after inline onclick fallbacks are added to pages that also load the module-driven header.
|
||||
|
||||
Fix: wrap the inline onclick in an IIFE that calls `e.stopImmediatePropagation()` to prevent the module handler from also firing:
|
||||
|
||||
```html
|
||||
<!-- BEFORE (double-toggle, no visible effect) -->
|
||||
<button onclick="var n=document.getElementById('mobile-navigation');if(n){n.classList.toggle('hidden')}">
|
||||
|
||||
<!-- AFTER (inline fires first, module suppressed) -->
|
||||
<button onclick="(function(e){e.stopImmediatePropagation();var n=document.getElementById('mobile-navigation');if(!n)return;var hidden=n.classList.toggle('hidden');n.style.display=hidden?'none':'block';n.style.zIndex='10000'})(event)">
|
||||
```
|
||||
|
||||
Key details:
|
||||
- Use `stopImmediatePropagation()` (not just `stopPropagation`) — the module handler is on the same element
|
||||
- Wrap in IIFE `(function(e){...})(event)` so `stopImmediatePropagation` receives the actual event object
|
||||
- Explicitly set `style.display` (not just class toggle) for defense-in-depth against CSS specificity
|
||||
- `index.html` (dashboard) often does NOT load `header-functionality.js` — no conflict, no fix needed
|
||||
- Pages that DO load the module (repair-orders, appointments, customers) all need the fix
|
||||
|
||||
## Class-Based Dark Mode: When Fixes Don't Stick
|
||||
|
||||
**Symptom:** You add `class="dark"` to `<html>`, or fix a dark mode toggle handler, but something still switches the site to light mode.
|
||||
|
||||
**Root cause:** Multiple JS files independently control the `.dark` class. You patched one but missed others hiding in settings sync code.
|
||||
|
||||
**The fix requires patching EVERY instance of `classList.toggle('dark', ...)` and `classList.remove('dark')` across ALL JS files — not just the obvious UI toggle handlers:**
|
||||
|
||||
```bash
|
||||
grep -rn "classList.*toggle.*dark\|classList.*remove.*dark" *.js **/*.js
|
||||
```
|
||||
|
||||
Common hidden locations:
|
||||
- Settings sync code in `dashboard.js`, `customers.js`, `settings.js`
|
||||
- Remote settings listeners that apply dark mode after a server sync
|
||||
- Mobile-specific toggle handlers in shared modules
|
||||
|
||||
**Verification after patching:**
|
||||
1. Hard refresh — should load dark
|
||||
2. Click every UI element that previously controlled dark mode — no change
|
||||
3. Open settings modals — any dark mode toggle should be inoperable
|
||||
4. Search localStorage in DevTools — `darkMode` should always be `'true'`
|
||||
|
||||
**Pitfall:** Don't rely solely on CSS `!important` overrides to force dark colors (e.g. `.bg-white { background: #1f2937 !important; }`). Tailwind CDN generates styles dynamically that can compete with your overrides. Patching the JS at the source is more reliable.
|
||||
|
||||
## Cache-Busting for Static Servers
|
||||
|
||||
Python's `http.server` and similar dev servers serve files fresh each request, but **browsers cache aggressively**, especially on mobile.
|
||||
|
||||
Solutions in order of effectiveness:
|
||||
1. Open page in incognito/private tab
|
||||
2. Hard refresh (Ctrl+Shift+R / Cmd+Shift+R)
|
||||
3. Add cache-busting meta tags to `<head>`:
|
||||
```html
|
||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||
<meta http-equiv="Pragma" content="no-cache">
|
||||
<meta http-equiv="Expires" content="0">
|
||||
```
|
||||
4. Restart the server to force fresh TCP connections
|
||||
|
||||
## Mobile Responsiveness Pitfalls
|
||||
|
||||
### Hamburger Menu Not Working
|
||||
Check four things in order:
|
||||
1. The hamburger button exists in HTML (search for `mobile-menu-btn`)
|
||||
2. The target `#mobile-navigation` panel exists in the HTML
|
||||
3. **Module conflict**: `shared/header-functionality.js` adds its own click handler that also toggles `hidden` — the inline onclick and module handler cancel each other out. Add `stopImmediatePropagation()` to the inline onclick (see Cause C above)
|
||||
4. The JS file that handles the click is loaded (check `<script>` tags)
|
||||
|
||||
### Hidden-by-Default Mobile Elements
|
||||
Mobile nav panels are usually `hidden` by default and toggled by JS. If the panel never appears:
|
||||
- Check the CSS class includes `hidden` in the initial state
|
||||
- Verify the JS toggle uses `classList.toggle('hidden')` and isn't broken by JS errors earlier in execution
|
||||
|
||||
## CSS Consistency Across Pages
|
||||
|
||||
## Escaped Characters in Inline Scripts Breaking Selectors
|
||||
|
||||
**Symptom:** An inline `<script>` block has a `document.addEventListener('click', ...)` handler that checks for close buttons via `[aria-label="Close"]`, but clicking the (X) button does nothing. The button clearly has `aria-label="Close"` in the HTML. The same handler works on other pages. Console shows no errors.
|
||||
|
||||
**Root cause:** Double-escaping in inline `<script>` blocks. When the HTML source contains `\\\"` (backslash-backslash-quote), the browser's HTML parser resolves it to a literal backslash-quote sequence (`\"`) in the JavaScript string. A CSS selector like `[aria-label=\"Close\"]` then matches the literal attribute value `"Close"` (with quote characters) instead of the actual HTML attribute value `Close` (without quotes).
|
||||
|
||||
**The broken pattern:**
|
||||
```javascript
|
||||
// In the HTML source, this looks like: [aria-label=\\\"Close\\\"]
|
||||
// After HTML parsing, JS sees: [aria-label=\"Close\"]
|
||||
// This selector matches: aria-label='"Close"' (quotes in value) — NEVER correct
|
||||
var closeBtn = e.target.closest('[aria-label=\\\"Close\\\"]');
|
||||
```
|
||||
|
||||
**The fix — use normal quotes:**
|
||||
```javascript
|
||||
// In the HTML source, this looks like: [aria-label="Close"]
|
||||
// After HTML parsing, JS sees: [aria-label="Close"]
|
||||
// This selector matches: aria-label="Close" — CORRECT
|
||||
var closeBtn = e.target.closest('[aria-label="Close"]');
|
||||
```
|
||||
|
||||
**Alternative fix — use single quotes in JS:**
|
||||
```javascript
|
||||
var closeBtn = e.target.closest("[aria-label='Close']");
|
||||
```
|
||||
|
||||
**Diagnosis:** Use `read_file` on the HTML and look at the raw text. If you see `\\\"` sequences inside `<script>` tags, the selectors are silently broken. The terminal command `sed -n 'LINENOp' file.html | cat -A` will show the raw bytes — `\\` displays as literal backslashes.
|
||||
|
||||
**Verification after fixing:** `curl -sk https://site.com/page.html | grep 'aria-label.*Close'` should show `aria-label="Close"` (no backslashes between label and value).
|
||||
|
||||
When making the same header/nav change across multiple pages:
|
||||
- Do ONE page first, get user approval, then apply the same diff pattern to the others
|
||||
- Search for the exact HTML structure in each file — they may have drifted (different padding classes, different wrapper divs)
|
||||
- For shared CSS classes (`.header-card`, `.nav-link`), editing the CSS file once fixes all pages
|
||||
|
||||
## IIFE Capture-Phase Handler + data-close-modal on Save Buttons
|
||||
|
||||
**Symptom:** On a page with the Universal Modal Manager IIFE, clicking the Save button in a settings modal closes the modal but changes don't persist. The same modal works perfectly on other pages. Close/Cancel buttons work fine — only Save is broken.
|
||||
|
||||
**Root cause:** `ensureSiteSettingsCloseAttributes()` (or dashboard.js) sets `data-close-modal` on the Save button. The IIFE capture-phase handler matches `[data-close-modal]`, calls `stopPropagation()`, and closes the modal — before any save handler fires.
|
||||
|
||||
**Diagnosis:** Compare a working page against the broken page:
|
||||
- Working pages (e.g., index.html, appointments.html) don't have the capture-phase IIFE
|
||||
- Broken page (repair-orders.html) has the IIFE + save button tagged with `data-close-modal`
|
||||
|
||||
**Fix:** See `references/event-propagation-iife-conflict.md` for full trace, three-part fix, and alternative approach.
|
||||
|
||||
## Color/Readability Rules of Thumb
|
||||
|
||||
- Never use colored text on the same-colored background (e.g. `text-yellow-700` on `bg-yellow-50`)
|
||||
- Use white/dark cards with **colored left-border accents** for alert banners — clean, modern, readable
|
||||
- For count badges: use subtle colored background (`bg-red-50`) with **bold darker text** (`text-red-700`), not washed-out tones
|
||||
- Labels should use neutral gray tones (`text-gray-500/600/700`) for readability across themes
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
# IIFE Capture-Phase Handler Kills Save Button
|
||||
|
||||
## Pattern
|
||||
|
||||
A page has an inline IIFE `<script>` block (not `type="module"`) that registers a capture-phase click handler on `document`:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('click', function(e) {
|
||||
var closeBtn = e.target.closest('[aria-label="Close"],[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // ← THIS IS THE KILLER
|
||||
var id = closeBtn.getAttribute('data-close-modal');
|
||||
if (id) { closeModal(id); return; }
|
||||
// ... fallback close logic ...
|
||||
}
|
||||
// ... backdrop click handling ...
|
||||
}, true); // ← capture phase
|
||||
```
|
||||
|
||||
Some JS module (e.g., `settings.js`) sets `data-close-modal` on close, cancel, AND save buttons:
|
||||
|
||||
```javascript
|
||||
saveSiteSettings.setAttribute('data-close-modal', 'site-settings-modal');
|
||||
```
|
||||
|
||||
## Symptom
|
||||
|
||||
- Close (X) and Cancel buttons work correctly — modal closes
|
||||
- Save button closes the modal but **settings are never persisted**
|
||||
- No console errors
|
||||
- The save function is defined and reachable (inline `onclick` or `addEventListener` exists)
|
||||
|
||||
## Root Cause
|
||||
|
||||
When the user clicks Save:
|
||||
|
||||
1. **Capture phase** — IIFE handler on `document` fires FIRST. Matches `[data-close-modal]` on the save button. Calls `closeModal()` immediately. Calls `e.stopPropagation()` which **prevents the event from ever reaching the target phase or bubble phase**.
|
||||
2. **Target phase** — NEVER REACHED. The inline `onclick="saveSiteSettings()"` never fires.
|
||||
3. **Bubble phase** — NEVER REACHED. Any `addEventListener('click', handler)` on the button never fires.
|
||||
|
||||
The modal closes (step 1 did that), but the save logic never executes.
|
||||
|
||||
## Why Close/Cancel Still Work
|
||||
|
||||
For close and cancel buttons, closing the modal IS the correct action. The IIFE intercepting and closing is fine — no additional behavior was needed.
|
||||
|
||||
## Fix (Three Options)
|
||||
|
||||
### Option A: Remove `data-close-modal` from save button (cleanest)
|
||||
|
||||
In the code that sets attributes (`ensureSiteSettingsCloseAttributes` or dashboard init):
|
||||
|
||||
```javascript
|
||||
// BEFORE (broken)
|
||||
setAttr(closeId); // ✓ fine
|
||||
setAttr(cancelId); // ✓ fine
|
||||
setAttr(saveId); // ✗ kills save handler
|
||||
|
||||
// AFTER (fixed)
|
||||
setAttr(closeId); // ✓
|
||||
setAttr(cancelId); // ✓
|
||||
// Do NOT set on saveId — let addEventListener handle it
|
||||
```
|
||||
|
||||
Then wire the save button via `addEventListener` (non-capture) so it fires after the IIFE ignores it:
|
||||
|
||||
```javascript
|
||||
saveBtn.addEventListener('click', async function() {
|
||||
// read form, save settings
|
||||
await saveAllSettings();
|
||||
closeModal('site-settings-modal');
|
||||
});
|
||||
```
|
||||
|
||||
The IIFE no longer matches the save button → capture phase passes through → target/bubble handlers fire normally.
|
||||
|
||||
### Option B: Add save handling to the IIFE itself
|
||||
|
||||
Insert a save-button check BEFORE the close-button check in the capture handler:
|
||||
|
||||
```javascript
|
||||
document.addEventListener('click', function(e) {
|
||||
// Check save button FIRST
|
||||
var saveBtn = e.target.closest('#save-site-settings');
|
||||
if (saveBtn) {
|
||||
e.stopPropagation();
|
||||
// Save directly or call window.saveSiteSettings()
|
||||
window.saveSiteSettings();
|
||||
window.closeModal('site-settings-modal');
|
||||
return;
|
||||
}
|
||||
// Then check close/cancel as before
|
||||
var closeBtn = e.target.closest('[aria-label="Close"],[data-close-modal]');
|
||||
// ...
|
||||
}, true);
|
||||
```
|
||||
|
||||
This works but couples the IIFE to specific button IDs. Option A is cleaner.
|
||||
|
||||
### Option C: Direct onclick property override (nuclear option)
|
||||
|
||||
Set `saveBtn.onclick` from the IIFE (firing in target phase, before bubbling):
|
||||
|
||||
```javascript
|
||||
var btn = document.getElementById('save-site-settings');
|
||||
if (btn) {
|
||||
btn.onclick = function(e) {
|
||||
e.stopImmediatePropagation();
|
||||
// save logic
|
||||
window.closeModal('site-settings-modal');
|
||||
return false;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
`stopImmediatePropagation()` prevents other listeners (from JS modules) on the same element from firing, avoiding duplicate saves.
|
||||
|
||||
## Diagnosis Checklist
|
||||
|
||||
1. Does the page have a capture-phase IIFE? Search for `addEventListener('click', ..., true)` or `},true)` in inline `<script>` blocks.
|
||||
2. Does the save button have `data-close-modal`? Check the HTML and any JS that sets attributes.
|
||||
3. Does the save button have an inline `onclick` or an `addEventListener` handler?
|
||||
4. If yes to all three → this is the bug.
|
||||
|
||||
## Related Pitfalls
|
||||
|
||||
- **Double save:** If you remove `data-close-modal` from save but BOTH the IIFE's new save handler AND the JS module's addEventListener fire, you get duplicate saves and duplicate notifications. Use `stopImmediatePropagation()` or check `hasAttribute('onclick')` to gate one of them.
|
||||
- **Module timing:** If the save handler is defined in a JS module (e.g., `window.saveSiteSettings` in `settings.js`), and the module loads after the IIFE, the IIFE may call it before it exists. Always check `typeof window.saveSiteSettings === 'function'` before calling.
|
||||
- **`stopPropagation()` vs `stopImmediatePropagation()`:** `stopPropagation()` in the target phase prevents bubbling but NOT other target-phase handlers on the same element. `stopImmediatePropagation()` prevents ALL remaining handlers on the element regardless of phase.
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
# Inline Script Bridging Checklist
|
||||
|
||||
When adding a plain `<script>` (non-module) to a page that uses ES modules, the inline script has NO access to module-level imports. Three categories of globals are commonly missing:
|
||||
|
||||
## 1. `closeModal` / Modal Helpers
|
||||
|
||||
**Symptom:** `onclick="closeModal('...')"` on buttons does nothing, or JS code calls `closeModal()` and gets `ReferenceError`.
|
||||
|
||||
**Diagnosis:**
|
||||
```bash
|
||||
grep -c 'window.closeModal' page.html
|
||||
# 0 = missing
|
||||
```
|
||||
|
||||
**Fix:** Add at the top of the inline script block:
|
||||
```javascript
|
||||
window.closeModal = function(id) {
|
||||
var m = document.getElementById(id);
|
||||
if (m) { m.classList.add('hidden'); m.setAttribute('aria-hidden', 'true'); }
|
||||
};
|
||||
```
|
||||
|
||||
(Note: `shared/modal-manager.js` exports `closeModal` as a named ES module export — it does NOT set `window.closeModal`. Don't assume it's globally available.)
|
||||
|
||||
## 2. `escapeHtml` / Sanitization Functions
|
||||
|
||||
**Symptom:** `escapeHtml is not defined` when rendering user data in dynamically created HTML.
|
||||
|
||||
**Diagnosis:** `shared/sanitize.js` exports `escapeHtml` as a named ES module export only. It is NOT on `window`.
|
||||
|
||||
**Fix:** Add a local polyfill at the top of the inline script:
|
||||
```javascript
|
||||
function escapeHtml(text) {
|
||||
var d = document.createElement('div');
|
||||
d.appendChild(document.createTextNode(text));
|
||||
return d.innerHTML;
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Module-Level Data Functions
|
||||
|
||||
**Symptom:** Inline script needs to call a function defined in a module (e.g., `batchCreateAppointments`, `loadAppointments`) but can't import it.
|
||||
|
||||
**Fix (exporter side — in the module):**
|
||||
```javascript
|
||||
// In appointments.js or similar module file
|
||||
window.batchCreateAppointments = async function(data) {
|
||||
// Uses module-scoped `addDoc`, `collection`, `db`, `currentUser` etc.
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Fix (consumer side — in the inline script):**
|
||||
```javascript
|
||||
// Call the window-exposed function
|
||||
const result = await window.batchCreateAppointments(selectedAppointments);
|
||||
```
|
||||
|
||||
Place the `window.*` assignment in the module file, AFTER the function's dependencies are initialized (after `onAuthStateChanged` has fired, after imports are resolved).
|
||||
|
||||
## 4. Timing: Module vs Inline Execution Order
|
||||
|
||||
**Symptom:** `window.someFunction is not a function` when the inline script calls it at page load.
|
||||
|
||||
**Root cause:** Classic `<script>` tags run synchronously before deferred `<script type="module">` tags. The module may not have executed yet.
|
||||
|
||||
**Safe pattern:** Only call `window.*` functions in **event handlers** (click, submit, etc.), not at script load time. By the time the user interacts, the module has loaded.
|
||||
|
||||
If you MUST call at load time, use a polling guard:
|
||||
```javascript
|
||||
function waitFor(fn, timeout) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const check = () => {
|
||||
if (typeof fn === 'function') return resolve();
|
||||
if (Date.now() - start > timeout) return reject(new Error('Timeout waiting for function'));
|
||||
setTimeout(check, 50);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Nginx Proxy for CORS-Sensitive APIs
|
||||
|
||||
**Symptom:** `fetch()` to an external API fails with CORS errors in the browser console.
|
||||
|
||||
**Fix:** Proxy the external API through nginx on the same origin:
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
proxy_pass https://api.deepseek.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.deepseek.com;
|
||||
proxy_set_header Authorization "Bearer YOUR_API_KEY";
|
||||
}
|
||||
```
|
||||
|
||||
Then call `/deepseek/chat/completions` (relative URL) from the inline script — no CORS issue.
|
||||
|
||||
**Verify:** The proxy location must be in the same `server { }` block that serves the page. If the page is served on port 3447 but the proxy is on port 80, the relative URL won't reach it.
|
||||
@@ -0,0 +1,65 @@
|
||||
# jsPDF PDF Generation Pitfalls
|
||||
|
||||
Common issues when generating PDFs client-side with jsPDF, observed across ShopProQuote quote generation.
|
||||
|
||||
## 1. Fill Covering Text — Drawing Order
|
||||
|
||||
**Symptom:** Background boxes (rounded rects) appear as blank gray areas — text that was drawn afterward is invisible.
|
||||
|
||||
**Root cause:** Calling `doc.roundedRect(x, y, w, h, r, r, 'FD')` (fill + draw) AFTER text has been placed. In PDF, later operations paint on top. The fill covers the text.
|
||||
|
||||
**Fix:** Split into two operations:
|
||||
1. Draw **fill-only** box BEFORE text: `doc.roundedRect(x, y, w, h, r, r, 'F')`
|
||||
2. Draw **border-only** box AFTER text: `doc.roundedRect(x, y, w, h, r, r, 'D')`
|
||||
|
||||
Use a generous fill height initially (wider than content) and trim with the border pass using the actual computed height.
|
||||
|
||||
```
|
||||
fill_height = generous_estimate // e.g. 80pt
|
||||
doc.setFillColor(248, 248, 248)
|
||||
doc.roundedRect(x, y, w, fill_height, 2, 2, 'F') // BEFORE text
|
||||
|
||||
// ... draw all text ...
|
||||
|
||||
actual_height = computed_from_content
|
||||
doc.setDrawColor(220, 220, 220)
|
||||
doc.roundedRect(x, y, w, actual_height, 2, 2, 'D') // AFTER text
|
||||
```
|
||||
|
||||
## 2. Border Invisibility — Color Contrast
|
||||
|
||||
**Symptom:** Box border doesn't appear at all, even though `setDrawColor` and `setLineWidth` are called.
|
||||
|
||||
**Root cause:** Colors that are too close to white — e.g. `setDrawColor(230, 230, 230)` at `setLineWidth(0.5)` on a white page is invisible to the human eye, especially on screen. The fill at `(250, 250, 250)` vs white `(255, 255, 255)` is also nearly imperceptible.
|
||||
|
||||
**Fix:** Use colors with at least 30-35 points of contrast from background:
|
||||
- Fill: `(248, 248, 248)` — visible light gray, not `(250, 250, 250)` or `(251, 251, 251)`
|
||||
- Border: `(220, 220, 220)` at 0.5pt — visible but subtle, not `(230, 230, 230)`
|
||||
- For print, these values are still very light and professional
|
||||
|
||||
**Diagnosis:** Check the PDF visually. If you can't clearly see the box boundary, the values are too close to white. Use PIL/pixel analysis to verify:
|
||||
```python
|
||||
from PIL import Image
|
||||
img = Image.open('box.png')
|
||||
# Check mid-region pixels — if they're > 248, the fill is invisible
|
||||
```
|
||||
|
||||
## 3. Spacing After Box — `yPos` Drift
|
||||
|
||||
**Symptom:** Content after a background box overlaps with the box or starts inside it.
|
||||
|
||||
**Root cause:** After drawing text inside the box, `yPos` is at the last text line. But the box has bottom padding that extends below `yPos`. Adding a fixed margin (`yPos += 15`) doesn't account for the actual padding.
|
||||
|
||||
**Fix:** Track the box's computed bottom coordinate and position subsequent content relative to it:
|
||||
```
|
||||
boxBottomY = yPos_after_content + bottom_padding
|
||||
nextY = boxBottomY + margin // start next section below box
|
||||
```
|
||||
|
||||
Never use a fixed increment from the last text line — always compute from the box boundary.
|
||||
|
||||
## 4. Duplicate Variable Declarations in Scope
|
||||
|
||||
**Symptom:** A variable like `pdfApprovedServices` is declared with `const` in one place, then redeclared further down. Silent bug in JS (temporal dead zone or reference-before-declaration).
|
||||
|
||||
**Fix:** Declare variables once at the top of the scope. Use `let` for values that need to be computed after other operations. When moving code around, check for duplicate `const`/`let` declarations.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Multi-Page Header Redesign — Replication Workflow
|
||||
|
||||
## Pattern Summary
|
||||
|
||||
When the user picks a header design variant across all pages of a multi-page static web app, the changes fall into two layers: CSS and HTML. Replicate in order — CSS first (so the HTML has rules to reference), then HTML.
|
||||
|
||||
## Layer 1: CSS Changes (apply to all pages)
|
||||
|
||||
### 1a. Change `.header-card` gradient
|
||||
|
||||
The header card uses `background: linear-gradient(...)` in a `<style>` block. Some pages have multiple `.header-card` definitions at different specificity points — update ALL of them.
|
||||
|
||||
### 1b. Replace `.nav-link` hover effect
|
||||
|
||||
The old shine effect (`::before` with horizontal gradient slide) is replaced with an underline indicator (`::after` with width transition):
|
||||
|
||||
```css
|
||||
.nav-link { position: relative; } /* remove overflow:hidden */
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute; bottom: 0; left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0; height: 2px;
|
||||
background: white; border-radius: 1px;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
.nav-link:hover::after { width: 50%; }
|
||||
```
|
||||
|
||||
### 1c. Active page class
|
||||
|
||||
```css
|
||||
.nav-link.active-page {
|
||||
color: white !important;
|
||||
font-weight: 600 !important;
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.nav-link.active-page::after { width: 50%; }
|
||||
```
|
||||
|
||||
### 1d. User avatar ring
|
||||
|
||||
```css
|
||||
.user-avatar { box-shadow: 0 0 0 2px rgba(255,255,255,0.15); }
|
||||
```
|
||||
|
||||
## Layer 2: HTML Changes (per-page, adjust active link)
|
||||
|
||||
### 2a. Desktop navigation restructure
|
||||
|
||||
Replace the centered `<div class="hidden lg:flex items-center gap-0.5 mx-auto">` with a `w-full` layout that has brand mark on left, nav links centered, user on right:
|
||||
|
||||
```html
|
||||
<div class="hidden lg:flex items-center gap-6 w-full">
|
||||
<!-- Brand (same on all pages) -->
|
||||
<a href="index.html" class="flex items-center gap-2.5 flex-shrink-0 group">
|
||||
<div class="w-8 h-8 bg-white/10 rounded-lg flex items-center justify-center
|
||||
group-hover:bg-white/15 transition-colors">
|
||||
<svg class="w-4 h-4 text-white/80" ...>gear icon</svg>
|
||||
</div>
|
||||
<span class="font-semibold text-sm text-white/90 tracking-tight">ShopProQuote</span>
|
||||
</a>
|
||||
|
||||
<!-- Nav Links (active link changes per page) -->
|
||||
<div class="flex items-center gap-1 ml-auto mr-auto">
|
||||
<a href="page.html"
|
||||
class="nav-link active-page px-3 md:px-4 py-2 text-xs md:text-sm font-medium
|
||||
text-white transition-all duration-200">
|
||||
...
|
||||
</a>
|
||||
<!-- inactive links: text-white/60 hover:text-white -->
|
||||
<a href="other.html"
|
||||
class="nav-link px-3 md:px-4 py-2 text-xs md:text-sm font-medium
|
||||
text-white/60 hover:text-white transition-all duration-200">
|
||||
...
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2b. User button
|
||||
|
||||
Replace the old button with a gradient avatar circle + ring:
|
||||
|
||||
```html
|
||||
<button id="user-menu-btn"
|
||||
class="flex items-center gap-2 p-1.5 pr-3 rounded-xl
|
||||
bg-white/8 hover:bg-white/12 transition-all duration-200 group">
|
||||
<div class="w-7 h-7 md:w-8 md:h-8 rounded-full user-avatar
|
||||
bg-gradient-to-br from-blue-400 to-blue-600
|
||||
flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
||||
<svg class="w-3.5 h-3.5" ...>person icon</svg>
|
||||
</div>
|
||||
<div class="hidden md:block text-left">
|
||||
<p id="user-email"
|
||||
class="text-xs font-medium text-white/90 truncate max-w-32"></p>
|
||||
</div>
|
||||
<svg class="w-3 h-3 text-white/40 group-hover:text-white/70 transition-colors" ...>
|
||||
chevron
|
||||
</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
### 2c. Mobile nav links
|
||||
|
||||
Update styling to match:
|
||||
- Inactive: `text-white/60 hover:text-white hover:bg-white/8`
|
||||
- Active: `text-white font-semibold` (no `bg-white/20`, no `shadow-sm`)
|
||||
- Container border: `border-white/10` (was `border-white/20`)
|
||||
|
||||
## Replication Order
|
||||
|
||||
1. **Do one page first** — typically the one the user is looking at
|
||||
2. **Verify** — hard refresh, check desktop + mobile
|
||||
3. **Replicate CSS changes to other pages** — same rules apply everywhere
|
||||
4. **Replicate HTML changes** — adjust ONLY the active link for each page
|
||||
5. **Verify ALL pages** — navigate between them to confirm each page's active link highlights correctly
|
||||
|
||||
## Per-Page Active Link Table
|
||||
|
||||
| Page file | Active link href | Active page name |
|
||||
|-----------|-----------------|-----------------|
|
||||
| `index.html` | `index.html` | Dashboard |
|
||||
| `repair-orders.html` | `repair-orders.html` | Repair Orders |
|
||||
| `appointments.html` | `appointments.html` | Appointments |
|
||||
| `customers.html` | `customers.html` | Customers |
|
||||
|
||||
The active link gets `class="nav-link active-page ..."` and `text-white` (not `text-white/60`). All others get `text-white/60 hover:text-white`.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Multiple `.header-card` CSS definitions.** Some pages define `.header-card` in 2-3 different `<style>` blocks within the same file. Update ALL of them — same specificity, later wins.
|
||||
- **SVG formatting varies between pages.** Some pages use multi-line SVGs, others use single-line. Match the exact whitespace when crafting patch strings.
|
||||
- **Mobile nav active link also needs updating.** Both desktop and mobile nav sections have their own copy of the nav links.
|
||||
- **`justify-between` vs `flex` layout.** Pages from different reference builds may use different flex layouts in the header row. Match the existing structure.
|
||||
- **Brand mark is identical across all pages.** The exact same HTML can be copied to each page — only the active nav link changes.
|
||||
- **Test dark mode too.** The CSS `!important` rules should override both light and dark variants of Tailwind classes.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Nav Centering Pattern — 4-Page App
|
||||
|
||||
## Applied: Jun 2026 session
|
||||
|
||||
Applied to Dashboard (index.html), Repair Orders, Customers, Appointments.
|
||||
|
||||
## Before Structure
|
||||
|
||||
```html
|
||||
<div class="flex items-center justify-between py-2 md:py-2.5">
|
||||
<!-- Left: Mobile Hamburger + Desktop Nav Links -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button id="mobile-menu-btn" class="lg:hidden p-2...">☰</button>
|
||||
<div class="hidden lg:flex items-center gap-0.5">
|
||||
... nav links ...
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile Page Title -->
|
||||
<span class="lg:hidden text-sm font-semibold text-white">Page</span>
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex items-center gap-1.5 md:gap-2">
|
||||
... user menu ...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## After Structure
|
||||
|
||||
```html
|
||||
<div class="flex items-center py-2 md:py-2.5">
|
||||
<!-- Left: Mobile Hamburger (hidden on desktop) -->
|
||||
<div class="flex-1 flex lg:hidden">
|
||||
<button id="mobile-menu-btn" class="p-2...">☰</button>
|
||||
</div>
|
||||
<!-- Desktop Navigation (centered) -->
|
||||
<div class="hidden lg:flex items-center gap-0.5 mx-auto">
|
||||
... nav links ...
|
||||
</div>
|
||||
<!-- Mobile Page Title -->
|
||||
<span class="lg:hidden text-sm font-semibold text-white">PageName</span>
|
||||
<!-- Right: Controls -->
|
||||
<div class="flex-1 flex justify-end items-center gap-1.5">
|
||||
... user menu ...
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Per-Page Details
|
||||
|
||||
| Page | Mobile Title | Active Nav Link |
|
||||
|------|-------------|-----------------|
|
||||
| index.html | Dashboard | Dashboard |
|
||||
| repair-orders.html | Repair Orders | Repair Orders |
|
||||
| customers.html | Customers | Customers |
|
||||
| appointments.html | Appointments | Appointments |
|
||||
|
||||
## What Changed Per Page
|
||||
|
||||
There are 3 structural differences to apply per page (use `justify-between` as search anchor):
|
||||
|
||||
1. **Outer flex row:** `justify-between` → removed. The `flex-1` spacers handle the spacing.
|
||||
2. **Left div:** `flex items-center gap-0.5` → `flex-1 flex lg:hidden`. Hamburger button loses its own `lg:hidden` class.
|
||||
3. **Right controls div:** `flex items-center gap-1.5 md:gap-2` → `flex-1 flex justify-end items-center gap-1.5`.
|
||||
4. **Desktop nav div:** Add `mx-auto` to the existing classes: `hidden lg:flex items-center gap-0.5 mx-auto`.
|
||||
5. **Stray closing div:** The old left wrapper (`flex items-center gap-0.5`) used to have a `</div>` that closed it. With the new structure, that closing `</div>` becomes orphaned — remove it. It's usually right before the Mobile Page Title span.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Nginx Server Block Diagnosis: Served vs Edited Mismatch
|
||||
|
||||
## Diagnostic Signal
|
||||
|
||||
You edit a file on disk, verify the edit is correct with `grep` or `read_file`, but the user reports the fix didn't work. Using `curl -sk https://localhost/page.html | grep 'fix-pattern'` returns empty — the fix is NOT in the served content.
|
||||
|
||||
## Root Cause Pattern
|
||||
|
||||
Multiple nginx server blocks can serve the same domain on different ports. `sites-available/` and `sites-enabled/` may contain **different files with different directives** — not simply symlinks.
|
||||
|
||||
In this session:
|
||||
- `/etc/nginx/sites-available/shopproquote` had `listen 3448 ssl` with aggressive caching (`expires 30d; Cache-Control: public, immutable`)
|
||||
- `/etc/nginx/sites-enabled/shopproquote` had `listen 443 ssl` with NO caching headers and different `location` blocks
|
||||
- The files were **different regular files**, not symlinks
|
||||
|
||||
## Diagnosis Steps
|
||||
|
||||
```bash
|
||||
# 1. Check what's actually enabled
|
||||
ls -la /etc/nginx/sites-enabled/
|
||||
|
||||
# 2. Compare enabled vs available
|
||||
diff /etc/nginx/sites-available/<name> /etc/nginx/sites-enabled/<name>
|
||||
|
||||
# 3. Check which port each server block listens on
|
||||
grep -rn 'listen' /etc/nginx/sites-enabled/
|
||||
|
||||
# 4. Check the root directive
|
||||
grep -rn 'root' /etc/nginx/sites-enabled/<name>
|
||||
|
||||
# 5. Check if any other enabled site uses the same port (conflict)
|
||||
grep -rn 'listen.*PORT' /etc/nginx/sites-enabled/
|
||||
|
||||
# 6. Verify served content matches disk content
|
||||
curl -sk https://localhost/page.html | grep -c 'your-fix-pattern'
|
||||
# Should return ≥ 1. If 0, the file on disk isn't what's being served.
|
||||
```
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
| Symptom | Likely Cause |
|
||||
|---------|-------------|
|
||||
| Curl shows fix, user doesn't see it | Browser cache |
|
||||
| Curl doesn't show fix, disk has it | Wrong server block / root path |
|
||||
| Curl shows OLD fix but not NEW one | File not saved, or nginx not reloaded |
|
||||
| Port 443 vs port 3448 content differs | Two different server blocks, different `root` |
|
||||
| JS loaded but HTML not updated | Static asset caching (see Browser Cache Busting) |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Rule-Based OCR Parser Architecture
|
||||
|
||||
Reference for the `parseWithRules()` parser in `appointments.html` (ShopProQuote). Extracts structured appointment data from Tesseract.js OCR output — no AI/API, pure JavaScript regex, runs entirely in the browser.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Raw OCR text
|
||||
→ Phase 1: Normalize & Clean (strip headers, separators, metadata)
|
||||
→ Phase 2: Block Splitting (row-anchor primary, blank-line fallback, table fallback)
|
||||
→ Phase 3: parseOne() recursive extraction per block
|
||||
→ Output: array of {customerName, customerPhone, customerEmail, vin, vehicleInfo, serviceType, notes}
|
||||
```
|
||||
|
||||
## Phase 2 — Row-Anchor Splitting (Primary)
|
||||
|
||||
Instead of splitting on `\n\n` (fragile — Tesseract's horizontal table scan often produces text with inconsistent blank lines), split on **Time + Duration patterns**:
|
||||
|
||||
```javascript
|
||||
var rowAnchorRe = /(\d{1,2}):(\d{2})\s*(AM|PM)\s+(\d+(?:\.\d+)?)\s*hrs?/gi;
|
||||
```
|
||||
|
||||
Find all anchor positions, slice text at boundaries. Each chunk starts with a time+duration — one appointment per chunk.
|
||||
|
||||
Fallback: if < 2 anchors found, revert to `\n\n` blank-line splitting.
|
||||
|
||||
## Phase 3 — parseOne() Consume-and-Destroy Order
|
||||
|
||||
**Critical: extraction order is strict.** Each field is matched and removed from the text before the next field is searched — prevents false matches.
|
||||
|
||||
```
|
||||
1a. Time (anchor) — extract first, remove
|
||||
1b. Duration — minutes/hours, remove
|
||||
1c. Phone — 10-digit, any format, remove
|
||||
1d. Email — extract before VIN (prevents email digits matching VIN/date)
|
||||
1e. VIN — 17-char boundary, OCR-tolerant (O→0, I→1, Q→0), remove
|
||||
1f. Post-VIN cleanup — strip residual "VIN" label, standalone "RO" token
|
||||
1g. Advisor sanitize — hard-strip "Rrahman Grajqevci" and variants
|
||||
1h. Date — 3 formats: YYYY-MM-DD, M/D/YYYY, "Mon DD, YYYY"
|
||||
```
|
||||
|
||||
After structured extraction, remaining text parts are split on `\s{2,}` OR `\n` (preserves multi-line OCR structure — do NOT collapse newlines to spaces before splitting).
|
||||
|
||||
### Advisors / RO Codes
|
||||
|
||||
Pattern: `AdvisorName [RO_CODE]` is stripped from parts. The `[CODE]` is captured into `notes` as `RO: [CODE]`.
|
||||
|
||||
### Cross-Block Name Carry
|
||||
|
||||
Trailing ALL-CAPS names at the bottom of a block belong to the NEXT block. Parser detects via `serviceType.match(/\s+([A-Z]{2,}(?:\s+[A-Z]{2,}){1,2})\s*$/)` and passes as `carryName` to the next `parseOne()` call.
|
||||
|
||||
### Vehicle Extraction
|
||||
|
||||
Primary: find part matching **year + make** (e.g., "2023 Honda")
|
||||
Fallback: find part matching year OR make alone
|
||||
Model capture: if next part is ALL-CAPS alphanumeric and not a service keyword, append to vehicle
|
||||
|
||||
### Header Metadata Stripping
|
||||
|
||||
Before block splitting, lines matching schedule headers are removed:
|
||||
- Day-of-week + date: `Wednesday Jun 18, 2025`
|
||||
- Single-word labels: `Weekly`, `Advisor`, `Refresh`, `Daily`
|
||||
|
||||
## Key Regex Patterns
|
||||
|
||||
| Field | Pattern | Notes |
|
||||
|-------|---------|-------|
|
||||
| Phone | `\(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4}` | Any format |
|
||||
| VIN | `\b[A-HJ-NPR-Z0-9OIQ]{17}\b/i` | OCR-tolerant, word-bounded |
|
||||
| Email | `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` | Extracted pre-VIN |
|
||||
| Date | `(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})` | ISO format primary |
|
||||
| Time | `\b(\d{1,2}):(\d{2})\s*(AM|PM)?\b` | With/without meridian |
|
||||
| Duration | `\b(\d+(?:\.\d+)?)\s*(?:min|minutes?|hrs?|hours?)\b` | Decimal hours OK |
|
||||
| Row Anchor | `(\d{1,2}):(\d{2})\s*(AM|PM)\s+(\d+(?:\.\d+)?)\s*hrs?` | Global flag, used for block splitting |
|
||||
| Makes | 25+ automotive brands | Used in vehicle detection |
|
||||
| Year | `\b(19|20)\d{2}\b` | Model years 1900-2099 |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Double-escape in regex strings:** When editing regex in HTML patches, `\\d` in the file is two characters. Using `\\\\d` produces four. Verify with `grep -n '\\\\\\\\d'` after editing.
|
||||
- **Don't collapse newlines in block processing:** The block-iteration step must NOT do `.replace(/\n/g, ' ')`. Parts splitting uses `\n` as a delimiter; collapsing them first destroys multi-line structure.
|
||||
- **"RO" token contamination:** The text "RO Gary Bowers" has standalone "RO" before the name. Strip `\bRO\b` after VIN extraction, before advisor stripping.
|
||||
- **"VIN" label residue:** After VIN extraction, the word "VIN" remains. Strip `\bVIN\b` in post-VIN cleanup.
|
||||
- **Email false positives:** Email addresses contain digits (e.g., `garyb9623@gmail.com`). Extract email BEFORE VIN to prevent the digits matching VIN patterns.
|
||||
- **Table fallback only fires when blank-line split produces 1 block.** The row-anchor path runs first and can produce multiple blocks — table fallback is a separate code path for the `blocks.length === 1` case.
|
||||
|
||||
## Test Suite
|
||||
|
||||
7 test cases in the parser test harness cover:
|
||||
1. Two appointments with blank-line separation
|
||||
2. Shop header + separator lines
|
||||
3. Single appointment
|
||||
4. Table format (uniform columns)
|
||||
5. Real 3-appointment screenshot with blank lines + email + advisor
|
||||
6. OCR horizontal-scan format with NO blank lines (row-anchor test)
|
||||
7. Email/code false-positive guard
|
||||
|
||||
Run tests via:
|
||||
```bash
|
||||
cd /mnt/seagate8tb/Websites/ShopProQuote
|
||||
sed -n '1147,1363p' appointments.html > /tmp/parser_fn.js
|
||||
echo 'module.exports = parseWithRules;' >> /tmp/parser_fn.js
|
||||
node -e "var p = require('/tmp/parser_fn.js'); /* ... test cases ... */"
|
||||
```
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
# PocketBase Adapter: Firestore Compatibility Contract
|
||||
|
||||
Use this when the app UI looks correct but core functions (dashboard counts, appointments load, customer lists, quote generation flows) still fail after a Firebase→PocketBase migration.
|
||||
|
||||
## Why this matters
|
||||
|
||||
In this class of app, page modules are written against Firestore semantics. If `pocketbase.js` does not emulate that contract, you get broad functional breakage even when HTML/JS files appear aligned.
|
||||
|
||||
## Required `getDocs()` return shape
|
||||
|
||||
`getDocs(...)` must return a QuerySnapshot-like object, not a raw array:
|
||||
|
||||
- `docs` (array of doc snapshots)
|
||||
- `size` (number)
|
||||
- `empty` (boolean)
|
||||
- `forEach(callback)`
|
||||
|
||||
Each doc in `docs` must expose:
|
||||
|
||||
- `id`
|
||||
- `data()`
|
||||
- `exists()` (function form for Firestore compatibility)
|
||||
- optional `ref`
|
||||
|
||||
## Required `getDoc()` return shape
|
||||
|
||||
`getDoc(...)` must return a DocumentSnapshot-like object:
|
||||
|
||||
- `id`
|
||||
- `data()`
|
||||
- `exists()` **as a function** (not boolean property)
|
||||
- `ref` (at minimum `{ id }`)
|
||||
|
||||
Why this matters: many legacy code paths call `if (snap.exists())` or check `snap.ref.id` in details/edit flows. Returning `exists: true` (boolean) breaks with runtime errors such as `snap.exists is not a function`.
|
||||
|
||||
## Required `addDoc()` behavior for sub-collection patterns
|
||||
|
||||
Legacy code often writes records via sub-collection syntax: `collection(db, 'users', uid, 'services')`. The adapter must inject the userId into the stored record so filtered queries (`where("userId", "==", uid)`) find it:
|
||||
|
||||
```javascript
|
||||
async function addDoc(collRef, data) {
|
||||
const collName = collRef._name;
|
||||
const pbData = convertToPB(data);
|
||||
if (collRef._userId && !pbData.userId) {
|
||||
pbData.userId = collRef._userId;
|
||||
}
|
||||
const record = await pb.collection(collName).create(pbData);
|
||||
return { id: record.id };
|
||||
}
|
||||
```
|
||||
|
||||
Without this injection, records created via sub-collection refs appear to "save" (no error thrown) but never appear in subsequent filtered queries — users see "nothing saved" despite no error feedback.
|
||||
|
||||
This matters for code paths creating records under `users/{uid}/services`, `users/{uid}/customers`, `users/{uid}/appointments`, `users/{uid}/repairOrders`.
|
||||
|
||||
## Required input compatibility
|
||||
|
||||
`getDocs(...)` must accept both:
|
||||
|
||||
1. `getDocs(query(...))`
|
||||
2. `getDocs(collection(...))`
|
||||
|
||||
Many legacy modules mix both patterns. Supporting only query objects silently breaks dashboard/customer/appointment code paths.
|
||||
|
||||
## `onSnapshot(...)` minimum compatibility
|
||||
|
||||
If realtime is shimmed, callback shape must still match caller expectations:
|
||||
|
||||
- For query/collection refs: callback receives snapshot object with `docs/size/empty/forEach`
|
||||
- For doc refs: callback receives doc snapshot-like object
|
||||
|
||||
A one-shot shim is acceptable for non-realtime pages, but document that limitation and avoid claiming full realtime parity.
|
||||
|
||||
## ⚠️ `setDoc()` must use query-based upsert (PocketBase v0.23+)
|
||||
|
||||
**The old pattern is broken:**
|
||||
|
||||
```javascript
|
||||
// BROKEN on PocketBase v0.23+
|
||||
function setDoc(docRef, data) {
|
||||
const collName = docRef._collection;
|
||||
const docId = docRef._id;
|
||||
const pbData = convertToPB(data);
|
||||
return pb.collection(collName).update(docId, pbData).catch(() =>
|
||||
pb.collection(collName).create({ ...pbData, id: docId })
|
||||
).then(rec => ({ id: rec.id }));
|
||||
}
|
||||
```
|
||||
|
||||
Two failures:
|
||||
|
||||
1. **Custom system IDs rejected.** PocketBase v0.23+ requires system IDs ≥ 15 characters. `'appSettings'` (11 chars), `'accountSettings'` (15 chars, barely valid), and similar short custom IDs cause `validation_min_text_constraint` or `validation_invalid_format` errors on `create`. Arbitrary strings with underscores or hyphens also fail format validation.
|
||||
|
||||
2. **Unknown fields dropped on create.** The `update().catch(() => create())` pattern tries to create with raw settings fields (`darkMode`, `taxRate`, ...) that aren't in the collection schema. PocketBase silently drops them or rejects the create.
|
||||
|
||||
**The correct pattern — query-based upsert by `name + userId`:**
|
||||
|
||||
```javascript
|
||||
function setDoc(docRef, data) {
|
||||
const collName = docRef._collection;
|
||||
const docId = docRef._id;
|
||||
const pbData = convertToPB(data);
|
||||
const userId = docRef._userId || '';
|
||||
|
||||
// Find existing record by name + userId, then update-or-create
|
||||
return pb.collection(collName).getFullList({
|
||||
filter: `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`,
|
||||
requestKey: null
|
||||
}).then(records => {
|
||||
if (records.length > 0) {
|
||||
return pb.collection(collName).update(records[0].id, pbData);
|
||||
} else {
|
||||
return pb.collection(collName).create(pbData);
|
||||
}
|
||||
}).then(rec => ({ id: rec.id }));
|
||||
}
|
||||
```
|
||||
|
||||
This avoids setting system IDs entirely (lets PocketBase auto-generate them) and uses the `name` + `userId` fields for reliable lookup.
|
||||
|
||||
### Required schema for settings-style collections
|
||||
|
||||
When using this pattern, the target collection must have these fields:
|
||||
|
||||
| Field | Type | Required | Purpose |
|
||||
|----------|--------|----------|---------|
|
||||
| `userId` | text | yes | Owner ID for collection rules (`userId = @request.auth.id`) |
|
||||
| `name` | text | yes | Document key — matches the 4th argument of `doc(db, 'users', uid, 'settings', 'name')` |
|
||||
| `data` | json | no | Arbitrary settings payload (entire `appSettings` object) |
|
||||
|
||||
Collection rules should be:
|
||||
- `listRule`: `userId = @request.auth.id`
|
||||
- `viewRule`: `userId = @request.auth.id`
|
||||
- `createRule`: `@request.auth.id != ""`
|
||||
- `updateRule`: `userId = @request.auth.id`
|
||||
- `deleteRule`: `userId = @request.auth.id`
|
||||
|
||||
## ⚠️ `getDoc()` needs name-based fallback
|
||||
|
||||
When records are created without predictable system IDs (auto-generated by PocketBase), a direct `getOne(docId)` call uses the doc ID from the Firestore ref path, which won't match the system ID. Example:
|
||||
|
||||
```javascript
|
||||
// Firestore ref: doc(db, 'users', uid, 'settings', 'appSettings')
|
||||
// Adapter creates: { _collection: 'settings', _id: 'appSettings', _userId: uid }
|
||||
// PocketBase getOne('appSettings') fails — no record with that system ID exists
|
||||
```
|
||||
|
||||
**Fix:** Add a name-based fallback query when `getOne(id)` returns 404:
|
||||
|
||||
```javascript
|
||||
async function getDoc(docRef) {
|
||||
const collName = docRef._collection;
|
||||
const docId = docRef._id;
|
||||
const userId = docRef._userId || '';
|
||||
|
||||
try {
|
||||
// Try direct lookup by system ID (for legacy records)
|
||||
let record = await pb.collection(collName).getOne(docId);
|
||||
return { id: record.id, data: () => convertFromPB(record),
|
||||
exists: () => true, ref: { id: record.id } };
|
||||
} catch (err) {
|
||||
// Fallback: look up by name (for query-based-created records)
|
||||
try {
|
||||
const filter = `name = "${docId}"${userId ? ` && userId = "${userId}"` : ''}`;
|
||||
const records = await pb.collection(collName).getFullList({
|
||||
filter, requestKey: null
|
||||
});
|
||||
if (records.length > 0) {
|
||||
const record = records[0];
|
||||
return { id: record.id, data: () => convertFromPB(record),
|
||||
exists: () => true, ref: { id: record.id } };
|
||||
}
|
||||
} catch (e2) { /* not found */ }
|
||||
return { exists: () => false, data: () => null, ref: { id: docId } };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Settings storage pattern for PocketBase
|
||||
|
||||
The app's `settings.js` uses `saveAllSettings()` and `loadSettings()` to persist a complex settings object. After migrating to PocketBase:
|
||||
|
||||
**Save:**
|
||||
```javascript
|
||||
// Instead of spreading fields:
|
||||
// setDoc(ref, { ...appSettings, lastUpdated: ... }); // FAILS: unknown fields
|
||||
// Use the 'data' json field:
|
||||
setDoc(ref, {
|
||||
data: { ...appSettings, lastUpdated: new Date().toISOString() },
|
||||
userId: currentUser.uid,
|
||||
name: 'appSettings'
|
||||
});
|
||||
```
|
||||
|
||||
**Load:**
|
||||
```javascript
|
||||
// Instead of reading raw fields:
|
||||
// const { lastUpdated, ...clean } = settingsDoc.data();
|
||||
// appSettings = { ...appSettings, ...clean };
|
||||
// Use the 'data' json field:
|
||||
if (record && record.data) {
|
||||
const { lastUpdated, ...cleanSettings } = record.data;
|
||||
appSettings = { ...appSettings, ...cleanSettings };
|
||||
}
|
||||
```
|
||||
|
||||
Apply the same pattern for account settings (`name: 'accountSettings'`) and any settings-type document.
|
||||
|
||||
## Fast verification checklist
|
||||
|
||||
Search target code for these usage patterns:
|
||||
|
||||
- `querySnapshot.docs.map(...)`
|
||||
- `if (querySnapshot.empty) ...`
|
||||
- `querySnapshot.size`
|
||||
- `querySnapshot.forEach(...)`
|
||||
- `getDocs(collection(` (not just query)
|
||||
- `setDoc(..., { merge: true })` — adapter ignores merge
|
||||
- `setDoc(..., { ...spread, lastUpdated })` — check if target collection has a `data` json field
|
||||
- `getDoc(doc(db, 'users', uid, 'settings', name))` — check if `getDoc` has name-based fallback
|
||||
|
||||
If these appear, adapter must provide full snapshot compatibility and query-based setDoc/getDoc.
|
||||
|
||||
## Practical migration pattern
|
||||
|
||||
1. First restore file parity vs known-good reference (HTML + page JS).
|
||||
2. Normalize backend import path (`firebase.js` → `pocketbase.js`).
|
||||
3. Fix adapter contract (`getDocs`, `onSnapshot`, snapshot shape).
|
||||
4. **Fix `setDoc` and `getDoc` for settings-style docs** (query-based upsert + name fallback).
|
||||
5. **Update all `handleSaveAccountSettings()` sites** across every page's JS file to use `{ data: {...}, userId, name }` pattern.
|
||||
6. **Ensure the `settings` collection has a `data` json field** added to its schema.
|
||||
7. Re-check the four core flows:
|
||||
- quote generation
|
||||
- appointment setting
|
||||
- dashboard actions/cards/modals
|
||||
- customer page search/edit/actions
|
||||
|
||||
This avoids wasting time patching dozens of per-page handlers when the real fault is the adapter contract.
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
# PocketBase Migration Debugging Patterns
|
||||
|
||||
Common pitfalls when converting a multi-page static web app from Firebase to a self-hosted PocketBase backend.
|
||||
|
||||
## 1. Relative Import Paths in `shared/` Modules
|
||||
|
||||
**Problem:** Files in `shared/` (like `header-functionality.js`) use dynamic `import()` calls with paths relative to their own location. After migration, `pocketbase.js` lives in the project root, but `shared/header-functionality.js` imports `'./pocketbase.js'` which resolves to `shared/pocketbase.js` — a file that doesn't exist.
|
||||
|
||||
**Fix:** All dynamic imports from `shared/` must use `'../pocketbase.js'` to reach the root file.
|
||||
|
||||
**Checklist:**
|
||||
```bash
|
||||
# Find all dynamic imports in shared files
|
||||
grep -rn "import.*pocketbase\|import.*firebase" shared/*.js
|
||||
|
||||
# Expected: '../pocketbase.js' (NOT './pocketbase.js', NOT '../firebase.js')
|
||||
```
|
||||
|
||||
## 2. Stale Firebase Imports After Migration
|
||||
|
||||
**Problem:** Some files still import from Firebase CDN URLs or reference `firebase.js` instead of `pocketbase.js`. The Firebase CDN imports will fail silently if Firebase isn't configured.
|
||||
|
||||
**Example from `header-functionality.js` (BROKEN):**
|
||||
```js
|
||||
const { signOut } = await import('./pocketbase.js'); // WRONG: shared/pocketbase.js
|
||||
const { auth } = await import('../firebase.js'); // WRONG: old Firebase file
|
||||
```
|
||||
|
||||
**Fixed:**
|
||||
```js
|
||||
const { signOut } = await import('../pocketbase.js');
|
||||
const { auth } = await import('../pocketbase.js');
|
||||
```
|
||||
|
||||
**Batch fix all remaining `firebase.js` references at once:**
|
||||
```bash
|
||||
cd /path/to/project
|
||||
grep -rln "firebase.js" --include="*.js" . | while read f; do
|
||||
sed -i "s|'./firebase.js'|'./pocketbase.js'|g; s|'../firebase.js'|'../pocketbase.js'|g" "$f"
|
||||
done
|
||||
# Verify no references remain (comments are OK)
|
||||
grep -rn "firebase.js" --include="*.js" . | grep -v "^.*://.*firebase"
|
||||
```
|
||||
|
||||
**Additional stub needed:** If any file imports `handleFirestoreConnectionError` from the old Firebase file, add a no-op stub to `pocketbase.js`:
|
||||
```js
|
||||
async function handleFirestoreConnectionError(error) {
|
||||
console.warn('Firestore connection error (using PocketBase, no retry needed):', error);
|
||||
}
|
||||
export { handleFirestoreConnectionError };
|
||||
```
|
||||
|
||||
## 3. Missing UI Elements After Migration
|
||||
|
||||
**Problem:** Not all pages have the same header/dropdown structure. Some pages may be missing the Sign Out button entirely.
|
||||
|
||||
**Also: JS doesn't populate header elements even when HTML exists.** Each page's `onAuthStateChanged` callback must explicitly set `document.getElementById('user-email').textContent = user.email;`. If a page's JS file omits this line (common on `customers.js` and any page copied from a template), the email never appears in the header even though the `<p id="user-email">` element exists in the HTML.
|
||||
|
||||
**Check:**
|
||||
```bash
|
||||
# Verify logout button exists on all pages
|
||||
grep -ln 'id="logoutBtn"' *.html
|
||||
|
||||
# Verify every JS file populates user-email
|
||||
for js in *.js; do
|
||||
grep -q "user-email.*textContent.*email\|userEmailDisplay.*textContent" "$js" || echo "MISSING user-email populate: $js"
|
||||
done
|
||||
```
|
||||
```bash
|
||||
# Verify logout button exists on all pages
|
||||
grep -ln 'id="logoutBtn"' *.html
|
||||
```
|
||||
|
||||
If a page is missing the button, copy the full button + divider section from a working page:
|
||||
```html
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 py-1">
|
||||
<button id="logoutBtn" class="..." onclick="localStorage.removeItem('pocketbase_auth');window.location.href='login.html';">
|
||||
<svg>...</svg>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 4. PocketBase Auth Token Bypass
|
||||
|
||||
**Problem:** PocketBase SDK sign-out functions fail due to broken import chains, stale cache, or async race conditions. The user clicks Sign Out and nothing happens.
|
||||
|
||||
**Bulletproof fix:** Clear the PocketBase auth token directly from localStorage:
|
||||
```html
|
||||
<button onclick="localStorage.removeItem('pocketbase_auth');window.location.href='login.html';">
|
||||
Sign Out
|
||||
</button>
|
||||
```
|
||||
|
||||
PocketBase stores auth in `localStorage` under the key `pocketbase_auth`. Removing this key and redirecting to `login.html` is equivalent to calling `pb.authStore.clear()` — but without any SDK dependency.
|
||||
|
||||
## 5. Admin Token vs User Token Bypass
|
||||
|
||||
**Problem:** After migration, an admin-level PocketBase token in localStorage auto-authenticates the user, skipping the login page entirely.
|
||||
|
||||
**Fix in `pocketbase.js` adapter:**
|
||||
```js
|
||||
// Clear any admin/superuser auth tokens — only user collection logins allowed
|
||||
if (pb.authStore.isValid && (!pb.authStore.model || pb.authStore.model.collectionName !== 'users')) {
|
||||
pb.authStore.clear();
|
||||
}
|
||||
```
|
||||
|
||||
This filters out admin/superuser tokens but preserves normal user tokens (from the `users` collection). Valid user tokens are NOT cleared — the user stays logged in across refreshes, which is correct behavior.
|
||||
|
||||
## 6. Adapter Bypass — Raw SDK Diagnostic Pattern
|
||||
|
||||
**Problem:** The login form uses an adapter (`pocketbase.js`) that wraps the PocketBase SDK. When login silently fails (no error, no redirect, just nothing happens), it's unclear whether the adapter code, the SDK import, or the API connection is the culprit. Adding `console.log` to the adapter means editing a file used by the entire app, risking collateral breakage.
|
||||
|
||||
**Solution:** Create a minimal standalone test page that imports the PocketBase SDK **directly** (no adapter) and performs a raw `authWithPassword()` call. This completely isolates the auth code path from the app's module graph.
|
||||
|
||||
**Test page template** (`login-test.html`):
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="UTF-8"><title>Login Test</title></head>
|
||||
<body>
|
||||
<h2>Login Test</h2>
|
||||
<input id="email" type="email" placeholder="Email"><br><br>
|
||||
<input id="password" type="password" placeholder="Password"><br><br>
|
||||
<button id="loginBtn">Sign In</button>
|
||||
<div id="result"></div>
|
||||
|
||||
<script type="module">
|
||||
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs";
|
||||
const pb = new PocketBase(window.location.origin);
|
||||
document.getElementById('loginBtn').onclick = async () => {
|
||||
const email = document.getElementById('email').value;
|
||||
const password = document.getElementById('password').value;
|
||||
document.getElementById('result').textContent = 'Trying...';
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
document.getElementById('result').textContent = 'OK! Token: ' + authData.token.substring(0, 20) + '...';
|
||||
} catch(e) {
|
||||
document.getElementById('result').textContent = 'ERROR: ' + (e.message || JSON.stringify(e));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
**Place in the same directory** as the app's other HTML files so it shares the same origin (`window.location.origin` resolves to the same PocketBase backend).
|
||||
|
||||
**Interpretation:**
|
||||
- **Test page works, login page doesn't** → The adapter/wrapper code is broken. Fix: replace adapter imports in `login.html` with raw SDK calls (see Section 7).
|
||||
- **Test page also fails** → The issue is in the PocketBase SDK, CDN availability, API connection, CORS, or the PocketBase server itself. Check network tab, CDN accessibility, and `docker`/`nginx` health.
|
||||
- **403 Forbidden** → File permissions (`chmod 644`). The test file was created with restrictive permissions that nginx can't read.
|
||||
|
||||
**Transition from test to fix:** Once the test page confirms the raw SDK works, port the same raw SDK calls into `login.html`:
|
||||
|
||||
```javascript
|
||||
// BEFORE (adapter — silently fails):
|
||||
import { auth } from './pocketbase.js';
|
||||
import { signInWithEmailAndPassword } from "./pocketbase.js";
|
||||
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
||||
|
||||
// AFTER (raw SDK — works):
|
||||
import PocketBase from "https://cdn.jsdelivr.net/npm/pocketbase@0.21.5/dist/pocketbase.es.mjs";
|
||||
const pb = new PocketBase(window.location.origin);
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
```
|
||||
|
||||
Also replace adapter-dependent functions:
|
||||
- `sendPasswordResetEmail(auth, email)` → `pb.collection('users').requestPasswordReset(email)`
|
||||
- `createUserWithEmailAndPassword(auth, email, password)` + `updateProfile(user, {displayName: name})` → `pb.collection('users').create({email, password, passwordConfirm: password, emailVisibility: true, name})` followed by `pb.collection('users').authWithPassword(email, password)`
|
||||
- Any `import { auth } from './pocketbase.js'` → remove (not needed with raw SDK)
|
||||
|
||||
**Cleanup:** After fixing `login.html`, delete `login-test.html` to avoid leaving diagnostic pages on the production site.
|
||||
|
||||
## 9. Settings Persistence — `data` JSON Field Pattern
|
||||
|
||||
**Problem:** The app stores settings via `doc(db, 'users', uid, 'settings', 'appSettings')` — a Firestore subcollection pattern. The adapter's `setDoc()` calls `pb.collection('settings').create({ ...appSettings, id: 'appSettings' })`. But the `settings` collection only has 3 schema fields (`id`, `userId`, `name`), and PocketBase silently drops unknown fields during create/update. The 20+ fields of the `appSettings` object (`darkMode`, `taxRate`, `shopChargeRate`, etc.) are lost. Additionally, PocketBase v0.23+ rejects custom system IDs (`'appSettings'` is only 11 chars, minimum is 15).
|
||||
|
||||
**Root cause:** PocketBase doesn't have Firestore's nested subcollection/document structure. A doc ref like `doc(db, 'users', uid, 'settings', 'appSettings')` maps to `_collection='settings', _id='appSettings'` — the adapter tries to create a record in the `settings` collection with system ID `appSettings`, which fails on ID validation and unknown fields.
|
||||
|
||||
**Fix — Three layers:**
|
||||
|
||||
### Layer 1: Schema — Add a `json` field to the settings collection
|
||||
|
||||
Add a `json` type field called `data` to the `settings` collection. Keep the existing `userId` and `name` fields for querying. The `data` field holds the arbitrary settings object.
|
||||
|
||||
### Layer 2: Adapter — Query-based upsert in `setDoc()`
|
||||
|
||||
Instead of `update(id).catch(() => create({...data, id}))`, use a find-by-name-then-update-or-create pattern. Do NOT set the system `id` field — let PocketBase auto-generate it. Identify records by `name` + `userId` fields instead.
|
||||
|
||||
Also add name-based fallback to `getDoc()` — try direct ID lookup first (backward compat), then fall back to query by `name = docId && userId = userId`.
|
||||
|
||||
### Layer 3: App code — Wrap settings in `data` field
|
||||
|
||||
Every `saveAllSettings()` call must wrap the settings object:
|
||||
```
|
||||
await setDoc(userSettingsRef, {
|
||||
data: { ...appSettings, lastUpdated: new Date().toISOString() },
|
||||
userId: currentUser.uid,
|
||||
name: 'appSettings'
|
||||
});
|
||||
```
|
||||
|
||||
Every load must read from the `data` field:
|
||||
```
|
||||
const record = settingsDoc.data();
|
||||
if (record && record.data) {
|
||||
const { lastUpdated, ...cleanSettings } = record.data;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple settings documents
|
||||
|
||||
| Doc ref ID | Save pattern |
|
||||
|---|---|
|
||||
| `appSettings` | `{ data: { ...siteConfig }, userId, name: 'appSettings' }` |
|
||||
| `accountSettings` | `{ data: { displayName, email, ... }, userId, name: 'accountSettings' }` |
|
||||
|
||||
### OnSnapshot realtime sync — same pattern
|
||||
|
||||
```javascript
|
||||
onSnapshot(siteRef, (snap) => {
|
||||
if (!snap.exists()) return;
|
||||
const record = snap.data();
|
||||
if (!record || !record.data) return;
|
||||
const { lastUpdated, ...clean } = record.data;
|
||||
appSettings = { ...appSettings, ...clean };
|
||||
});
|
||||
```
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **PocketBase v0.23+ rejects system IDs < 15 chars.** Do not set `id` in create requests. Use `name` + `userId` as the lookup key instead.
|
||||
- **The `settings` collection must have listRule/viewRule set to `userId = @request.auth.id`.** Without this, users can read each other's settings.
|
||||
- **Each app JS file that saves settings needs the same fix.** Search `setDoc.*accountSettings` across all JS files — 4+ files may need updating.
|
||||
- **The `data` field must be `json` type, not `text`.** A text field would stringify the object and lose nested structure.
|
||||
- **Test the full round-trip before deploying.** Create → query by name → read back → update → read back again.
|
||||
|
||||
## 10. Raw API Round-Trip Test for Settings
|
||||
|
||||
When debugging settings persistence, test the PocketBase API directly (bypassing the app) to isolate adapter bugs from schema bugs.
|
||||
|
||||
**Test script pattern (Python):**
|
||||
```python
|
||||
# 1. Auth as user
|
||||
# 2. Create record with { data: {...}, userId, name }
|
||||
# 3. Query by name + userId filter
|
||||
# 4. Verify data field values
|
||||
# 5. Update and re-read
|
||||
# 6. Delete test record
|
||||
```
|
||||
|
||||
Run locally via `python3` against `http://127.0.0.1:PORT/api/`. Use the admin token for schema changes, user token for record CRUD. This quickly proves whether the schema + API interaction works before touching adapter code.
|
||||
|
||||
## 7. Sync Auth Guard on Protected Pages
|
||||
|
||||
**Problem:** Module-based auth checks (`onAuthStateChanged` in `dashboard.js`) run asynchronously. The page content may flash before the redirect fires, or the check may fail silently due to broken imports (wrong relative path) or cached stale modules. The user can access protected pages without being logged in.
|
||||
|
||||
**Fix:** Add a synchronous inline script right after `<body>` on every protected page:
|
||||
```html
|
||||
<body class="...">
|
||||
<script>if(!localStorage.getItem('pocketbase_auth')){location.replace('login.html')}</script>
|
||||
```
|
||||
|
||||
And the inverse on the login page (skip login if already authenticated):
|
||||
```html
|
||||
<body class="...">
|
||||
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('index.html')}</script>
|
||||
```
|
||||
|
||||
**Apply to all pages:**
|
||||
```bash
|
||||
for f in index.html repair-orders.html appointments.html customers.html; do
|
||||
# Add after <body ...> tag
|
||||
sed -i '/^<body/a<script>if(!localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''login.html'\'')}</script>' $f
|
||||
done
|
||||
|
||||
# Inverse guard on login page
|
||||
sed -i '/^<body/a<script>if(localStorage.getItem('\''pocketbase_auth'\'')){location.replace('\''index.html'\'')}</script>' login.html
|
||||
```
|
||||
|
||||
**Verify with curl:**
|
||||
```bash
|
||||
curl -sk https://site.com/index.html | grep "location.replace('login.html')"
|
||||
```
|
||||
|
||||
**Why `location.replace()` not `location.href`:** `replace()` doesn't add to browser history, preventing back-button loops where the user gets bounced between the protected page and login infinitely.
|
||||
|
||||
**Always add `?cb=` cache-busting to redirects:**
|
||||
```javascript
|
||||
// EVERY redirect should include ?cb=Date.now()
|
||||
location.replace('login.html?cb='+Date.now()); // auth guard
|
||||
location.replace('index.html?cb='+Date.now()); // login success
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
location.replace('login.html?cb='+Date.now()); // sign-out
|
||||
```
|
||||
If the user's browser cached an old version of the target page (before the auth guard was added), a plain `login.html` redirect loads the stale cached page — which has no guard, so it appears to "not work." The `?cb=` parameter forces the browser to fetch a fresh copy.
|
||||
|
||||
## 8. CSP Blocking Inline Scripts After Migration
|
||||
|
||||
**Problem:** After adding inline `<script>` auth guards and `onclick` handlers to fix post-migration auth flow, **only the main page (index.html) doesn't work** — the auth guard doesn't redirect, dropdowns don't open, sign-out does nothing. Other pages work fine. The inline code is in the served HTML (verified with `curl | grep`).
|
||||
|
||||
**Root cause:** `index.html` has a `<meta>` Content Security Policy (commonly from a Firebase Hosting template) that blocks inline scripts:
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="...script-src 'self' https://cdn.example.com...">
|
||||
```
|
||||
No `'unsafe-inline'` in `script-src`. The browser silently refuses to execute all inline `<script>` tags and `onclick` handlers. Other pages (`repair-orders.html`, etc.) have no CSP and work fine.
|
||||
|
||||
**Diagnosis — compare the working vs broken page:**
|
||||
```bash
|
||||
curl -sk https://site.com/index.html | grep -o 'script-src[^;]*'
|
||||
# Returns: script-src 'self' https://cdn.tailwindcss.com ... (no 'unsafe-inline')
|
||||
curl -sk https://site.com/repair-orders.html | grep -o 'script-src[^;]*'
|
||||
# Returns: <empty> (no CSP meta tag at all)
|
||||
```
|
||||
|
||||
**Fix — add `'unsafe-inline'`:**
|
||||
```html
|
||||
<!-- BEFORE -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="...script-src 'self' https://cdn.example.com...">
|
||||
|
||||
<!-- AFTER -->
|
||||
<meta http-equiv="Content-Security-Policy"
|
||||
content="...script-src 'self' 'unsafe-inline' https://cdn.example.com...">
|
||||
```
|
||||
|
||||
This is a migration-specific pitfall because the Firebase Hosting template included a CSP for security, but the PocketBase migration adds inline scripts (auth guards, onclick handlers) that the CSP blocks.
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
# Reference-build parity + live runtime validation (ShopProQuote-style migrations)
|
||||
|
||||
Use this when the user asks to make a target web app behave the same as a known-good reference build (especially after backend swaps like Firebase → PocketBase).
|
||||
|
||||
## 1) Fastest parity path for HTML interaction bugs
|
||||
|
||||
If target pages have accumulated inline fallbacks (onclick handlers, duplicate modal managers, auth guard snippets) and behavior diverges from reference:
|
||||
|
||||
1. Back up target HTML files (`*.bak.*`).
|
||||
2. Copy reference HTML files over target for the relevant pages.
|
||||
3. Apply only deterministic backend bootstrap rewrites (e.g. `src="firebase.js"` → `src="pocketbase.js"`).
|
||||
4. Verify parity by diffing with normalization (reference content + the one allowed rewrite).
|
||||
|
||||
This is usually faster and safer than manually removing dozens of injected handlers one-by-one.
|
||||
|
||||
## 2) Evidence-first runtime validation checklist
|
||||
|
||||
After parity sync, run real click-path checks and report explicit pass/fail evidence per action:
|
||||
|
||||
- Dashboard: open/close key modals (financial, tasks, create task)
|
||||
- Repair Orders: tab switching (active/history/quote generator)
|
||||
- Quote form: clear/save click-path (look for API request signals)
|
||||
- Appointments: create modal open/submit/close + created item visible
|
||||
- Customers: search/filter clear and edit/open flows (or report blocked by dataset)
|
||||
|
||||
Evidence format should include:
|
||||
|
||||
- button id clicked
|
||||
- modal/content visibility before/after
|
||||
- URL after navigation/login
|
||||
- request counts or concrete DOM text signals
|
||||
|
||||
## 3) Environment workaround: Playwright on Ubuntu 26
|
||||
|
||||
On Ubuntu 26, `python -m playwright install chromium` may fail with unsupported-browser errors.
|
||||
|
||||
Reliable fallback used in-session:
|
||||
|
||||
1. `python3 -m pip install playwright`
|
||||
2. `python3 -m playwright install chrome`
|
||||
3. Launch with channel Chrome:
|
||||
|
||||
```python
|
||||
browser = p.chromium.launch(channel='chrome', headless=True, args=['--ignore-certificate-errors'])
|
||||
page = browser.new_page(ignore_https_errors=True)
|
||||
```
|
||||
|
||||
Use this for local HTTPS self-signed targets like `https://127.0.0.1:3447`.
|
||||
|
||||
## 4) Flake-resistant validation for edit/delete flows
|
||||
|
||||
UI-only assertions are not enough for list/card actions (especially appointments/customers edit/delete). Use a two-layer check:
|
||||
|
||||
1. **Network assertion**: capture the expected request (e.g., `DELETE /api/collections/appointments/records/{id}`) and status.
|
||||
2. **State assertion**: verify post-action backend state (`GET` returns `404` for deleted id) plus UI disappearance.
|
||||
|
||||
Also use robust targeting for action menus:
|
||||
|
||||
- re-query visible cards right before click (avoid stale handles)
|
||||
- open action menu, then wait for concrete menu item visibility
|
||||
- after click, wait for either success toast OR API-state change
|
||||
|
||||
If a run is inconsistent, report as automation flake unless backend state disproves deletion.
|
||||
|
||||
## 5) Reporting discipline
|
||||
|
||||
If blocked (auth/data/permissions), mark checks as `blocked` with exact reason.
|
||||
Do not claim pass without concrete runtime evidence.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Repair Orders Page Fix: Missing Mobile Navigation
|
||||
|
||||
## Bug
|
||||
Hamburger menu button (top-right, next to account settings) did nothing when tapped on mobile. Page didn't have mobile navigation at all.
|
||||
|
||||
## Diagnosis
|
||||
Compared `repair-orders.html` against working pages (`customers.html`, `appointments.html`). Found **three missing pieces**:
|
||||
|
||||
1. **Missing script include** — `shared/header-functionality.js` was not loaded. This file contains the `setupHeaderEventListeners()` function that registers the click handler for `#mobile-menu-btn`.
|
||||
|
||||
2. **Missing HTML** — The `#mobile-navigation` div (the nav panel that slides in on hamburger click) didn't exist in the page. Working pages had it after the desktop `#main-navigation` block.
|
||||
|
||||
3. **Missing CSS** — `#mobile-navigation` z-index and slide-in animation styles were not present in the inline `<style>` block.
|
||||
|
||||
## Fix Applied
|
||||
|
||||
### 1. Added script tag (alongside existing scripts at page bottom)
|
||||
```html
|
||||
<script type="module" src="shared/header-functionality.js"></script>
|
||||
```
|
||||
|
||||
### 2. Added mobile-navigation HTML after `#main-navigation` close
|
||||
```html
|
||||
<div id="mobile-navigation" class="lg:hidden hidden py-3 md:py-4 border-t border-white/20">
|
||||
<div class="space-y-2">
|
||||
<!-- Dashboard link -->
|
||||
<!-- Repair Orders link (highlighted active) -->
|
||||
<!-- Appointments link -->
|
||||
<!-- Customers link -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
Nav links use `mobile-nav-link` class and include inline SVG icons for each page. Active page gets `bg-white/20 text-white shadow-sm` highlight.
|
||||
|
||||
### 3. Added CSS (within `<style>` block)
|
||||
```css
|
||||
/* Mobile Navigation */
|
||||
#mobile-navigation {
|
||||
z-index: 10000 !important;
|
||||
}
|
||||
|
||||
#mobile-navigation {
|
||||
animation: mobileNavSlideIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes mobileNavSlideIn {
|
||||
from { opacity: 0; transform: translateY(-20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#mobile-navigation {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern
|
||||
This follows a recurring pattern in multi-page static web apps: when a UI feature works on some pages but not others, it's almost always one of:
|
||||
1. **Script** not loaded on the broken page
|
||||
2. **HTML element** not present on the broken page
|
||||
3. **CSS rule** missing on the broken page
|
||||
|
||||
Check all three layers systematically.
|
||||
@@ -0,0 +1,67 @@
|
||||
## Silent setTimeout / Async Callback Errors
|
||||
|
||||
### Session Context
|
||||
|
||||
Debugged in ShopProQuote (PocketBase web app) — "Mark as Completed" flow on repair orders.
|
||||
|
||||
### The Bug
|
||||
|
||||
Clicking work-status-badge → "Completed" → confirm modal → nothing. Financial summary modal never appeared. No console errors.
|
||||
|
||||
### The Chain
|
||||
|
||||
1. User clicks confirm button in dynamically-created confirm modal element
|
||||
2. Click handler calls `updateRepairOrderWorkStatus(roId, 'completed')` (async)
|
||||
3. Inside, code reaches `if (newWorkStatus === 'completed')` branch (confirmed by console.log)
|
||||
4. `setTimeout(() => { openFinancialSummaryModal(roId); }, 100)` is scheduled
|
||||
5. `return` exits both the function and the enclosing `try/catch`
|
||||
6. 100ms later, `openFinancialSummaryModal` runs — this is NOW outside any try/catch
|
||||
7. Line 1545: `document.getElementById('financial-customer-name').textContent = ...`
|
||||
8. `#financial-customer-name` never existed in the HTML → `null.textContent` → `TypeError`
|
||||
9. The TypeError is uncaught (no try/catch in the detached context) → global error handler
|
||||
10. Global error handler does nothing visible → user sees "nothing happens"
|
||||
|
||||
### The Tool That Caught It
|
||||
|
||||
Overriding `window.setTimeout` from Playwright:
|
||||
|
||||
```python
|
||||
page.evaluate("""()=>{
|
||||
window.__origSetTimeout = window.setTimeout.bind(window);
|
||||
window.setTimeout = function(fn, delay) {
|
||||
if(delay===100) {
|
||||
return window.__origSetTimeout(function(){
|
||||
try { return fn(); }
|
||||
catch(e) { console.error('TIMEOUT ERROR:', e); }
|
||||
}, delay);
|
||||
}
|
||||
return window.__origSetTimeout(fn, delay);
|
||||
};
|
||||
}""")
|
||||
```
|
||||
|
||||
This logged: `TIMEOUT ERROR: TypeError: Cannot set properties of null (setting 'textContent') at openFinancialSummaryModal (repair-orders.js:1545:72)`
|
||||
|
||||
### The Fix
|
||||
|
||||
Added missing `<span id="financial-customer-name">--</span>` to the `financial-summary-modal` HTML in `repair-orders.html`.
|
||||
|
||||
### Verification Pattern
|
||||
|
||||
After the fix, the full keyboard+click flow was re-tested end-to-end via Playwright:
|
||||
1. Create repair order (fill form)
|
||||
2. Click work status badge → select "Completed"
|
||||
3. Confirm modal → financial modal appears
|
||||
4. Fill CP Total / CP Cost
|
||||
5. Click "Complete" → modal closes, notification shows
|
||||
|
||||
One way to `overwrite for robust test runs`:
|
||||
```python
|
||||
# Trap ALL setTimeout errors during test
|
||||
page.evaluate("""()=>{
|
||||
const _st=window.setTimeout.bind(window);
|
||||
window.setTimeout=(fn,d)=>_st(function(){
|
||||
try{return fn()}catch(e){console.error('setTimeout ERROR:',e.stack||e)}
|
||||
},d);
|
||||
}""")
|
||||
```
|
||||
Reference in New Issue
Block a user