> Part of the PWA DevGuide (split from pwa_dev_style.md on 2026-07-02 — see that file for the index; ADR-099).

## 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
```

---

