# ADR-052: Learning View Tracking Uses Two-RPC Beacon (start_view / end_view) — Not Single Write

## Status

Accepted, 2026-06-22.

## Status History

```yaml
status_history:
  - date: 2026-06-22
    status: Proposed
    changed_by: hkl
    reason: Formalising two-phase beacon design for learn.view_event tracking
    changed_via: adr-kit (360lm)
  - date: 2026-06-22
    status: Accepted
    changed_by: hkl
    reason: start_view / end_view RPCs live; partial watches recorded; completion evaluated server-side
    changed_via: adr-kit (360lm)
```

## Context

Learning Hub needs to track when an employee watches a tutorial: which content, how much, and whether they completed it. The simplest approach (write one record at the end of the session) loses data for sessions that end unexpectedly (tab closed, phone locked, network drop). A more robust approach captures the session start (so the record exists even if end is never sent) and updates completion metrics at the end. This also enables "in progress" states: the TM dashboard can see "started but not completed."

## Decision

View tracking uses a two-phase beacon:

**Phase 1 — start_view(tutorial_id, employee_id, content_type):**
- Called when play starts (video first plays, or scene guide opens).
- Creates a `learn.view_event` row with `started_at = now()`, `completed = false`.
- Returns `view_id BIGINT` — the client must store this for Phase 2.

**Phase 2 — end_view(view_id, scenes_reached?, watched_seconds?, completed):**
- Called on: video pause/end, scene guide close, tab visibilitychange to hidden, page unload (via sendBeacon).
- Updates the `view_event` row: sets `ended_at`, `watched_seconds`, `scenes_reached`, evaluates `completed` per ADR-051 thresholds.
- Returns VOID.

`view_event` is **append-only per play session** — each play start creates a new row; rewatching creates a second row. The TM dashboard aggregates across rows (employee's latest completed event per tutorial, or count of distinct completions).

**sendBeacon API** is used for the `end_view` call on page unload — it survives tab close unlike a regular fetch().

**Decision Maker:** hkl

## Alternatives Considered

- **Single write at session end (POST after completion or close).** Rejected: if tab is closed before the POST fires, no record is created; for screencasts, "watched 70% then closed" is a valuable signal for the TM dashboard — lost entirely with single-write.
- **Polling heartbeat (write every N seconds).** Rejected: creates many partial records per session; querying "how much did employee X watch?" requires summing across many rows; higher DB write load for continuous progress signals that could be captured in two points.
- **Client-side progress tracked in localStorage, synced at session end.** Rejected: if the device is lost or reset before sync, all progress is lost; does not survive cross-device access (employee switches device mid-course); start_view row exists from Phase 1 regardless of device state.
- **IndexedDB queue with sync on reconnect (offline-first).** Rejected: Learning Hub requires internet connectivity to stream video; offline-first is not a requirement; two-RPC beacon is sufficient for the online-required use case.

## Consequences

**Positive:**
- Partial watches are recorded even if the session ends unexpectedly (Phase 1 row persists).
- TM dashboard can show "started but not completed" state.
- sendBeacon ensures end_view fires even on tab close.
- Append-only rows preserve full session history (multiple watches by the same employee).

**Negative / Trade-offs:**
- Client must preserve `view_id` from start_view through the entire session — stored in a JS variable; lost if page hard-refreshes (Phase 2 cannot find the correct row; creates a new Phase 1 on next play).
- Multiple partial watch rows per employee per tutorial complicate "completed" queries (use `WHERE completed = true ORDER BY ended_at DESC LIMIT 1` per tutorial).
- sendBeacon has no response — end_view failures are silent on the client; monitor via server-side error logs.

**Risks and mitigations:**
- view_id lost on page refresh (race between Phase 1 and DOM refresh): mitigated by storing view_id in sessionStorage, not just a JS variable; sessionStorage survives soft refreshes.
- end_view called with stale view_id (from a prior session tab reuse): mitigated by view_event having a `started_at` column; stale IDs would update old rows — acceptable (both rows represent the same employee + tutorial pair).

## Related Decisions

- ADR-051 (learning completion thresholds) — completion is evaluated in end_view using the thresholds defined in ADR-051.
- ADR-014 (PostgREST as API layer) — start_view and end_view are PostgREST RPCs, not custom HTTP endpoints.

## References

- `memory/dbt_learn.md` — "start_view → BIGINT (view_id)"; "end_view → VOID"; "append-only per-play-session"
- `learn/index.html` — start_view call on play, end_view via sendBeacon on close
- `learn schema` — view_event table columns: view_id, started_at, ended_at, watched_seconds, scenes_reached, completed
