# MPS-2: Anonymous Signal Gathering & View Count ## Philosophy Collect every honest signal we can about how people experience content — without recording who they are. No cookies, no IPs, no fingerprints, no user agent strings. A viewer's identity is never captured. Only the aggregate truth of what happened on the page. The raw signals feed four derived scores: **Engagement**, **Attention**, **Learning**, and **Passive Consumption**. These scores are computed from simple math now and can be refined with machine learning later — the raw data is the same either way. A **view** counts after 7 seconds of wall-clock time on the page with the tab visible. This respects all four pillars: truthful (they were really here), free (no judgment on how they consume), harmonious (same bar for a 30-second clip and a 2-hour film), and loving (assumes good intent from the visitor). ## What We Collect All signals are gathered in JavaScript memory during a page session and sent as a single JSON summary via `navigator.sendBeacon()` on page unload. One beacon per page visit. Nothing streams, nothing phones home mid-session. ### Presence Signals | Signal | How | Why | |--------|-----|-----| | `wall_clock_ms` | `Date.now()` delta from page load to unload | Total time on page | | `visible_ms` | Page Visibility API (`visibilitychange`) — accumulate time while `visible` | Tab in foreground vs backgrounded | | `active_ms` | Accumulate time while mouse/touch/keyboard events fire (debounce 2s idle gap) | Human is present and interacting | | `idle_ms` | `wall_clock_ms - active_ms` | Derived, not tracked separately | **Mouse/touch activity**: Listen for `mousemove`, `touchstart`, `keydown` — debounced to a boolean "active" flag that goes idle after 2 seconds of silence. We track **whether** the user is active, never **where** on the page. No coordinates, no element targets. ### Scroll Signals | Signal | How | Why | |--------|-----|-----| | `scroll_depth_max` | `window.scrollY / document.body.scrollHeight` — track max (0.0-1.0) | How far down the page they went | | `scroll_direction_changes` | Count sign changes in scroll delta | Reading (few reversals) vs scanning (many) | ### Media Playback Signals Piggyback on existing `timeupdate`, `play`, `pause`, `ended`, `seeked`, `ratechange` events already wired in watch.js. | Signal | How | Why | |--------|-----|-----| | `media_duration_ms` | `media.duration * 1000` on `loadedmetadata` | Length of the content | | `media_play_ms` | Accumulate time between `play` and `pause`/`ended` events | Actual playback time | | `media_play_count` | Count `play` events | Replays = love | | `media_pause_count` | Count `pause` events (user-initiated, not ended) | Interaction with content | | `media_seek_count` | Count `seeked` events | Skipping around (study or skip?) | | `media_seek_back_count` | Count seeks where `currentTime` decreased | Rewinding = learning | | `media_speed` | Last value from `ratechange` event, default 1.0 | 2x = efficient, 0.5x = studying | | `media_completed` | Boolean: did `ended` event fire at least once | Watched/listened to the end | | `media_percent_played` | `media_play_ms / media_duration_ms` (can exceed 1.0 on replay) | Completion without judging speed | ### Context Signals (server-side, from the HTTP request) | Signal | How | Why | |--------|-----|-----| | `is_ring_entry` | Boolean: full page load vs SPA transition | Front door vs ring navigation | | `ring_position` | Integer: how many SPA transitions from entry | Depth in the ring | | `referrer_class` | Enum: `direct`, `search`, `social`, `internal`, `unknown` | Where traffic comes from — derived from Referer header domain, NOT the full URL | | `referrer_domain` | String (128 chars) | Referer hostname (e.g. "www.google.com") — added in MPS-6 | | `referrer_query` | String (256 chars) | Search engine query parameter — added in MPS-6 | | `device_class` | Enum: `mobile`, `tablet`, `desktop` | Screen size bucket only, not user agent — derived from viewport width sent in beacon | `referrer_class` is computed server-side by matching the Referer header domain against known patterns (google/bing/ddg → search, twitter/facebook/reddit → social, same domain → internal, everything else → unknown). The raw Referer URL is never stored. `device_class` is derived from viewport width sent in the beacon payload: ≤768 → mobile, ≤1024 → tablet, else desktop. No user agent parsing. ## Storage ### Per-session table: `mps_page_session` One row per page visit. This is the raw truth. ``` id UUID PK shop_id FK → mps_shop product_id FK → mps_product created_timestamp BigInteger (ms) — when beacon was received -- presence wall_clock_ms Integer visible_ms Integer active_ms Integer -- scroll scroll_depth_max Float (0.0-1.0) scroll_direction_changes Integer -- media media_duration_ms Integer (NULL if no media) media_play_ms Integer (NULL if no media) media_play_count SmallInteger media_pause_count SmallInteger media_seek_count SmallInteger media_seek_back_count SmallInteger media_speed Float media_completed Boolean media_percent_played Float -- context is_ring_entry Boolean ring_position SmallInteger referrer_class SmallInteger (enum: 0=direct 1=search 2=social 3=internal 4=unknown) referrer_domain Unicode(128) — extracted domain (e.g. "www.google.com") referrer_query Unicode(256) — search engine query (e.g. "lo-fi beats") device_class SmallInteger (enum: 0=mobile 1=tablet 2=desktop) ``` No foreign key to any user/account table. No IP column. No session cookie column. The row is an anonymous fact about what happened, nothing more. ### Aggregate columns on `mps_product` Updated asynchronously (on beacon receipt or periodic batch): - `view_count` (BigInteger, server_default="0") — incremented when a session hits the 7-second visible threshold ### Retention Keep individual `mps_page_session` rows for 90 days. After that, aggregate into `mps_product_daily_stats` (one row per product per day with averages and sums) and delete the raw sessions. The daily rollup lives forever. ## Derived Scores Computed from raw signals, either on-read (in Python properties) or in the daily rollup. These are the creator-facing metrics. ### Engagement (0.0 – 1.0) ``` engagement = active_ms / wall_clock_ms ``` High = clicking, scrolling, pausing, seeking — actively interacting with the content. Low = tab open but idle. This is the "lean-in" metric. ### Attention (0.0 – 1.0) ``` attention = visible_ms / wall_clock_ms ``` High = tab in foreground the entire time. Low = they switched tabs. Combined with engagement: high attention + low engagement = focused passive watching. High attention + high engagement = deeply studying. ### Learning Signal (heuristic score) Indicators of active study, not passive consumption: - `media_seek_back_count > 0` — they rewound to re-hear something - `media_speed < 1.0` — slowed down to absorb - `scroll_direction_changes > 3` — re-reading sections - `media_pause_count > 2` — pausing to think or take notes - `engagement > 0.7` — high interaction throughout Score: count of true indicators (0-5). A session with 3+ is "learning mode." ### Passive Consumption (heuristic score) Indicators of lean-back enjoyment: - `attention > 0.7` — tab is in foreground - `engagement < 0.3` — but not interacting much - `media_percent_played > 0.69` — played most/all of the content - `media_play_count == 1` — straight through, no replays - `media_pause_count == 0` — never paused Score: count of true indicators (0-5). A session with 3+ is "passive consumption" — and this is a GOOD signal. It means the content is worth playing through without interruption. ### Machine Learning Hook The raw `mps_page_session` rows are feature vectors. Every field is a number. When ML is added later, it can: - Cluster sessions into behavioral profiles beyond the 4 heuristic scores - Detect anomalies (bot patterns: zero scroll + zero active + high play count) - Predict which products will retain based on early session patterns - Surface "hidden gems" — products with high learning/attention scores but low view counts The collection schema doesn't change. ML consumes the same rows and produces richer derived scores that feed into dashboards. The raw truth stays raw. ## View Count Display Show the human-readable view count on product and content pages, next to the upload date under the title h1: ``` Track Title uploaded to ShopName Created 3 days ago · 420 views ``` ### Formatting - 0: don't show view count at all (no "0 views") - 1: "1 view" - 2-999: exact number ("420 views") - 1,000-999,999: "1.2K views" - 1,000,000+: "1.8M views" ### Three-layer update (SPA compatibility) 1. **Templates** (`content.j2`, `product.j2`): render in `.product-meta-dates` 2. **watch.py** (`watch_json`): include `view_count` and `human_view_count` 3. **watch.js** (`updatePageContent`): update the view count span during SPA nav ## JavaScript Collector (`signals.js`) New file. Lightweight, no dependencies. Loaded on all product/content pages. ``` Page load: → Record start time → Attach debounced mousemove/touchstart/keydown → active flag → Attach visibilitychange → accumulate visible_ms → Attach scroll → track depth + direction changes → Attach media events (play/pause/ended/seeked/ratechange) → Generate random session token (in-memory only, never persisted) Page unload (beforeunload / visibilitychange → hidden): → Bundle all signals into JSON → POST via navigator.sendBeacon('/signals/beacon') → Fallback: fetch() with keepalive: true ``` The beacon payload is ~300 bytes of JSON. One request per page visit. No polling, no websockets, no streaming. ## Server Endpoint `POST /signals/beacon` — accepts JSON, validates fields, computes `referrer_class` from the Referer header, inserts one `mps_page_session` row. If `visible_ms >= 7000`, also increments `product.view_count`. Returns 204. Rate limiting: max 1 beacon per product per client per 60 seconds, enforced by a short-lived server-side cache keyed on `(session_token, product_id)`. The session token is random per page load — it prevents double-counting from accidental double-fires, not tracking users across pages. ## Files Changed | File | Change | |------|--------| | `models/page_session.py` | New model: `MpsPageSession` | | `models/product.py` | Add `view_count` column, `human_view_count` property | | `models/__init__.py` | Import new model | | `views/signals.py` | New: beacon endpoint | | `views/watch.py` | Include `view_count` / `human_view_count` in JSON | | `views/product.py` | Pass view count to template context | | `views/content.py` | Pass view count to template context | | `static/js/signals.js` | New: client-side signal collector | | `templates/content.j2` | Show view count in `.product-meta-dates` | | `templates/product.j2` | Show view count in `.product-meta-dates` | | `templates/snippets/base_styles.j2` | Load signals.js on product pages | | `static/js/watch.js` | Update view count in `updatePageContent()` | | `scripts/alembic/versions/` | Migration: `mps_page_session` table + `view_count` column | | `tests/test_functional.py` | Beacon endpoint tests, view count display tests | | `tests/test_models.py` | `human_view_count` formatting tests | ## What This Does NOT Collect - IP addresses - User agent strings (device class is viewport width only) - Cookies or persistent identifiers - Mouse/touch coordinates - Clicked element identifiers - Any form input content - Referrer URLs (only domain + search query parameter — see MPS-6) - Cross-page session linking (each page visit is an island) A viewer could visit every product in a shop and there would be no way to connect those visits to the same person. That is by design.