# PWA Dev Style Guide — 360LM
> Universal build reference + pre-build questionnaire for Claude. Patterns extracted from Recce (primary exemplar). **Always start with Section 0 before writing any new PWA.** All implementation details are Recce-pattern unless noted; adapt to app context.

---

## 0. New PWA — Pre-Build Questionnaire

> **How to use**: Before writing any code, ask the user each question group below. Present numbered options. Collect ALL answers first. Then reference the corresponding sections for implementation patterns. Compatible with any GPT/LLM.

### Q0.1 App Identity
Ask: "What is the app name? (short name for manifest + full title for display)"
Ask: "Primary purpose?" — Options:
- (a) Field data collection (agent submits records offline)
- (b) Finance / expense tracking
- (c) Job / installation scheduling
- (d) Admin dashboard
- (e) Other (describe)

Ask: "Color palette?" — Options:
- (a) Orange accent `#f97316` on Navy `#0f172a` [Recce, Hub — standard]
- (b) Orange on Black `#0a0a0a` [Installation]
- (c) Green accent `#16a34a` on Navy
- (d) Custom (ask for hex)

### Q0.2 Authentication
Ask: "How should users log in?" — Options:
- (a) Hub session gate — reads `lm360-session` from localStorage, 12 h expiry, redirect `/hub/?next=<encoded-current-path>` if missing/expired [all current PWAs]. **The `?next=` parameter is mandatory — see `feedback_hub_next_redirect.md`.** Hub validates and redirects back after PIN entry.
- (b) PIN-based local auth (no server lookup)
- (c) No auth — public access

### Q0.3 Data & Storage
Ask: "What are the main data entities to store?" (each becomes an IndexedDB store or Postgres table)
Ask: "Submission ID format?" — Options:
- (a) `PREFIX_<Date.now()>_<4RAND>` e.g. `RC_1714900000_AB3C` [Recce pattern — standard]
- (b) Auto-increment (server-assigned)
- (c) UUID

Ask: "Storage priority?" — Options:
- (a) Offline-first: IndexedDB primary, server sync secondary [standard]
- (b) Online-required: no local storage
- (c) Local-only: no server sync

### Q0.4 Form Flow
Ask: "How many form steps?" — Options:
- (a) 1 step (single page)
- (b) 2–3 steps (light)
- (c) 4–5 steps with progress bar [Recce standard]
- (d) 6+ steps

Ask: "Auto-save draft on field change (debounced 500 ms)?" Yes / No
Ask: "Can user edit previously submitted records?" Yes / No
Ask: "Save-as-draft before final submit?" Yes / No — If Yes, add `status TEXT DEFAULT 'submitted'` column; draft tiles re-open capture form pre-filled; final submit captures GPS and sets `status='submitted'`

### Q0.5 Photos
Ask: "What photo groups are needed?" (e.g. Front / Inside / Additional / Before / After / Evidence)
Ask: "Per group: min/max counts?" (e.g. Front: min 1 max 3 | Inside: optional max 3)
Ask: "Camera vs gallery choice dialog on capture?" Yes / No
Ask: "GPS stamp burned onto camera photos?" Yes (standard) / No
Ask: "Canvas annotation (draw rectangles, arrows, freehand) on any photo group?" Yes [Recce branding] / No
> **Note:** For proof/receipt images attached to *transactions* (not field-survey photo groups), see **Q0.14** instead — that covers the full crop + annotation + compression editor standard.
Ask: "Compression level?" — Options:
- (a) Two-mode: Compressed 1280 px / q 0.78 [default] + HD 1920 px / q 0.90 [standard]
- (b) Fixed single quality

### Q0.6 GPS & Location
Ask: "Auto-capture GPS when form opens?" Yes (standard) / No
Ask: "GPS quality badge (color-coded ±Xm) shown in UI?" Yes (standard) / No
Ask: "GPS stamp in photos?" Yes (standard) / No
Quality thresholds: ≤15 m = Excellent · ≤50 m = Good · ≤200 m = Fair · >200 m = Poor (cell)

### Q0.6b Maps APIs (geocode / routing / places / traffic)
Ask: "Does this PWA call any Maps API (geocode, directions, distance-matrix, places-nearby, traffic)?" Yes / No
If Yes — **Maps Provider Policy applies** (see `feedback_maps_provider.md`). Confirm with the user:
- **Default provider: Google Maps MCP** (`/mcp-maps/`) — used for ALL basic operations unless one of the four exceptions below applies
- **Use TomTom instead** (`/mcp-tomtom/`) ONLY for:
  - (a) Live traffic-incident lookup — `route_traffic` tool (Google doesn't expose this)
  - (b) Distance matrix > 25 origins or > 25 destinations (TomTom: 100×100; Google: 25×25)
  - (c) Truck / HGV / hazmat routing with vehicle constraints (Google doesn't support)
  - (d) Auto-failover when Google returns 429 / 5xx / timeout
- **Automatic fallback wiring is MANDATORY** for new PWAs:
  - Browser-side: include `<script src="/shared/maps-client.js">` and call `mapsClient.geocode()` etc. — falls back to TomTom on failure
  - Server-side: wrap calls in try/catch and retry on TomTom; log `provider` in response so we can see fallback rates
- Document each Maps operation's primary provider in this PWA's `dbt_<pwa>.md` so it survives refactors
- Add a Playwright test that simulates Google failing and asserts TomTom answered

### Q0.7 Store / Record Lookup
Ask: "Does the form have a store/outlet/location picker?" Yes / No
- If Yes: "Source?" — (a) Excel/CSV upload (SheetJS) + server JSON auto-fetch [Recce] · (b) Server API only · (c) Manual entry only
- If Yes: "Full-screen search picker with live multi-word filter?" Yes [Recce] / Simple dropdown

### Q0.8 Admin Mode
Ask: "Is there an admin view with elevated data access?" Yes / No
- If Yes: "Unlock method?" — (a) Auto-unlock by empId list (e.g. `['harish','pramod','rakesh']`) + optional password for others [standard] · (b) Password only · (c) None
- If Yes: "What does admin see extra?" — All users' submissions / Aggregated stats / Both

### Q0.9 Sync & Backend
Ask: "Primary backend?" — Options:
- (a) PostgREST at `/db/` with schema prefix [standard]
- (b) Custom REST API
- (c) None — local only

Ask: "Google Sheet sync (GAS `syncSheet`)?" Yes / No
- If Yes: "New sheet or existing?" (need Sheet ID). See §19.14 for 107-col layout pattern.

Ask: "Google Slides auto-generation on sync?" Yes / No
- If Yes: "Which template?" — (a) Recce template (store info + photo groups + branding items) · (b) Installation template (job + counters) · (c) New layout (describe)

Ask: "Server HTML report saved to VPS?" Yes (`/slides-proxy` save-view pattern) / No
Ask: "Photos uploaded to VPS for permanent URLs?" Yes (`/slides-proxy` save-photos) / No

### Q0.10 Service Worker
Ask: "Offline support needed?" Yes (standard) / No
- If Yes: Cache name convention: `<app-short-name>-v<N>` (increment N on every release)
- Default strategy: Cache-first, network fallback, fallback to cached `/` on failure

### Q0.11 UI Chrome
Ask: "Bottom navigation bar?" None / 2 tabs / 3 tabs / 4 tabs
Ask: "Hub button (🏡) in every view header?" Yes (standard — all current PWAs) / No
Ask: "Settings view with admin-locked URL inputs?" Yes / No
Ask: "Toast notification system?" Yes (standard) / No

### Q0.12 Special Features
Ask each: "Does this app need…?"
- Mandatory document upload (image or PDF)
- Video capture field with note
- Repeating typed-item + photo blocks (like Recce branding items)
- Excel/CSV import for reference data
- Filter + group-by in list view (admin)
- Offline report view (blob URL, 30 s auto-revoke) + server report (permanent URL)

### Q0.13 Amount Input — Indian Formatting ⚑ CONFIRM BEFORE IMPLEMENTING

> Full spec: `docs/input_and_image_standards.md §1`

**Standard behavior (default YES for any money-amount field):** All amount inputs display in Indian number format (`??,??,???.??`) as the user types — last 3 digits group, then groups of 2 leftward. Cursor position is preserved during mid-number editing.

Ask: "Should amount input boxes use Indian number formatting?"
- (a) **Yes — all money fields** [standard for 360LM]
- (b) Yes — only specific fields (list them)
- (c) No — plain numeric input

**Implementation notes:**
- Change `type="number"` → `type="text" inputmode="decimal"` on target fields
- Exception: line-item rows that use `querySelectorAll('input[type="number"]')` for auto-calc must stay `type="number"`
- Replace every `parseFloat(el.value)` on formatted fields with `_parseAmt(el)`
- Copy `fmtAmountInput()`, `_indFmt()`, `_parseAmt()` utility functions verbatim from the spec

### Q0.14 Proof Image Capture — Editor & Compression ⚑ CONFIRM BEFORE IMPLEMENTING

> Full spec: `docs/input_and_image_standards.md §2`

**Standard behavior (apply when a payment or submission needs an image as proof):** File pick or camera capture opens a fullscreen image editor (crop + rectangle + oval + line + pen + color + stroke width + undo), then a quality-slider compression step (10–95, default 70, max 100 KB). Image stored as `receipt_image TEXT` in DB. Ledger/list queries never include the image column — lazy-fetch on tap only.

Ask: "Does this PWA capture proof images?"
- (a) **Yes — with full editor** (crop + all annotation tools) [standard]
- (b) Yes — crop only (no annotation)
- (c) Yes — compress immediately, no editing step
- (d) No

If (a), (b), or (c): ask which submission modes require a **mandatory** image vs **optional**.

Ask: "Does any mode need Cash Hand-to-Hand verification (front camera + signature composite)?"
- Yes → include CTH modal with 3-2-1 countdown, signature canvas, composite at 800 px, desktop fallback to signature-only
- No → skip CTH

---

## 1. App Identity

| Property | Value |
|---|---|
| Full name | Branding Recce |
| Short name | Recce |
| Purpose | Offline-first field form — branding recce data for field agents |
| Font | DM Sans (400/500/600/700) via Google Fonts |
| Theme color | `#0f172a` (navy) |
| Background color | `#0f172a` |
| Orientation | Portrait |
| Display | Standalone (installed PWA) |
| Language | en-IN |
| Auth gate | Checks `lm360-session` in localStorage. Session expires in 12 hours. Expired or missing → redirect to `/hub/` |

---

## 2. Design System

### 2.1 CSS Custom Properties

```css
--nv:  #0f172a   /* Navy dark — headers, heroes, dark backgrounds */
--nv2: #1e293b   /* Navy medium — secondary dark surfaces */
--or:  #f97316   /* Orange — primary accent, CTAs, active states */
--orl: #fed7aa   /* Orange light — hover tints */
--gn:  #16a34a   /* Green — synced, success, GPS good */
--rd:  #dc2626   /* Red — error, danger, failed */
--am:  #d97706   /* Amber — warning, pending, distributor text */
--g50:  #f8fafc  /* Page background */
--g100: #f1f5f9  /* Section backgrounds */
--g200: #e2e8f0  /* Borders */
--g300: #cbd5e1  /* Disabled borders */
--g400: #94a3b8  /* Muted text */
--g500: #64748b  /* Secondary text */
--g600: #475569  /* Body text */
--r:    12px     /* Card border-radius */
--rs:   8px      /* Small element border-radius */
--sh:   0 1px 3px rgba(0,0,0,.1), 0 1px 2px rgba(0,0,0,.06)  /* Card shadow */
--bnav-h: 72px   /* Bottom nav bar height */
--host-bar: 0px  /* Raised dynamically if tiiny.host bar detected */
```

### 2.2 Typography Scale

| Usage | Size | Weight | Color |
|---|---|---|---|
| Hero title | 22px | 700 | #fff |
| Header title | 16px | 600 | #fff |
| Card section title | 11px UPPERCASE | 700 | `--g500` |
| Form label | 13px | 500 | `--g600` |
| Form input | 15px | 400 | `--nv` |
| Body text | 14px | 400 | `--nv` |
| Metadata / chips | 11–12px | 500–600 | varies |
| Stat number | 22px | 700 | white / green / yellow |

### 2.3 Button System (4 variants)

| Class | Style | Use |
|---|---|---|
| `.btn-p` | Orange fill, white text, 15px bold, full-width, 15px padding | Primary action (Continue, Submit) |
| `.btn-s` | White fill, navy text, 1.5px `--g200` border, full-width | Secondary / back action |
| `.btn-o` | White fill, orange text + border, inline-flex, 14px | Outline action (Add, Fetch, Save) |
| `.btn-d` | White fill, red text + `#fecaca` border, inline-flex, 12px | Destructive (Remove, Delete) |

Active states: `.btn-p:active` → `#ea6c05`; `.btn-s:active` → `--g100`; `.btn-o:active` → `--orl`

### 2.4 Form Controls

- **Input / Textarea / Select**: `1.5px --g200` border, `--rs` radius, `15px` font, focus → `1.5px --or` border (no outline)
- **Read-only input**: `--g50` background, `--g600` text
- **Select**: custom chevron SVG via background-image, `padding-right:36px`
- **Required asterisk**: `<span class="req">` → orange color
- **Radio buttons (`.rbtn`)**: flex group, 1:1, orange fill when `.sel`
- **Chips (`.chip`)**: `7px 12px`, `20px` border-radius, orange fill + white text when `.sel`

### 2.5 Photo Slot (`.pslot`)

- Empty: `2px dashed --g300`, `min-height:110px`, centered icon + label
- Filled (`.filled`): `2px solid --or` (orange border), no padding, `img` covers slot
- Remove button (`.premove`): `26×26px` red circle, top-right corner, z-index 5
- Size badge (`.pbadge`): bottom-right pill, `rgba(0,0,0,.6)`, file size label
- Quality toggle buttons (`atool`): appear below empty pslot for branding items only

---

## 3. State Architecture

### 3.1 Global Variables

```js
let F = {}               // Form data accumulator — all step fields merged here
let frontPhotos  = []    // [{blob, prev, cmt, src}]
let insidePhotos = []    // [{blob, prev, cmt, src}]
let addlPhotos   = []    // [{blob, prev, desc, src}]
let brandings    = []    // [{type, w, h, qty, quality, blob, prev, fromCamera}]
let videoBlob    = null  // File object for video
let submissionDoc = null // Blob — mandatory document
let gpsCoords    = null  // {lat, lng, acc} — set on new recce start + manual capture
let stores       = []    // Loaded from IndexedDB / server — all store list entries
let selectedStore = null // Currently selected store object from picker
let manualMode   = false // true when user enters store manually
let DB                   // IndexedDB instance (RecceDB v4)
let imgQuality   = 'compressed'  // 'compressed' | 'hd'
let geoStampEnabled = true       // GPS stamp on camera photos
let brands = ['LENOVO','HP','PHILIPS','LIEBHERR']  // Editable brand list
let _storeFiles  = []    // Tracked uploaded files [{name, count, brand}]
let _pendingParse = null // Parsed store rows waiting brand assignment
let _adminUnlocked = false  // true for hub empIds: harish, pramod, rakesh
let _adminRows   = []    // Server rows for admin view
let _myServerRows = []   // Server rows for current field agent
```

### 3.2 Constants

```js
const BTYPE = [
  'Glow Sign Board','Inshop Branding','ACP Board','One Way Vision',
  'Fabric Board','Lollypop','Non Lit Flex','Double Side GSB',
  'Clip on Board','Translit','Vinyl Printing',
  'Roll up Standee','Sunboard Standee'              // added 2026-05-05
]
const CACHE_VER = 'recce-v6'          // bump with every release (triggers update banner)
const _RECCE_ADMIN_IDS = ['harish','pramod','rakesh']
const DEFAULT_GAS_URL = 'https://script.google.com/...'   // Sheet sync GAS
const DEFAULT_STORES_URL = 'https://srv1111289.hstgr.cloud/recce/stores.json'
const DEFAULT_ADMIN_HASH = '42d372e...'   // SHA-256 of default admin password
const _RC_BASE = '/db'                    // PostgREST base
const _RC_READ   = { 'Accept-Profile': 'recce' }
const _RC_WRITE  = { 'Content-Type': 'application/json', 'Content-Profile': 'recce', 'Accept-Profile': 'recce' }
const _RC_UPSERT = { ..._RC_WRITE, 'Prefer': 'resolution=merge-duplicates,return=minimal' }
```

---

## 4. Database (IndexedDB)

**DB name:** `RecceDB` **version:** 4

| Store | Key | Contents |
|---|---|---|
| `subs` | `id` | Submitted recce entries (full data including base64 photos) |
| `cfg` | `k` | App config key-value pairs |
| `draft` | `k` | Active draft + photos (keys: `current`, `photos`) |

### 4.1 Config Keys (cfg store)

| Key | Type | Description |
|---|---|---|
| `stores` | JSON string | Cached store list array |
| `store-files` | JSON string | Uploaded file tracking array |
| `srv-url` | string | Server URL for auto-fetching stores.json |
| `gas-url` | string | Google Apps Script URL (Sheet sync — legacy, fire-and-forget) |
| `slides-gas-url` | string | Google Apps Script URL (Slides generator — current, awaited via proxy) |
| `img-quality` | string | `'compressed'` or `'hd'` |
| `geo-stamp` | string | `'1'` = enabled, `'0'` = disabled (null = enabled by default) |
| `brands` | JSON string | Brand list array |
| `admin-cfg-url` | string | URL to remote admin.json (password/hash) |
| `admin-hash` | string | Local SHA-256 admin password hash |
| `last-sync` | ISO string | Timestamp of last successful sync |

### 4.2 Submission Entry Schema

```js
{
  id:             'RC_<timestamp>_<4-char-random>',  // 'RC_1714900000000_AB3C'
  timestamp:      ISO string,     // Created at (immutable)
  editedAt:       ISO string,     // Set only on edits
  status:         'pending' | 'synced' | 'failed',
  gasAt:          ISO string,     // When GAS was fired (set during sync)
  syncedAt:       ISO string,     // When Postgres confirmed sync
  viewUrl:        string | null,  // Server-hosted HTML report URL
  slideUrl:       string | null,  // Google Slides URL (if generated)
  submittedBy:    empId,          // From hub session
  submittedByName: name,
  data: {
    date:         'YYYY-MM-DD',
    storeName:    string,
    brand:        string,
    address:      string,
    mobile:       string,
    city:         string,
    gpsCoords:    {lat, lng, acc} | null,
    hasVideo:     boolean,
    videoNote:    string | null,  // e.g. 'Video local (2.1MB) — share via WhatsApp'
    videoCmt:     string,
    hasBranding:  boolean,
    frontPhotos:  [{b64, cmt}],
    insidePhotos: [{b64, cmt}],
    brandings:    [{type, w, h, qty, sqft, photoB64}],
    addlPhotos:   [{b64, desc}],
    docB64:       string,         // Mandatory document as base64
  }
}
```

### 4.3 Draft Schema

```js
// key: 'current'
{
  k: 'current', ts: timestamp,
  F: { date, brand, storeName, address, mobile, hasVideo, hasBranding, editingId? },
  selectedStore: object | null,
  manualMode: boolean,
  gpsCoords: {lat, lng, acc} | null,
  fCnt: number,  // frontPhotos count (for resume banner)
  iCnt: number,  // insidePhotos count
  bCnt: number,  // brandings count
}

// key: 'photos'
{
  k: 'photos',
  front:  [{b64, cmt}],
  inside: [{b64, cmt}],
  addl:   [{b64, desc}],
  brand:  [{type, w, h, qty, b64}],
}
```

---

## 5. Screen Architecture (7 views)

Views shown/hidden via `showView(n)` — toggles `.active` class, no page reloads.

### View 1 — Home (`#view-home`)

**Layout:** Hero (dark navy) → Stats row (3 cards) → New Recce button → Resume Draft banner → Submissions list → Network status strip

**Hero:** "Branding Recce" title, today's date (en-IN), top-right: 🏡 Hub pill + ⚙ gear

**Stats (field agent):** Synced (green) = local synced + server-only; Pending (amber) = local non-synced; Total = all
**Stats (admin):** Showing = filtered count; Agents = unique agent count; Total = all server rows

**Resume draft banner:** Dark navy, shows "Resume draft · X ago · StoreName · Brand · X photos →". Only shown when `draft.current` has `storeName`.

**Field agent submissions list:** Last 50 local + server-only rows, newest first. Card buttons: 👁 View · ✏ Edit · 📊 Slides. Status badge (synced/pending/failed).

**Admin submissions:** Fetches 500 rows from PostgREST. Filter bar: Agent / Brand / Period / Group-by. Free-text search on store_name + city.

### View 2 — Settings (`#view-settings`)

Key sections (admin-locked panels use SHA-256 password, 10-min session):
- Brand List: pill tags with ×, add/fetch from server URL
- Store List: Excel/CSV upload (SheetJS), brand-assign dialog, preview dialog, server URL
- Sync Endpoint: two admin-locked URL inputs → `gas-url` (Sheet sync) + `slides-gas-url` (Slides generator)
- Geolocation Stamp: toggle switch
- Image Quality: Compressed [default, green "Recommended"] / HD
- Danger Zone: Clear all local data

### View 3 — Form Step 1: Store Details (20%)

Three modes: Store picker (`.povl` overlay, 100-result cap, grouped by brand) · Store selected card (all fields, missing fields editable inline) · Manual entry (Brand + Name + Address + GPS capture + Mobile)

**Validation:** Date · Store name · Brand · Address all required

### View 4 — Form Step 2: Store Photos (40%)

Front: min 1, max 3. Inside: optional, max 3. Both use same photo-src-dialog → Camera/Gallery → compress → GPS stamp (camera only) → saveDraft.

**Validation:** At least 1 front photo required

### View 5 — Form Step 3: Video (60%)

Yes/No radio. Yes → upload slot + comment. Video stored as `videoBlob`, not uploaded (WhatsApp note generated). **Validation:** Yes or No required.

### View 6 — Form Step 4: Branding (80%)

Yes/No radio. Each branding item (`.bitem`): Type chip-grid (13 BTYPE options) · Width/Height/Qty inputs · sqft auto-calc · Photo slot → `capBr(i)` → compress → stamp → `openAnnot(i)` (annotation modal).

"Add Another" button only shown when last item fully complete (type + w + h + qty + photo). Max 10 items. Dup ↑ copies dimensions from previous item.

**Validation:** Every item must have type + w + h + qty + photo

### View 7 — Form Step 5: Additional + Submit (100%)

Mandatory document (image/* or .pdf), Additional photos (optional, max 8), Review card (all data summary), Submit button.

**Submit flow:** Save to IndexedDB (status: pending) → clear draft → if online: syncNow() → navigate home

---

## 6. Overlay / Modal System (z-index layers)

| Z | Element | Trigger |
|---|---|---|
| 200 | `.povl` Store Picker | `openPicker()` |
| 300 | `.annot-modal` Annotation | `openAnnot(i)` |
| 400 | `.bdialog-wrap` Bottom Dialogs | `.classList.add('open')` |
| 500 | `#admin-login-modal` | `showAdminLogin()` |
| 550 | `.upd-banner` App update banner | `checkAppUpdate()` on init |
| 600 | `.ardl-overlay` Admin Detail Sheet | `openAdminRecce(subId)` |
| 1000 | `#toast` | `toast(msg, type)` |

> ⚠️ **DOM ORDER RULE**: When multiple modals share the same `z-index`, the one **later in the DOM** renders on top. Always place picker/source-chooser modals AFTER the base modal they need to appear over. If a sub-picker (e.g. camera/gallery chooser) is placed before a parent modal in the DOM, it will be hidden behind the parent when both are open simultaneously.
>
> Also: when closing a parent modal (e.g. photo-edit), always call `cancelPicker()` first to clear any pending callback — otherwise the callback fires on a now-closed modal.

### Annotation Modal (branding photos only)

Draw toolbar: Rect · Ellipse · Line · Arrow · Free · Move. Color swatches: Orange[default] / Red / White / Yellow / Green. Width: Thin(2px) / Med(4px)[default] / Thick(7px). Undo / Clear.

Canvas scale: `sc = Math.min(1, innerWidth/img.w, (innerHeight-185)/img.h)`. Shapes: strokeRect with `color+'28'` fill; ellipse; moveTo→lineTo; arrow with `atan2` barbs. Move mode: 32px touch hit-test, corner resize handles.

Footer: "Skip markup" (keeps original blob) · "Done ✓" (`canvas.toBlob(jpeg, 0.88)` → replaces `brandings[i].blob`).

### Bottom Dialogs

| ID | Purpose |
|---|---|
| `#photo-src-dialog` | Camera vs Gallery picker |
| `#exit-dialog` | Exit with Save Draft / Discard / Continue |
| `#store-preview-dialog` | Excel preview before store merge |
| `#bdialog` | "Add reference photos?" after branding photo |

---

## 7. Photo Pipeline

### 7.1 Compression (`compress`)

```
compress(file, maxPxOverride?, qOverride?)
  └── maxPx = override || (hd → 1920, compressed → 1280)
  └── q     = override || (hd → 0.90, compressed → 0.78)
  └── Canvas: scale to fit maxPx on longest side
  └── canvas.toBlob(jpeg, q) → resolved blob
  └── onerror → resolves with original file
```

### 7.2 GPS Stamp (`stampPhoto`)

Only applied to **camera captures** (not gallery). Applied at bottom 18% of image height.

**Stamp layout (left to right):**
- Row 1 (26% of SH): 📍 StoreName · Brand (bold, white, truncated with fitText)
- Row 2 (55% of SH): Lat/Long OR address (if no GPS)
- Row 3 (82% of SH): Date and time (en-IN locale, 12h)
- Right badge: GPS quality pill
  - `acc ≤ 50m` → green "GPS ±Xm"
  - `acc ≤ 200m` → amber "~Xm"
  - `acc > 200m` → red "Cell ~Xm"
  - No GPS → grey "No GPS"
- Semi-transparent navy overlay (`rgba(10,18,35,0.78)`)
- Orange accent line at top of stamp (0.4% of height)

Output: `jpeg` at `hd → 0.88` / `compressed → 0.82` quality

### 7.3 Photo Source Dialog

```
capPhoto(cb, forceSource?, quality?)
  ├─ forceSource='camera' → _doCapture directly
  ├─ forceSource='gallery' → _doCapture directly
  └─ null → show photo-src-dialog (Camera / Gallery buttons)

_doCapture(cb, src, quality)
  └── creates hidden <input type=file> (capture=environment if camera)
  └── compresses on change → stamps if camera → savePhotoToDevice → cb({_blob, _src})
  └── input removed after 8000ms
```

**Activity PWA pattern (two persistent body-level inputs):**
```html
<!-- Place AFTER any modal that calls openPhotoSrc() — DOM order = z-index -->
<input type="file" id="photo-camera-input" accept="image/*" capture="environment" style="display:none" onchange="onPhotoChosen(event)">
<input type="file" id="photo-gallery-input" accept="image/*" style="display:none" onchange="onPhotoChosen(event)">
```
```js
let pendingPhotoCallback = null;
function openPhotoSrc(cb)  { pendingPhotoCallback = cb; document.getElementById('photo-src-modal').classList.add('open'); }
function closePhotoSrc()   { document.getElementById('photo-src-modal').classList.remove('open'); }  // keeps callback
function cancelPhotoSrc()  { pendingPhotoCallback = null; closePhotoSrc(); }  // clears callback
function pickCamera()      { closePhotoSrc(); document.getElementById('photo-camera-input').value=''; document.getElementById('photo-camera-input').click(); }
function pickGallery()     { closePhotoSrc(); document.getElementById('photo-gallery-input').value=''; document.getElementById('photo-gallery-input').click(); }
async function onPhotoChosen(e) {
  const file = e.target.files[0];
  if (!file || !pendingPhotoCallback) return;
  const blob = await compressImage(file, quality, false);
  const preview = URL.createObjectURL(blob);
  const cb = pendingPhotoCallback; pendingPhotoCallback = null;
  cb({ blob, preview, file });
}
```
Key: `pickCamera/pickGallery` call `closePhotoSrc()` (preserves callback); Cancel button and backdrop call `cancelPhotoSrc()` (clears callback). Parent modal's close function must also call `cancelPhotoSrc()`.

### 7.4 Device Save (`savePhotoToDevice`)

Auto-triggers a download of every captured photo to device gallery/Downloads using `<a download>` + `.click()`. Silent fail via try/catch.

---

## 8. Store List Management

### 8.1 Upload Flow

```
handleStoreUpload(e)
  ├─ CSV: split by newline/comma, parse headers, map rows
  └─ XLSX: XLSX.utils.sheet_to_json (SheetJS v0.18.5)
  → parseStoreRow() → filter(name.trim) → showStorePreview()
  → confirmStorePreview()
    ├─ has brand column → mergeStores()
    └─ no brand → show brand-assign-dialog (chip selector) → confirmBrandAssign() → mergeStores()
```

### 8.2 Column Mapping (`parseStoreRow`)

| Field | Column aliases tried |
|---|---|
| name | sub dealer name, sub dealer, dealer name, store name, store, name |
| address | address, addr, location, area |
| mobile | contact detail, contact no., contact no, mobile no, mobile, phone, mob, contact |
| brand | **EXACT match only**: brand, brand name, brand_name, brandname |
| distName | dist name, distributor name, distributor, dist, territory, tsm, asm |
| city | city, district, town |
| sales | 2025 full year, 2025, full year sales, fy25, sales |
| proj | 2026 projection, 2026, projection, fy26, target |
| glowSign | glow sign, glow |
| inshop | inshop branding, inshop, in-shop, in shop |

**Note:** Brand column uses exact match intentionally — "Inshop Branding" partial match avoidance.

### 8.3 Deduplication (`dedupeStores`)

Key: `name.toLowerCase() + '__' + brand.toLowerCase() + '__' + address.toLowerCase().slice(0,30)`

On true duplicate: keeps first record, merges missing fields from second.

### 8.4 Auto-fetch on Init

On every startup (when online): fetches `DEFAULT_STORES_URL` and replaces local store cache silently.

---

## 9. GPS Capture

Two modes:

**Auto (on new recce start):**
`autoRequestGPS()` — `enableHighAccuracy:true, timeout:20000, maximumAge:60000`
- Updates `gpsCoords` silently
- Shows quality feedback in Settings geo-status div

**Manual (form step 1):**
`captureGPS()` — `enableHighAccuracy:true, timeout:10000`
- Shows "Getting location…" → "✓ lat, lng (±Xm)"
- Denied → error toast with Chrome instructions

Quality thresholds: ≤15m = Excellent, ≤50m = Good, ≤200m = Fair, >200m = Poor (cell tower)

---

## 10. Sync Architecture (3-Stage)

```
syncNow()
  ├─ offline → toast 'Offline'
  ├─ fetch all local subs → filter pending/failed
  ├─ also backfill: synced entries without gasAt
  │
  ├─ For each pending:
  │   ├─ fireGas(s) — no-cors POST to GAS URL (fire-and-forget)
  │   │   └── GAS: uploads photos to Drive, writes Google Sheet, generates Slides
  │   └─ syncToPostgres(s):
  │       ├─ Step 1: POST /slides-proxy → type:'save-photos'
  │       │   └── uploads all photos → returns {photoUrls: {front,inside,brandings,addl,doc}}
  │       ├─ Step 2: POST /slides-proxy → type:'save-view'
  │       │   └── generates rich HTML report → returns {url: view_url}
  │       └─ Step 3: POST /db/submissions (PostgREST upsert)
  │           └── stores metadata + photo URLs + view_url
  │
  ├─ On success: status='synced', syncedAt, viewUrl saved to IndexedDB
  └─ On failure: status='failed'
```

**Backfill:** Entries already synced (status='synced') but without `gasAt` → re-fired to GAS to catch older entries.

### PostgREST Schema (submissions table, recce schema)

```
sub_id, store_name, brand, visit_date, address, mobile, city,
has_branding, branding_count, has_video,
front_photos, inside_photos, addl_photos,
gps_lat, gps_lng,
submitted_by, submitted_at,
photo_urls (JSON), view_url, status,
meta: { brandings:[{type,w,h,qty,sqft}], videoNote, videoCmt }
```

### Google Slides Generation

`makeSlides(id)` (from submission card "📊 Slides" button):
- Requires `slides-gas-url` configured (admin-locked setting)
- POSTs to `/slides-proxy/` with `type:'recce'`, all photo groups as base64, store info
- On success: opens Google Slides URL in new tab, saves `entry.slideUrl`

### Rich HTML Report (`generateRecceReportHtml`)

Generated client-side for offline view, OR server-hosted after sync.
- Dark gradient hero: store name, address, brand, visit date, agent, mobile, GPS Maps link, submitted timestamp
- Photo grids: 1-col / 2-col / 3-col / auto layout based on count
- Branding items: number badge, type, dimensions spec row, 1:1 aspect photo
- Video section (text note if video present)
- Document preview (image with print-ready size)
- Print / Save as PDF button

---

## 11. Admin System

### Auto-unlock
`empId` from `lm360-session` is checked at page load. If it's in `_RECCE_ADMIN_IDS` → `_adminUnlocked = true` (no password needed).

### Password Verification (3-layer fallback)
1. Remote `admin-cfg-url` JSON: `{password:"plain"}` or `{hash:"sha256"}`
2. Local IndexedDB `admin-hash` (SHA-256 of password)
3. Hardcoded `DEFAULT_ADMIN_HASH`

Uses `crypto.subtle.digest('SHA-256', ...)` for all hashing.

### Admin Session
- Unlocked: `gas-locked/slides-gas-locked` div hidden, `gas-unlocked/slides-gas-unlocked` shown
- Auto-locks after 10 minutes

### Admin Home (when unlocked)
- `loadHomeAdmin()` replaces normal home view in-place (injects filter bar, changes stat labels, hides New Recce button)
- Fetches all 500 rows from PostgREST
- 4 filter controls + search input
- 4 grouping modes: Recent (date desc) / Agent / Brand / City
- Tap card → `openAdminRecce(subId)` → if `view_url` → new tab, else → `ardl-overlay` detail sheet

---

## 12. Draft System

### Save (debounced 500ms)
`saveDraft()` → saves `current` to draft store (form state, store selection, GPS, counts) → `saveDraftPhotos()` saves all photo blobs as base64 to `photos` key.

### Resume
`checkDraft()` → shows resume banner if draft has storeName
`resumeDraft()` → restores F, selectedStore/manualMode, gpsCoords, all photo arrays, navigates to form-store

### Exit Dialog (3 options)
- Save Draft & Exit → `saveDraft()` + navigate home
- Discard → clear all state + `dbDel('draft','current')` + navigate home
- Continue Recce → close dialog only

---

## 13. View / Report System

### Offline View (`viewRecce(id)`)
Generates an HTML report in-browser from local base64 data, opens in new tab via blob URL (revoked after 30 seconds).

### Server View
When `entry.viewUrl` exists → `window.open(viewUrl)` directly.

### Edit Mode (`editRecce(id)`)
- Loads entry from IndexedDB
- Restores all arrays from base64 blobs
- Sets `F.editingId = id`
- Reopens form at Step 1 in manual mode
- On re-submit: reuses same `id`, original `timestamp`, adds `editedAt`

---

## 14. Initialization (`init`)

```
init()
  ├─ detectHostBar() — checks hostname/gap for tiiny.host → sets --host-bar CSS var
  ├─ setLang(LANG) — applies stored language preference (en/hi)
  ├─ applyFontSize() — applies stored font-size level (0–4)
  ├─ checkAppUpdate() — compares CACHE_VER vs localStorage; shows update banner if changed
  ├─ initDB() — opens RecceDB v4
  ├─ loadSettings() — loads all cfg keys, restores stores, quality, brands, geo-stamp
  ├─ loadHome() — renders home (admin or field agent view)
  ├─ checkDraft() — shows resume banner if draft exists
  ├─ netStatus() — sets online/offline indicator
  ├─ addEventListener('online') → netStatus + syncNow
  ├─ addEventListener('offline') → netStatus
  ├─ injectHubButtons() — adds 🏡 Hub button to every .hdr
  ├─ sets today-date text
  ├─ updAvailTag() — updates store count display
  └─ auto-fetches stores.json from server (silent, online only)
```

---

## 15. Service Worker

Inline blob-registered (no external sw.js file):

```
Cache name: 'recce-v6'
Install: caches.add('/')
Activate: clients.claim()
Fetch: cache-first, network fallback, cache successful responses
       On network failure: return cached '/' (offline fallback)
       Skips non-GET requests
```

**Update detection:** Because the SW uses a blob URL (changes on every load), `updatefound` events are unreliable. Instead, use a version-string check against `localStorage`:
```js
const CACHE_VER = 'recce-v6';   // keep in sync with SW cache name
function checkAppUpdate() {
  const seen = localStorage.getItem('recce-ver-seen');
  localStorage.setItem('recce-ver-seen', CACHE_VER);
  if (seen && seen !== CACHE_VER) showUpdateBanner();
}
```
- First install: `seen` is null → no banner, stores version silently
- Same version reload: `seen === CACHE_VER` → no banner
- After release bump: mismatch → banner shown
- **Rule:** Always bump `CACHE_VER` and the SW cache name string together on every release

---

## 16. Utility Functions

| Function | Purpose |
|---|---|
| `compress(file, maxPx?, q?)` | Canvas-based JPEG compression, respects imgQuality global |
| `b64(blob)` | Promise → base64 data URL via FileReader |
| `fmtSz(bytes)` | Formats to B / KB / MB |
| `toast(msg, type)` | Fixed bottom toast, auto-hides after 3000ms. Types: info/success/error |
| `netStatus()` | Updates online/offline dot + text |
| `showView(n)` | Switches active view, scrolls to top |
| `calcSqft(w,h)` | `W×H sq.in = X.XX sq.ft` string |
| `getSD()` | Returns current store data from selectedStore or manual fields |
| `fitText(ctx, text, maxW)` | Truncates text with … to fit canvas width |
| `roundRectPath(ctx,x,y,w,h,r)` | Draws rounded rect path on canvas context |
| `populateBrandDropdown()` | Rebuilds f-brand select from brands array |
| `updAvailTag()` | Updates "X stores" count tags in Step 1 |

---

## 17. Known Code Notes

| Location | Note |
|---|---|
| Line 705–706 | `initDB` defined twice — v3 then v4. Second definition wins (JS hoisting not applicable; second `function` declaration executes last). |
| Line 909 | `_storeFiles.pus68(...)` — **typo bug FIXED**: corrected to `.push(...)`. Was silently dropping all store-file tracking entries after the first upload. |
| Lines 1705 | `brand-selector.scrollIntoView` on missing brand — requires `selectedStore` to have no brand column |
| `_doCapture` | `input.remove()` delayed 8000ms — if camera takes > 8s to return, input is detached from DOM (onchange still fires in most browsers) |
| Admin lock | Auto-lock after 10 min — not reset on activity, fixed timer from login |

---

## 18. Design Patterns Summary

| Pattern | Implementation |
|---|---|
| Single-page app | 7 `.view` divs, only one `.active` at a time |
| Offline-first | IndexedDB primary, server sync secondary |
| Optimistic local save | Submit saves locally first, syncs after |
| Progressive form | 5 steps with progress bar, back navigation always available |
| No router | Direct DOM manipulation, `showView()` |
| State reset | `startNewRecce()` resets all global arrays and form fields |
| Auto-draft | 500ms debounced on every field change |
| Responsive images | Canvas compression before any storage or upload |
| Two sync targets | GAS (fire-and-forget, no-cors) + PostgREST (awaited, primary) |
| Admin in-place | Admin view reuses home screen DOM, no separate route |
| Z-index stacking | 200→300→400→500→600→1000, each layer has dedicated purpose |
| Draft mode | `status TEXT DEFAULT 'submitted'` in DB; draft tile re-opens capture form pre-filled from `form_data`; separate Save Draft / Submit paths |
| GForm explicit-only | Never fire-and-forget GForm on submit; only via explicit user button; patch `gform_submitted=true` in DB on success |
| Delete gate | `canCapture && !r.gform_submitted` — delete allowed until GForm submitted; draft delete via separate button in capture form |
| GPS — non-blocking | Use `gpsLoc` not `location` (shadows `window.location`); 3s timeout; show "Getting location…" text; silent fail; capture only on final submit not draft |
| PostgREST schema reload | After any `ALTER TABLE` on a live DB, run `NOTIFY pgrst;` from psql — PostgREST reloads schema cache within seconds without restart |
| Modal DOM order | Same-z-index modals: later in DOM = on top. Place sub-pickers after parent modals |
| Bilingual static HTML | Wrap every visible string in `<span class="en-txt">...</span><span class="hi-txt">...</span>`; CSS hides one set based on `body.lang-hi` class |
| Bilingual dynamic JS | Use `t(en, hi)` helper inside template literals and `textContent` calls; avoids span injection into innerHTML (safer, no DOM parsing overhead) |
| Map var vs `t()` conflict | When using `BTYPE.map(t => ...)` alongside a `t()` translation function, rename the map parameter to `tp` or `typ` to avoid shadowing |
| App update banner | `CACHE_VER` constant compared to `localStorage('app-ver-seen')` on init; mismatch (not first install) → slides up `.upd-banner` from bottom; auto-dismiss 5s; manual OK; bilingual; z-index 550; bump both `CACHE_VER` and SW cache name together on every release |

---

## 19. Report Generation & Google Slides (GAS)

This section covers the full output pipeline: what is generated after a recce is submitted, how it reaches the two destinations (VPS server report + Google Slides), and the exact data structures involved.

---

### 19.1 Overview — Two Output Targets

When a submitted entry is synced (`syncNow()` triggers on submit if online, or on manual tap):

| Target | How fired | What is produced | Response handling |
|---|---|---|---|
| **VPS `/slides-proxy/`** | `fetch` (awaited, JSON) | Server-hosted HTML report + permanent photo URLs | Awaited — URLs stored in IndexedDB |
| **Google Apps Script (GAS)** | `fetch` (fire-and-forget, `mode:'no-cors'`) | Google Drive photos + Google Sheets row + Google Slides PPT | No response read — truly fire-and-forget |

Both run inside `syncNow()` for every `pending` or `failed` entry. A **backfill pass** also re-fires GAS for any `synced` entry that has no `gasAt` timestamp (entries that reached Postgres but never reached GAS).

---

### 19.2 Sync Flow Step by Step

```
submitRecce()
  ↓ save to IndexedDB (status: 'pending')
  ↓ if online → syncNow()

syncNow()
  ↓ load all pending/failed entries from IndexedDB
  for each entry:
    ├─ fireGas(entry)          ← fire-and-forget (no-cors), no await
    │    └─ POST full JSON to GAS_URL
    │         GAS does: Drive upload → Sheet row → Slides generation
    │
    └─ syncToPostgres(entry)   ← awaited
         ├─ Step 1: POST /slides-proxy  {type:'save-photos', subId, photos{front/inside/brandings/addl/doc in b64}}
         │           → returns { success:true, photoUrls:{front:[], inside:[], brandings:[], addl:[], doc:null} }
         │
         ├─ Step 1.5: POST /slides-proxy  {type:'make-slides', gasUrl, subId, storeInfo, photoGroups{VPS URLs}}
         │           → fire-and-forget inner async (no await on result)
         │
         ├─ Step 2: generateRecceReportHtml(entry, photoUrls)  ← client-side
         │           → returns complete HTML string
         │           POST /slides-proxy  {type:'save-view', subId, html}
         │           → returns { success:true, url:'https://…/recce/views/RC_….html' }
         │
         └─ Step 3: POST /db/submissions  (PostgREST upsert)
                    → stores all metadata + photoUrls + view_url in Postgres
                    → returns { ok:true, viewUrl }
```

On success: entry in IndexedDB updated to `status:'synced'`, `syncedAt` and `viewUrl` fields added.

---

### 19.3 GAS Payload — What is Sent

The **entire IndexedDB submission object** is POST-ed to the GAS endpoint:

```json
{
  "id": "RC_1715000000000_A1B2",
  "timestamp": "2025-05-06T09:30:00.000Z",
  "status": "pending",
  "submittedBy": "harish",
  "submittedByName": "Harish Kumar",
  "gasAt": "2025-05-06T09:30:05.000Z",
  "data": {
    "storeName": "Sood Electronics & Electricals",
    "brand": "LIEBHERR",
    "address": "Railway Road, Doraha",
    "mobile": "8968800019",
    "city": "",
    "date": "2025-05-06",
    "gpsCoords": { "lat": 30.7946, "lng": 76.0345, "acc": 12 },
    "frontPhotos":  [ { "b64": "data:image/jpeg;base64,/9j/...", "cmt": "" } ],
    "insidePhotos": [ { "b64": "data:image/jpeg;base64,...",     "cmt": "" } ],
    "hasVideo": false,
    "videoNote": null,
    "videoCmt": "",
    "brandings": [
      {
        "type": "Glow Sign Board",
        "w": "48", "h": "24", "qty": "2",
        "sqft": "16 sq.in = 8.00 sq.ft",
        "photoB64": "data:image/jpeg;base64,..."
      }
    ],
    "addlPhotos": [ { "b64": "...", "desc": "Corner view" } ],
    "docB64": "data:image/jpeg;base64,..."
  }
}
```

> **Note**: All photos are full base64 data URIs (including MIME prefix `data:image/jpeg;base64,...`). GAS is responsible for decoding these and saving to Google Drive. The payload can be large — a single recce with 6 photos at compressed quality is typically 2–5 MB.

---

### 19.4 GAS Configuration

| Item | Value / Location |
|---|---|
| Default GAS URL | `DEFAULT_GAS_URL` constant — hardcoded Google Apps Script exec URL |
| User-configurable URL | Settings screen → "GAS URL" input → saved as `gas-url` in cfg store |
| Runtime URL | `(await cfg('gas-url')) || DEFAULT_GAS_URL` — user value overrides default |
| Request mode | `mode:'no-cors'` — response is never readable; any HTTP status is swallowed |
| Error handling | `.catch(()=>{})` — failures are silent; there is no retry on GAS failure |
| GAS responsibilities | Photos → Google Drive folder, Row → Google Sheet, Slides → Google Slides PPT |

---

### 19.5 VPS Proxy — `/slides-proxy/` Endpoint

Used inside `syncToPostgres()`. Two call types, both are JSON POST:

#### Call 1 — Save Photos
```json
POST /slides-proxy
{
  "type": "save-photos",
  "subId": "RC_1715000000000_A1B2",
  "photos": {
    "front":    [ { "b64": "data:image/jpeg;base64,...", "cmt": "" } ],
    "inside":   [ { "b64": "data:image/jpeg;base64,...", "cmt": "" } ],
    "brandings":[ { "b64": "data:image/jpeg;base64,..." } ],
    "addl":     [ { "b64": "data:image/jpeg;base64,...", "desc": "Corner view" } ],
    "doc":      "data:image/jpeg;base64,..."
  }
}
```
Response:
```json
{ "success": true, "photoUrls": {
    "front":    ["https://…/recce/uploads/RC_…/front_0.jpg"],
    "inside":   ["https://…/recce/uploads/RC_…/inside_0.jpg"],
    "brandings":["https://…/recce/uploads/RC_…/brand_0.jpg"],
    "addl":     ["https://…/recce/uploads/RC_…/addl_0.jpg"],
    "doc":      "https://…/recce/uploads/RC_…/doc.jpg"
}}
```

#### Call 2 — Save HTML Report
```json
POST /slides-proxy
{
  "type": "save-view",
  "subId": "RC_1715000000000_A1B2",
  "html": "<!DOCTYPE html>…full report HTML string…"
}
```
Response:
```json
{ "success": true, "url": "https://srv….hstgr.cloud/recce/views/RC_….html" }
```
This `url` becomes `entry.viewUrl` — the permanent shareable link.

---

### 19.6 HTML Report — Rich Server Version (`generateRecceReportHtml`)

Generated **client-side** using permanent VPS photo URLs, then saved server-side.

#### Layout Structure

```
┌─────────────────────────────────────────┐
│  HERO (gradient navy → dark blue)        │
│  ┌─────────────────────────────────────┐│
│  │ 📍 Branding Recce Report  [badge]   ││
│  │ Store Name (h1)                     ││
│  │ Address (muted)                     ││
│  │ ┌──────┐┌──────┐┌──────┐┌──────┐   ││
│  │ │Brand ││Visit ││Agent ││Mobile│   ││
│  │ └──────┘└──────┘└──────┘└──────┘   ││
│  │ [GPS → Maps] [Submitted timestamp]  ││
│  └─────────────────────────────────────┘│
├──────── SECTION: Front View Photos ──────┤
├──────── SECTION: Inside View Photos ─────┤
├──────── SECTION: Video Recording ────────┤  (conditional)
├──────── SECTION: Branding Items ─────────┤  (conditional)
├──────── SECTION: Additional Photos ──────┤  (conditional)
├──────── SECTION: Submission Document ────┤  (conditional)
│  [🖨 Print / Save as PDF]  [Close]       │
└─────────────────────────────────────────┘
```

#### Photo Grid System

| Photo count | CSS class | Grid layout |
|---|---|---|
| 1 | `pg1` | 1 column, max-width 480px |
| 2 | `pg2` | 2 equal columns |
| 3–6 | `pg3` | 3 columns |
| 7+ | `pgn` | Auto-fill, minmax(160px, 1fr) |

Each photo cell (`pi`): aspect-ratio 4:3, border-radius 8px, hover zoom effect, caption below in italic if present.

#### Branding Item Row Layout

```
┌────────────────────────────────────┬──────────────┐
│ [1]  Glow Sign Board               │  [Photo 1:1] │
│ 48 × 24 in  · Qty 2  · 8.00 sq.ft │              │
└────────────────────────────────────┴──────────────┘
```
Grid: `1fr 170px`. Photo aspect-ratio 1:1 (square). Falls back to "No photo" placeholder if photo missing.

#### Hero Meta Grid

Auto-fill grid of "hm" tiles (minmax 148px). Shown tiles:

| Tile | Always shown | Conditional |
|---|---|---|
| Brand | ✓ | |
| Visit Date | ✓ | |
| Field Agent | ✓ | |
| Store Mobile | | only if `d.mobile` is truthy |
| GPS Location | | only if `d.gpsCoords` exists → clickable Maps link |
| Submitted | ✓ | timestamp in `en-IN` locale |

#### Report CSS Notes

- **Self-contained**: all CSS is inline `<style>` — no external dependencies
- **Responsive**: `@media(max-width:600px)` — 3-col grid drops to 2-col, branding goes single-column
- **Print-ready**: `@media print` — white background, no box-shadows, `page-break-inside:avoid` on sections, print button hidden

---

### 19.7 HTML Report — Offline Fallback Version (`viewRecce`)

When `entry.viewUrl` is **null** (entry not yet synced, or sync failed), `viewRecce()` generates a **simpler offline report** from base64 blobs stored in IndexedDB.

| Aspect | Server report | Offline fallback |
|---|---|---|
| Source | Permanent VPS URLs | `URL.createObjectURL` from IndexedDB base64 blobs |
| CSS | Full design system | Minimal inline styles |
| Branding items | Grid with specs + photo | Single-column with inline image |
| Photo grid | Responsive grid class system | `auto-fill minmax(140px,1fr)` |
| GPS | Maps link | Coordinates text only |
| Persistence | URL is permanent, shareable | Blob URLs revoked after 30 seconds |
| Open method | `window.open(entry.viewUrl, '_blank')` | `window.open(blobUrl, '_blank')` |

`b64ToUrl()` helper (local to `viewRecce`): converts `data:image/jpeg;base64,...` string → fetch → Blob → `createObjectURL`. All conversions run in parallel via `Promise.all`.

---

### 19.8 `viewRecce()` Decision Logic

```
viewRecce(id)
  ↓ load entry from IndexedDB
  ↓ if entry.viewUrl exists
      → window.open(entry.viewUrl, '_blank')   ← server report
  ↓ else
      → convert all b64 photos to blob URLs
      → build offline HTML string
      → Blob → createObjectURL
      → window.open(blobUrl, '_blank')
      → setTimeout(revokeObjectURL, 30000)
```

---

### 19.9 PostgREST — What is Stored

Table: `recce.submissions` (upsert key: `sub_id`, conflict resolution: `merge-duplicates`)

| Column | Type | Source |
|---|---|---|
| `sub_id` | text PK | `entry.id` (`RC_…`) |
| `store_name` | text | `d.storeName` |
| `brand` | text | `d.brand` |
| `visit_date` | date | `d.date` |
| `address` | text | `d.address` |
| `mobile` | text | `d.mobile` |
| `city` | text | `d.city` |
| `has_branding` | bool | `d.brandings.length > 0` |
| `branding_count` | int | `d.brandings.length` |
| `has_video` | bool | `d.hasVideo` |
| `front_photos` | int | count |
| `inside_photos` | int | count |
| `addl_photos` | int | count |
| `gps_lat` | float | `d.gpsCoords.lat` |
| `gps_lng` | float | `d.gpsCoords.lng` |
| `submitted_by` | text | `hub.empId` (from `lm360-session`) |
| `submitted_at` | timestamptz | `entry.timestamp` |
| `photo_urls` | jsonb | full `photoUrls` object from Step 1 |
| `view_url` | text | permanent report URL from Step 2 |
| `meta` | jsonb | `{ brandings:[{type,w,h,qty,sqft}], videoNote, videoCmt }` |
| `status` | text | `'submitted'` |

> **Note**: Individual photo base64 blobs are **not** stored in Postgres — only the permanent VPS URLs (`photo_urls` jsonb). Base64 is only held in IndexedDB locally and in the GAS payload.

---

### 19.10 Entry Lifecycle (all status fields)

| Field | When set | Value |
|---|---|---|
| `status` | On local save | `'pending'` |
| `status` | After successful Postgres sync | `'synced'` |
| `status` | After Postgres sync failure | `'failed'` |
| `syncedAt` | On Postgres success | ISO timestamp |
| `gasAt` | When GAS is fired | ISO timestamp |
| `viewUrl` | On Step 2 success | permanent URL string |
| `editedAt` | On edit + re-submit | ISO timestamp (replaces original timestamp) |

An entry can be `synced` (reached Postgres) but have no `gasAt` (GAS never fired). The backfill pass in `syncNow()` catches these and re-fires GAS.

---

### 19.11 Submission ID Format

```
RC_{Date.now()}_{Math.random().toString(36).slice(2,6).toUpperCase()}

Example:  RC_1715000000000_A1B2
```

- Prefix: `RC_`
- Epoch ms timestamp (13 digits)
- Underscore
- 4-char alphanumeric random suffix (uppercase)

On edit (`F.editingId` set): the original `id` is reused — the upsert in Postgres replaces the existing row via `merge-duplicates`.

---

### 19.12 `_recceHubUser()` — Agent Identity

Reads the active hub session from `localStorage`:
```js
JSON.parse(localStorage.getItem('lm360-session') || '{}')
```
Returns: `{ empId, name, loginAt, ... }` (same session object written by `/hub/` on login).

Used in:
- `submitRecce()` → sets `submittedBy` and `submittedByName` on the entry
- `syncToPostgres()` → writes `submitted_by` to Postgres
- `loadHome()` → filters server rows to show only current agent's submissions

---

### 19.13 Two GAS Endpoints — Key Distinction

| Config key | Settings label | Fired by | Mechanism | Payload | Purpose |
|---|---|---|---|---|---|
| `gas-url` | "Sheet Sync" | `fireGas(entry)` inside `syncNow()` | `mode:'no-cors'` POST, no await | Full IndexedDB entry with base64 photos | Uploads photos to Drive + appends row to Google Sheet |
| `slides-gas-url` | "Slides Generator" | `makeSlides(id)` (manual 📊 button) + Step 1.5 in `syncToPostgres()` | Awaited POST via `/slides-proxy` intermediary | VPS photo URLs + store info (no base64) | Creates Google Slides PPT, returns `slideUrl` |

**Legacy vs current:** `gas-url` sends raw base64 — GAS decodes and saves. `slides-gas-url` receives permanent VPS URLs — GAS just fetches them. Use `slides-gas-url` for new PWAs (lighter GAS, reliable).

Both URLs are admin-locked. Runtime selection: `(await cfg('slides-gas-url')) || (await cfg('gas-url')) || DEFAULT_GAS_URL`.

---

### 19.14 Google Sheet — 107-Column Layout

**Sheet ID:** `1qRbmD-MuCkGHpas0F_4Klecu7NUVH6sfS2TIrTbUc8I`
**Drive parent folder ID:** `1uAF07gJ5a7dNGpxsmfEWDAV05WHwYsg2` → creates subfolder "Branding Recce Photos" → per-submission subfolder named by `entry.id`

| Cols | Count | Content |
|---|---|---|
| 1–3 | 3 | `sub_id` · `syncedAt` · `editedAt` |
| 4–10 | 7 | `date` · `brand` · `storeName` · `address` · `mobile` · `gps_lat` · `gps_lng` |
| 11–22 | 12 | Front photo 1–3 (Drive URL + comment each) · Inside photo 1–3 (Drive URL + comment each) |
| 23–25 | 3 | `hasVideo` (Yes/No) · `videoNote` · `videoCmt` |
| 26–27 | 2 | `hasBranding` (Yes/No) · `branding_count` |
| 28–87 | 60 | 10 branding slots × 6 cols: `type` · `w` · `h` · `qty` · `sqft` · Drive URL |
| 88 | 1 | Submission doc Drive URL |
| 89–104 | 16 | 8 additional photos × 2 cols: Drive URL · description |
| 105 | 1 | Sync status (`synced`) |
| 106–107 | 2 | Document Studio auto-fill (left empty by GAS) |

**For new PWA:** Design column headers first, update `SS_ID` and `PARENT_ID` in `gas_slides.gs`, build the `row` array to match. Keep typed-item blocks as N-col groups (e.g. 6 cols per item) for Document Studio compatibility.

**Photo sharing:** Each file uploaded to Drive is set `ANYONE_WITH_LINK, VIEW` so Document Studio can embed it.

---

### 19.15 slides_proxy.py — Request Type Matrix

Python proxy at port `8768`. Five `type` values dispatched by `data.type`:

| `type` | Called by | Action |
|---|---|---|
| `save-photos` | `syncToPostgres()` Step 1 | Decodes base64 → saves to `/recce/{subId}/` on disk → returns `photoUrls` object |
| `save-view` | `syncToPostgres()` Step 2 | Saves HTML string to `/recce/views/{subId}.html` → returns permanent URL |
| `make-slides` | `syncToPostgres()` Step 1.5 (fire-and-forget inner async) | Forwards `storeInfo` + `photoGroups` (with VPS URLs) to GAS `makeRecceSlides` |
| `recce` | `makeSlides(id)` manual 📊 button | Saves base64 photos + calls GAS `makeRecceSlides` in one round trip |
| `installation` | Installation PWA | Calls GAS `makeInstallationSlides` with `job` + `counters` payload |

**For new PWA:** Add a new `type` handler in `slides_proxy.py` and a matching `action` in `gas_slides.gs`.

---

### 19.16 GAS Script — Action Routing (`gas_slides.gs`)

```
doPost(e)
  data.action = ?
  ├─ 'makeRecceSlides'
  │     Input: { storeInfo:{storeName,brand,date,address,mobile,gpsCoords},
  │              photoGroups:{front:[{url,cmt}], inside:[{url,cmt}],
  │                           addl:[{url,cmt}], brandings:[{url,meta:{type,w,h,qty,sqft}}]} }
  │     Output: { slideUrl: 'https://docs.google.com/presentation/d/…' }
  │
  ├─ 'makeInstallationSlides'
  │     Input: { job:{client,tour}, counters:[{counter_name,status,city,state,
  │                                            item_type,size,qty,remarks_inst,slide_number}] }
  │     Output: { slideUrl: '…' }
  │
  └─ 'syncSheet'
        Input: { submission: <full IndexedDB entry with base64 photos> }
        Action: uploads all photos to Drive folder → appends 107-col row to Sheet
        Output: { success:true, sheetUrl: '…' }
```

All created Slides files are shared `ANYONE_WITH_LINK, VIEW` before returning.

---

### 19.17 Google Slides — Recce Layout Template

Canvas: 720 × 405 (16:9). All text via `addText(slide, text, x, y, w, h, {size, color, bold, align})`. All images via `insertImg(slide, url, x, y, w, h)` (fetches via `UrlFetchApp`).

| Slide | Background | Key elements |
|---|---|---|
| **1 — Title** | `#0f172a` | Brand 13 px orange bold (y=20) · Store name 36 px white bold (y=55) · Address/mobile/date 13 px grey (y=160) · GPS green 11 px (y=275) |
| **Photo group slides** | `#1e293b` | Section label orange 11 px bold · 2 photos per slide at (x=20,y=42,340×280) and (x=360,y=42,340×280) · comment 9 px grey below each |
| **Branding header** | `#0f172a` | "Existing Branding" 30 px orange centered (y=130) · item count 15 px grey (y=210) |
| **Branding item slides** | `#1e293b` | "Branding N: Type" 12 px orange bold · dims/qty/sqft 13 px white · photo centered 175,80,370×270 |

Photo groups rendered in order: front → inside → addl → brandings. Each group only if `photos.length > 0`.

---

### 19.18 Google Slides — Installation Layout Template

Dark theme: `#0a0a0a` (title) / `#161616` (content). Accent: `#FF6B35`.

| Slide | Content |
|---|---|
| **1 — Title** | "INSTALLATION REPORT" 11 px orange · Client 34 px white bold · Tour 18 px orange · N counters / N ready / N pending stats · Date |
| **2 — Status Summary** | One row per status: label (15 px white) + count right-aligned (20 px orange bold) |
| **Counter slides** | 3 counters per slide · Per counter: name bold white, location grey, specs white, remarks grey, status badge right (colored) · thin divider line between counters |

Status colors: ready=`#2ecc71` · recce_pending=`#f39c12` · mockup_pending=`#3498db` · unclear=`#888888`

**Proxy call:** `POST /slides-proxy` with `{type:'installation', gasUrl, job:{client,tour}, counters:[…]}`.

---

### 19.19 New PWA — Google Sheet + Slides Setup Checklist

1. Design sheet columns (decide entity fields + photo groups + item blocks)
2. Create Google Sheet → note **Sheet ID** from URL
3. Create Drive parent folder → note **Folder ID**
4. In `gas_slides.gs`: update `SS_ID`, `PARENT_ID`; add new `action` handler if needed
5. In `slides_proxy.py`: add new `type` handler if needed; update `RECCE_DIR` / `WEB_BASE`
6. In PWA settings view: add two admin-locked URL inputs → `gas-url` + `slides-gas-url`
7. Add to IndexedDB cfg keys: `gas-url`, `slides-gas-url`
8. **Sheet sync pattern** (`fireGas`): `fetch(gasUrl, {method:'POST', mode:'no-cors', body: JSON.stringify(entry)})`  — no-cors, no response check, silent `.catch(()=>{})`
9. **Slides pattern** (`makeSlides`): `fetch('/slides-proxy', {method:'POST', body: JSON.stringify({type:'recce', gasUrl, subId, storeInfo, photoGroups})})` → await → `result.slideUrl` → `window.open`
10. Re-deploy GAS after any script change → copy new exec URL → update PWA settings

---

## 20. Activity PWA — Response Capture Patterns

Patterns from `/activity/` PWA that apply to any multi-user field data collection app.

### 20.1 Response Status Lifecycle

```
Draft → Submitted → GForm submitted
  ↑           ↑            ↑
  saveDraft()  submitResponse()  resubmitGForm()
  (no GPS,     (GPS captured,    (explicit button,
  no GForm)    no auto-GForm)    patches DB)
```

DB column: `status TEXT NOT NULL DEFAULT 'submitted'`
- `'draft'` — partial entry, editable, deletable
- `'submitted'` — finalized, photo-editable, deletable until GForm submitted
- Once `gform_submitted = true` — immutable (no delete, no field edit)

### 20.2 Draft Mode — Implementation

```js
let editingDraftId = null;   // null = new response, number = editing draft
let draftExistingPhotos = []; // already-uploaded photos for the draft
let draftDeletedPhotoIds = []; // IDs to DELETE on next save/submit

// Reset on opening a new capture:
photos = []; editingDraftId = null; draftExistingPhotos = []; draftDeletedPhotoIds = [];

// Opening a draft:
async function openDraft(id) {
  const r = currentResponses.find(x => x.id === id);
  r._photos = await fetchResponsePhotos(id);
  currentResponse = r; editingDraftId = id;
  draftExistingPhotos = r._photos || []; draftDeletedPhotoIds = []; photos = [];
  showCaptureForm(); // buildCaptureForm() reads editingDraftId to pre-fill
}

// buildCaptureForm() — pre-fill when isDraft:
const draftFd = isDraft ? (currentResponse?.form_data || {}) : {};
// For each field: set value="${esc(draftFd[f.key] || '')}"
// For select: match option value to set selected
// Show existing-photos strip (non-editable thumbnails with delete toggles)
// Show "Delete Draft" button; hide for new responses
```

### 20.3 GPS Location Capture

```js
// IMPORTANT: do NOT use `let location` — shadows window.location
let gpsLoc = null;
if (navigator.geolocation) {
  btn.textContent = 'Getting location…';
  await new Promise(res => navigator.geolocation.getCurrentPosition(
    p => { gpsLoc = `${p.coords.latitude.toFixed(5)},${p.coords.longitude.toFixed(5)}`; res(); },
    () => res(),  // silent fail
    { timeout: 3000, maximumAge: 300000 }
  ));
}
// Store as `location TEXT` in responses table
// Show on tile: <a href="https://maps.google.com/?q=${r.location}" target="_blank">📍</a>
```
Capture only on final submit (`status='submitted'`), not on draft save.

### 20.4 Response Tile Info Density

Each response card should show (in order of usefulness):
1. **Name + Company** — primary identity (`form_data.person_name`, `form_data.company_name`)
2. **Status badge** — `📝 Draft` (indigo) only when `status==='draft'`
3. **Lead type badge** — Hot/Warm/Cold (only on submitted)
4. **Photo count** — `📸 N` (only when `photo_count > 0`)
5. **GForm badge** — `✓ GForm` (green) / `⚠ GForm` (amber) — only on submitted when activity has gform_url
6. **Location link** — `📍` → Google Maps (only when `r.location` set)
7. **Captured by** — `by {captured_by}` (small, muted)
8. **Date** (right side) + mobile (right side)

Click behavior: `status==='draft'` → `openDraft(id)`, else → `openPresentation(id)`

### 20.5 Delete Policy

| Response state | Who can delete | How |
|---|---|---|
| Draft | `canCapture` | "🗑️ Delete Draft" button in capture form (draft-edit mode) |
| Submitted, GForm pending | `canCapture` | "🗑️ Delete Response" in presentation view |
| Submitted, GForm done | Nobody | Delete button hidden |

Both use tap-twice confirm pattern (3s window):
```js
let _delConfirmPending = false;
function confirmDelete() {
  if (!_delConfirmPending) {
    _delConfirmPending = true;
    btn.textContent = '⚠️ Tap again to confirm';
    setTimeout(() => { _delConfirmPending = false; btn.textContent = '🗑️ Delete Response'; }, 3000);
  } else { deleteResponse(); }
}
```

### 20.6 PostgREST — Schema Changes on Live DB

When adding columns to a table that PostgREST is already serving:

```sql
ALTER TABLE schema.table ADD COLUMN IF NOT EXISTS col_name TEXT;
-- Then immediately:
NOTIFY pgrst;
```

PostgREST listens on the `pgrst` channel and reloads its schema cache within ~1s. Without this, it returns HTTP 400 for any request that includes the new column name (it doesn't know the column exists).

Run from psql: `docker exec postgres psql -U lmadmin -d lm360 -c "NOTIFY pgrst;"`

### 20.7 Role System (canCapture pattern)

```js
// In buildPresentation() and buildActivityDetail():
const myRole = getMyRole(currentActivity);       // from activity_team or ADMIN_IDS
const isAdmin   = myRole === 'admin';
const canCapture = myRole === 'admin' || myRole === 'capture'
                || (session.type === 'client' && session.role === 'capture');

// Gate visibility:
editBtn.style.display         = canCapture ? '' : 'none';           // photo edit
delBtn shown                  = canCapture && !r.gform_submitted;    // delete
manageBtn.style.display       = isAdmin ? '' : 'none';              // team manage
```

---

## 21. Bilingual (EN / HI) Pattern

Used in: Recce PWA. Apply to any PWA that needs an EN/HI language toggle.

### 21.1 CSS Toggle Mechanism

```css
.hi-txt { display: none }
body.lang-hi .en-txt { display: none }
body.lang-hi .hi-txt { display: inline }
```

Applied at the `<body>` level — one class switch flips every bilingual pair on the whole page simultaneously.

### 21.2 Static HTML — Wrap Both Languages in Spans

```html
<span class="en-txt">Submit Recce ✓</span><span class="hi-txt">रेकी सबमिट करें ✓</span>
```

Apply to: button labels, card titles, field labels, nav items, dialog text, badge words, status messages, empty-state text, header titles, placeholder-adjacent labels (not the placeholder attribute itself).

### 21.3 JS State, Toggle Function, and Translation Helper

```js
let LANG = localStorage.getItem('recce-lang') || 'en';

function setLang(l) {
  LANG = l;
  localStorage.setItem('recce-lang', l);
  document.body.classList.toggle('lang-hi', l === 'hi');
}

// Used in dynamic HTML generation and textContent assignments
function t(en, hi) { return LANG === 'hi' ? hi : en; }
```

Language toggle button (header):
```html
<button class="lang-btn" onclick="setLang(LANG==='en'?'hi':'en')">EN | हि</button>
```

On init, call `setLang(LANG)` before rendering anything — ensures `body.lang-hi` is applied before first paint.

### 21.4 Dynamic JS — Use `t()` in Template Literals

Whenever HTML is built in JS (via `innerHTML`, `insertAdjacentHTML`), use `t()` instead of injecting `.en-txt`/`.hi-txt` spans. The spans approach requires the browser to parse and hide elements; `t()` inserts only the correct language string from the start.

```js
// ✅ Correct — template literal with t()
cont.innerHTML = brandings.map((b, i) => `
  <div class="bitem-title">${t('Branding', 'ब्रांडिंग')} ${i + 1}</div>
  <label>${t('Type', 'प्रकार')} <span class="req">*</span></label>
  <div class="pslot-lbl">${t('Tap to capture', 'दबाकर कैप्चर करें')}</div>
`).join('');

// ✅ Correct — textContent assignment
el.textContent = t('Enter W × H', 'W × H दर्ज करें');

// ✅ Correct — toast messages
toast(t('Max 10 items', 'अधिकतम 10 आइटम'), 'error');

// ✅ Correct — dialog titles set via JS
showBDialog(
  t('Add reference photos?', 'अतिरिक्त फोटो जोड़ें?'),
  t('Optional — for future reference.', 'वैकल्पिक — भविष्य के लिए।'),
  callback
);
```

### 21.5 Map Variable Name Conflict

If your code has both a `t()` translation function and an array `.map(t => ...)`, rename the map parameter to avoid shadowing:

```js
// ❌ Shadows t() — chip onclick will call the map parameter, not translate
BTYPE.map(t => `<div onclick="setType('${t}')">${t}</div>`)

// ✅ Rename map parameter
BTYPE.map(tp => `<div onclick="setType('${tp}')">${tp}</div>`)
```

Same issue applies to any other `.map()`, `.filter()`, `.forEach()` that uses `t` as the parameter name.

### 21.6 Bilingual Coverage Checklist (per screen)

Before marking a screen bilingual-complete, verify each of these:

- [ ] Header title + step subtitle (static HTML)
- [ ] All card section titles
- [ ] All field labels + required markers text
- [ ] All button labels — primary, secondary, back, outline, destructive
- [ ] All chip and radio button labels
- [ ] Empty state messages (`empty-txt`)
- [ ] Toast messages → use `t()` in all `toast(...)` calls
- [ ] Dialog title + subtitle → use `t()` in `showDialog(...)` call, not static HTML (since `textContent` assignment replaces spans)
- [ ] Dynamically rendered list/item labels → use `t()` in template literals
- [ ] Badge / counter text that contains words (not bare numbers)
- [ ] Status row text (network status, sync status, last sync)
- [ ] Settings section headings + descriptions (lower priority — admin-facing)

### 21.7 Language Button Styling

```css
.lang-btn {
  background: none;
  border: 1px solid rgba(255,255,255,.3);
  color: rgba(255,255,255,.8);
  font-size: 12px; font-weight: 700;
  padding: 4px 10px;
  border-radius: 20px;
  cursor: pointer;
  display: flex; align-items: center; gap: 3px;
  white-space: nowrap; flex-shrink: 0;
}
.lang-btn:active { background: rgba(255,255,255,.15) }
```

Place in header hero row alongside Hub button and settings gear.

---

## 22. App Update Banner Pattern

Non-disruptive notification shown once per app version upgrade. Works with blob-registered service workers (where `updatefound` events are unreliable).

### 22.1 How It Works

```
On init:
  seen = localStorage.getItem('app-ver-seen')
  localStorage.setItem('app-ver-seen', CACHE_VER)
  if (seen && seen !== CACHE_VER) → show banner
  (no banner on first install, no banner on same-version reload)
```

### 22.2 CSS

```css
.upd-banner {
  position: fixed;
  bottom: calc(var(--bnav-h) + var(--host-bar) + 10px);
  left: 12px; right: 12px;
  background: #16a34a; color: #fff;
  padding: 11px 14px;
  border-radius: var(--r);
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  z-index: 550;
  box-shadow: 0 4px 16px rgba(0,0,0,.18);
  transform: translateY(120px); opacity: 0;
  transition: transform .35s ease, opacity .35s ease;
  pointer-events: none;
}
.upd-banner.show { transform: translateY(0); opacity: 1; pointer-events: auto }
```

### 22.3 HTML

```html
<div class="upd-banner" id="upd-banner">
  <span style="font-size:13px;font-weight:600">
    <span class="en-txt">✓ App updated to latest version</span>
    <span class="hi-txt">✓ ऐप नया संस्करण अपडेट हुआ</span>
  </span>
  <button class="upd-banner-ok" onclick="dismissUpdateBanner()">
    <span class="en-txt">OK</span><span class="hi-txt">ठीक है</span>
  </button>
</div>
```

### 22.4 JS

```js
const CACHE_VER = 'app-v6';   // increment on every release, same as SW cache name

function checkAppUpdate() {
  const seen = localStorage.getItem('recce-ver-seen');
  localStorage.setItem('recce-ver-seen', CACHE_VER);
  if (seen && seen !== CACHE_VER) {
    const b = document.getElementById('upd-banner');
    if (b) { b.classList.add('show'); setTimeout(() => b.classList.remove('show'), 5000); }
  }
}
function dismissUpdateBanner() {
  document.getElementById('upd-banner')?.classList.remove('show');
}
```

Call `checkAppUpdate()` early in `init()`, after `setLang()` / `applyFontSize()` and before `initDB()`.

### 22.5 Release Checklist

When shipping a new version of any PWA with this pattern:
1. Increment SW cache name: `'recce-v5'` → `'recce-v6'`
2. Increment `CACHE_VER` to match: `'recce-v6'`
3. Both must be the same string — the banner fires on mismatch between `CACHE_VER` and the stored seen value

---

## 23. Client Portal Session Bridge Pattern

Pattern for cross-PWA authentication when a login portal (e.g. `/client/`) needs to hand off a session to a destination PWA (e.g. `/activity/`) without the destination needing its own login form.

### 23.1 Problem

Activity PWA needs to show activity data to clients. But the Activity PWA's own login was simplified to redirect-only. The client logs in at `/client/` instead — so how does Activity PWA recognise the client after login?

**Answer:** Both PWAs are on the same origin (`srv1111289.hstgr.cloud`), so they share `localStorage`. `/client/` writes the exact session key that `/activity/` reads, then redirects there. No tokens, no server session — plain localStorage sharing.

### 23.2 Session Keys Involved

| Key | Written by | Read by | Format | TTL |
|-----|-----------|---------|--------|-----|
| `lm360-activity-session` | Activity PWA login (old) · `/client/` company-code login (new) | Activity PWA `restoreSession()` | `{id:'client:X', name, company, phone, role, type:'client', activityId, clientAccessId, ts}` | 8 hours |
| `lm360-client-session` | `/client/` PIN login | `/client/` `restoreSession()` | `{id, displayName, contactName, loginAt}` | 30 days |
| `lm360-session` | Hub PWA | Activity PWA `restoreSession()` | `{empId, name, role, loginAt}` | 12 hours |

### 23.3 Login Flow (Company Code → Activity)

```
User opens /activity/ (no session)
  └── restoreSession() finds nothing
  └── shows redirect screen

User taps "Client Access" button
  └── browser navigates to /client/?next=/activity/

/client/ company-code login succeeds
  └── loginWithCode() writes lm360-activity-session with {id:'client:X', ..., ts:Date.now()}
  └── reads ?next= param from URL
  └── window.location.href = '/activity/'   (or ?next= value)

/activity/ loads
  └── restoreSession() finds lm360-activity-session (written seconds ago, within 8h TTL)
  └── session = parsed object (type:'client')
  └── showHome() → loadActivities() → fetches activityId from session
```

### 23.4 ?next= Redirect Pattern

Any portal PWA that can send users to another PWA after login should support `?next=`:

```js
// After successful login, before navigating home:
const next = new URLSearchParams(location.search).get('next');
if (next && next.startsWith('/')) {   // safety: only allow relative paths
  window.location.href = next;
  return;
}
await enterClientHome();   // default: show portal home
```

**Security note:** Always validate `next` starts with `/` to prevent open redirects to external URLs.

### 23.5 Redirect-Only Login Screen Pattern

When a PWA's login is delegated to another PWA, replace the login form with redirect buttons:

```html
<div id="scr-login" class="scr active">
  <div class="login-box">
    <div class="login-logo">...</div>
    <p style="text-align:center;color:var(--muted);font-size:.85rem;margin-bottom:24px">Sign in to continue</p>
    <a href="/hub/"
       style="display:block;text-align:center;padding:14px;background:var(--teal);color:#fff;
              border-radius:var(--radius);font-size:1rem;font-weight:600;text-decoration:none;margin-bottom:12px">
      👤 Employee Login
    </a>
    <a href="/client/?next=/activity/"
       style="display:block;text-align:center;padding:14px;background:var(--bg2);color:var(--text);
              border:1px solid var(--border);border-radius:var(--radius);font-size:1rem;font-weight:600;text-decoration:none">
      🏢 Client Access
    </a>
  </div>
</div>
```

`restoreSession()` still auto-logins from hub session (`lm360-session`) and activity session (`lm360-activity-session`) — no changes needed to session restore logic.

### 23.6 Two Parallel Client Systems (360LM specific)

The 360LM platform has two separate client identity systems that must NOT be confused:

| System | Tables | Auth | Session key | Used by |
|--------|--------|------|-------------|---------|
| **Portal clients** | `client.accounts` + `client.access_grants` | ID + 6-digit PIN | `lm360-client-session` | `/client/` home, responses viewer |
| **Activity clients** | `activity.companies` + `activity.client_access` | company code + individual access code | `lm360-activity-session` (type:'client') | `/activity/` |

Portal clients see responses inside `/client/` (no need to navigate to `/activity/`).
Activity clients (company-code) get redirected to `/activity/` with a bridged session.

These two systems are intentionally separate and serve different use cases. The company-code system is lighter (no account creation, just access codes distributed by admin).

---

## 24. Admin PWA — Extending with New Management Sections

Pattern for adding a new management section to `/admin/` without breaking existing structure.

### 24.1 Existing Admin PWA Structure

- PIN auth (harish/pramod only) via `verify_pin` RPC
- Home screen: Company tile + Employee tile
- Routes via `showSection(name)` or equivalent screen switcher
- All admin actions guarded by `ADMIN_IDS = ['harish','pramod']`

### 24.2 Adding a New Section (e.g. `/admin/client`)

1. Add a new tile to the Admin home screen
2. Add new screen `<div id="screen-xxx">` with back → admin home
3. New section fetches from whatever DB schema it manages
4. Keep the same PIN session guard — if `adminSession` is null, redirect to login

### 24.3 Client Management Section (planned)

Target: move company + client_access management out of Activity PWA's Manage screen into `/admin/`.

DB targets:
- `activity.companies` — CRUD (name, code, active)
- `activity.client_access` — list by company; add/revoke per person
- `client.accounts` + `client.access_grants` — same as existing Admin Client screen but in dedicated section

API headers needed:
```js
const ACT_H  = {'Content-Type':'application/json','Accept-Profile':'activity','Content-Profile':'activity','Prefer':'return=representation'};
const ACT_RD = {'Content-Type':'application/json','Accept-Profile':'activity'};
const CL_H   = {'Content-Type':'application/json','Accept-Profile':'client','Content-Profile':'client','Prefer':'return=representation'};
```
