# Leads → Pay Attention (the priority lists)

## What it does

**Pay attention** is the LEFT-HAND side of the Leads filter bar — its own toggle and its own panel,
beside **Filters** on the right — holding a set of one-click call lists: each
checkbox is a WHOLE question ("three hours of webinars AND nobody has ever spoken to them AND they
have not bought"), not a threshold. Ticking two means *either*. Each prints who it is about and how
many visible leads it returns.

It is one registry — [`Src\Lead\Support\AttentionFlags`](/src/Lead/Support/AttentionFlags.php). A
new reason is one entry there (a name, a hint, a group, a condition); the filter, its count, its
checkbox and its heading appear with it and nothing else has to be touched.

**A flag must not be sayable with one existing filter.** "Webinar ≥ 180 min" is a threshold and has
one. A flag earns its place by needing an OR the list cannot express (the list's filters are
AND-combined), a "has never", a second table, or a combination nobody would rebuild by hand daily.

## The flags (2026-09-20)

Counts are the live figures on the day the set shipped (12,694 leads), so a reader can tell whether
a number on screen is plausible.

| Group | Flag | Count | The question | Reads |
|---|---|---|---|---|
| Money on the table | **Booked 30+ days, SPA not signed** `booking_stalled` | 51 | An ACTIVE booking, older than 30 days, `spa_signed_at` empty | `bookings` |
| | **Booked, gone quiet** `booking_quiet` | 39 | An open booking, and no contact on any channel for 14 days | `bookings` + `LastContact` |
| We owe them | **Waiting for our WhatsApp reply** `member_reply_owed` | 105 | A paid member whose thread is waiting on us | `MemberEntitlements::paidLeadIds()` + `ReplyOwed` |
| | **No Financial Report yet** `no_financial_report` | 353 | A paid member with no report on file | `MemberEntitlements` |
| | **No 1-1 Zoom yet** `no_zoom` | 352 | A paid member with no qualifying 1-1 Zoom | `MemberEntitlements` |
| Worth a call | **Back after a month of silence** `reactivated` | 12 | Wrote in the last 7 days, nothing from them in the 30 before, thread older than the silence | `MessageEngagement` |
| | **30m+ talked, 60m+ webinars, no deal** `engaged_no_deal` | 70 | (1-1 Zoom ≥ 30 min **OR** phone ≥ 30 min) AND webinar ≥ 60 min, no deal | `EngagementTime` |
| | **5+ of 7 areas ready, no deal** `nearly_ready` | 37 | Five or more READINESS areas green — the **verdict** the band shows, so only leads with an Overall AI reading (2026-09-20; was 37 on the records layer) | `lead_readiness.verdict` |
| | **Loan + Cash ready, nobody on it** `qualified_unworked` | 72 | Loan and Cash green (verdict), no deal, and NO project engagement at all | `lead_readiness.verdict` |
| | **Dropped a deal, never bought** `dropped_not_bought` | 152 | A Lost property record, no Booked or Completed one | `propertyRecords` |
| | **Bought, no Financial Report** `buyer_no_report` | 175 | A Completed record, no report — second-purchase capacity unknown | `propertyRecords` + `MemberEntitlements::reportExists()` |
| | **3h+ of webinars, never spoken to** `warm_never_spoken` | 1,453 | Webinar ≥ 180 min, no recorded call, no 1-1 Zoom that HAPPENED, no deal | `EngagementTime` |
| Check first | **Never replied to us** `never_replied` | 1,924 | We messaged; they have never written back | `MessageEngagement` |
| | **Agent in our webinars** `agent_in_webinars` | 18 | Judged agent / agent-linked, webinar ≥ 180 min — rule out, do not call | `LeadQuality::agentStateSql()` |

The order is the working order: money already on the table → what we promised → who to ring → who
to rule out before ringing anybody. The big list (`warm_never_spoken`) is a job for a caller team or
the AI caller, not a closer.

## How it works

- **`AttentionFlags::apply($query, $keys)`** ORs whole conditions. Each condition carries its own
  population (paid members, people with a booking, anybody we messaged), which is why the OR is
  across conditions and not across reasons inside one shared scope.
- **Every figure a flag reads is one a COLUMN prints, through the same definition**, so a row a
  flag returns never shows a cell that contradicts it:
  - [`EngagementTime`](/src/Lead/Support/EngagementTime.php) — webinar seconds, phone seconds, 1-1
    Zoom minutes. Also read by the `zoom_min` / `calls_min` / `meeting_minutes_min` filters.
  - [`LastContact::since()`](/src/Lead/Support/LastContact.php) — "touched on any channel since".
    Also read by `engaged_days` / `quiet_days` (it was `LeadQueryRequest::touchedSince()` until the
    flags needed it too).
  - [`MessageEngagement`](/src/Lead/Support/MessageEngagement.php) — real 1:1 WhatsApp only;
    `existsInWindowSql()` is what `reactivated` is built from.
  - `MemberEntitlements`, `ReplyOwed`, `LeadQuality`, `ReadinessColumns::RANKS`.
- **"No deal" means neither Booked NOR Completed** (`AttentionFlags::noDeal()`). A property record
  holds one status, so a deal that converted is no longer Booked: "Book = None" alone returned 85
  people on the first list this was written for, 13 of whom had already bought.
- **`booking_stalled` reads `bookings`, not the engagement status.** 41 of the 70 open bookings
  (ACTIVE, SPA unsigned) hang off an engagement that already reads **Completed**, so the PROPERTY
  CLOSING columns cannot say which deals are actually unsigned. ⚠️ This also means the list's
  **Convert** column counts deals whose SPA is still pending — a known gap, not fixed here.
- **Every "has none" is an EXISTS / `whereDoesntHave`**, never `count(...) = 0` or a comparison
  against a subquery that can return NULL — negating a NULL drops exactly the rows the flag is about.
- **Agents are excluded from the four outreach lists** (`notAgent()`), and surfaced on their own
  flag instead. Agents only, not fakes: the fake-score resolver costs ~150 ms per flag over the whole
  table to exclude ten people, none of whom is on any of these lists. The `Fake lead` filter still
  composes with any flag.
- **The counts are cached for two minutes per viewer** (`LeadsController::attentionCounts()`,
  key `leads:attention-counts:{user id}`). Fourteen whole-table counts on a database a network hop
  away were ~1.2 s on EVERY list request. The LIST is never cached — what a checkbox returns is
  always live; only the figure beside it can be two minutes old.
- **Two panels, one form** (founder, 2026-09-20: *"split the filter to general filter and pay
  attention filter (left and right), so that the current filter wont be long"*). The bar's header
  carries two toggles — `data-lead-filter-toggle` (Filters) and `data-lead-attention-toggle` (Pay
  attention) — and ONE panel shows at a time. They are two views of the same `draft`, not two
  forms: switching sides keeps the other side's ticks, the "N changes not applied" count covers
  both, and the single Filter button applies everything. Each toggle's "N on" counts only its own
  applied conditions, so a closed bar still says which side is narrowing the list. Both panels stay
  mounted while the bar is open (`v-show`), which is what lets the draft survive a switch.
  **Pay attention sits on the LEFT** (rose tile), Filters on the right (light-blue tile): it is the
  side a manager opens every morning. **Pressing Filter folds the bar away** from either panel
  (founder: *"after filter and click filter, the filter section should hide back"*) — the list is
  the thing to look at once the form is sent, and the toggles' "N on" still say what narrows it.
- **The panel prints one column per `AttentionFlags::GROUPS` heading**, in registry order
  (`attentionGroups` prop → `LeadFilterBar.vue`). A payload with no groups still renders, as one
  untitled block.

## Explaining itself — the rule book and the "?" (2026-09-20)

Founder: *"add logo i can click to open new page to understand how you classified those filtering,
like booked & quiet, how u define? … when i look at each lead, i dunno what is the issue. each lead
should have a ? symbol so that i can click then hover to show the details with date etc"*.

- **The rule book — `/manage/leads/pay-attention`** (`AttentionGuideController` →
  `Pages/Manage/Leads/AttentionGuide.vue`, route `manage.leads.pay-attention`, declared before
  `GET {id}`). One card per flag, under the panel's four headings: the rules that must ALL be true,
  what to do with the list, what the data cannot say, and an *Open this list* link. It renders
  `AttentionFlags::guide()` — each flag's `rules` / `do` / `caveat` live IN the registry entry, and
  every threshold is concatenated from the constant `condition()` compares against, so the page
  cannot say "14 days" while the filter means 21. **Change a `condition()` arm and its `rules`
  lines in the same edit.** Each card's id is the flag key (`#booking_quiet`). Opened in a NEW TAB
  (like the CLV / Status / Readiness guides) from a signpost at the top of the panel, a small one
  beside every flag, and the foot of every "?" card — the half-ticked draft survives. The bar takes
  the href as a prop (`attentionGuideHref`, suite-stamped by `Index.vue`) so it stays free of
  Inertia. No sidebar entry; the page carries a `PageHeader` back link.
- **The "?" beside each name** — `Partials/AttentionWhyCell.vue`, fed by the row's
  `attention_reasons` (`[{ key, name, facts: [{ label, value, at, at_label }] }]`), built for the
  page by [`AttentionReasons::forPage()`](/src/Lead/Support/AttentionReasons.php). Hover shows the
  card; a click pins it until the next click anywhere. It appears ONLY while a flag is ticked, and
  costs nothing otherwise. Two rules:
  - **Who is on a list is decided by `AttentionFlags` and nothing else.** With several flags ticked
    the list is an OR, so each flag is re-asked of the page's ids through its own condition (one
    `whereIn` query per flag; skipped when only one is ticked, since every row then matches). Facts
    EXPLAIN a verdict; they are never a second way of reaching one.
  - **Every figure is the one the row's own column prints** — minutes and last-contact dates come
    off attributes the list query already selected (`eng_*_at`, `zoom_meeting_minutes`,
    `call_duration_seconds`, `zoom_webinar_minutes`, `wa_owed_since`), deals and plans off relations
    it already eager-loaded, the owed-entitlement wording off `MemberEntitlements::forPage()`. Only
    three kinds of evidence need a query of their own, each batched for the page: open bookings,
    green `lead_readiness` rows (the hourly cache the flag counts — not the live column rules), and
    WhatsApp dates (`MessageEngagement::datesForUsers()`, same joins as every other WhatsApp figure;
    7 ms for a page).
  - Adding a flag now also means a `facts()` arm in `AttentionReasons` — a flag with none still
    gets a "?" naming the list, just with no lines under it.

## What the data cannot say yet

Read these before trusting a "never":

- **Phone calls are recorded from 2026-07-12, webinar attendance from 2026-07-03.** 296 of the 305
  Elite members paid before that. For them "no call" means *no record*, not *no call*.
- **1-1 Zoom time is the BOOKED length.** `zoom_meetings` has no `ended_at`.
- **`bookings.lo_signed_at` is filled on 2 of 513 bookings**, so nothing can be built on the Letter
  of Offer milestone until somebody starts recording it.
- **Follow Up roles (`lead_success_roles`) had 0 rows and action items had never been closed** when
  this shipped — an "ownership gap" or "overdue work" flag would return everybody. Add them once
  the team is recording.

## The trend — Leads → Pay Attention tab (2026-09-21)

Founder: *"add another sub tab to monitor the performance of [the lists] … show graph i wanna see all
these numbers are reducing"*. The Leads strip's **Pay Attention** tab (`/manage/leads/attention-trend`,
`LeadAttentionTrendController`, `Pages/Manage/Leads/AttentionTrend.vue`) draws each list's size over a
range: one card per flag under the panel's four headings, the latest count, the change since the first
reading in the range (**fewer** = green ↓, **more** = red ↑, always with the word), and a line.

- **History is recorded, not reconstructed.** Each list is computed from what is true NOW, so its past
  cannot be rebuilt from today's tables. `leads:snapshot-attention` (scheduled `hourlyAt(1)` in
  `app/Console/Kernel.php`) writes one row per flag per hour into **`lead_attention_snapshots`**
  (append-only, unique on `captured_at` + `flag`, `insertOrIgnore` — a second run in the hour adds
  nothing). Recording began **2026-09-21 14:00 MYT**; nothing before that exists.
- **One count definition.** `AttentionFlags::counts(fn () => $leads)` is what BOTH the list panel
  (`LeadsController::attentionCounts()`, scoped to the viewer) and the snapshot (every lead) call, so a
  point on the chart is the number the panel showed that hour. The first reading matched the panel on
  all fourteen lists.
- **Company-wide, so all-leads viewers only.** The controller aborts below `LeadVisibility::LEVEL_ALL`
  and the tab is gated on `view-leads-all` (super-admins pass) — the Booking study's rule.
- **Grain.** Up to 7 days every hourly reading is a point (today's movement shows from the first
  afternoon); longer ranges plot each day's **last** reading (where the day ended). Lands on *Last 7 days*.
- **Scale.** Small multiples with a per-card y-scale, labelled at both ends: the lists run from 1 to ~2,000
  leads, and a shared zero-based axis would flatten every small one. A table twin repeats start / now /
  change / lowest / highest for all fourteen.
- **Adding a flag** needs nothing here: the command counts every `FLAGS` key, and the page reads `guide()`.
  A new flag's line simply starts at the hour it was added.

## Adding a flag

1. A constant, a `FLAGS` entry (`name`, `hint` = WHO it is about, `group`, `rules` = the definition
   in words with thresholds spelt from constants, `do`, optional `caveat`), a `condition()` arm, and
   a `facts()` arm in `AttentionReasons` (what the "?" prints, with its date).
2. Reuse a shared definition for every figure; if a column prints it, find where and read that.
3. A case in `LeadAttentionPrioritiesTest` pinning BOTH halves: the lead it exists to surface and
   the near-miss a looser condition would sweep in.
4. Time it (`AttentionFlags::apply` + `count()` over the whole table). Anything over ~150 ms on its
   own deserves a second look before it joins thirteen others on every page load.

## Related files

- [`src/Lead/Support/AttentionFlags.php`](/src/Lead/Support/AttentionFlags.php) — the registry
- [`src/Lead/Support/AttentionReasons.php`](/src/Lead/Support/AttentionReasons.php) — the per-lead evidence behind the "?"
- [`app/Http/Controllers/Manage/Leads/AttentionGuideController.php`](/app/Http/Controllers/Manage/Leads/AttentionGuideController.php) +
  [`resources/js/Pages/Manage/Leads/AttentionGuide.vue`](/resources/js/Pages/Manage/Leads/AttentionGuide.vue) — the rule book page
- [`resources/js/Pages/Manage/Leads/Partials/AttentionWhyCell.vue`](/resources/js/Pages/Manage/Leads/Partials/AttentionWhyCell.vue) — the "?" card
- [`src/Lead/Support/EngagementTime.php`](/src/Lead/Support/EngagementTime.php),
  [`LastContact.php`](/src/Lead/Support/LastContact.php),
  [`MessageEngagement.php`](/src/Lead/Support/MessageEngagement.php) — shared definitions
- [`app/Http/Requests/Manage/Leads/LeadQueryRequest.php`](/app/Http/Requests/Manage/Leads/LeadQueryRequest.php) — `filterAttention()`
- [`app/Http/Controllers/Manage/Leads/LeadsController.php`](/app/Http/Controllers/Manage/Leads/LeadsController.php) — `attentionCounts()` (via `AttentionFlags::counts()`)
- The trend: [`app/Console/Commands/SnapshotLeadAttention.php`](/app/Console/Commands/SnapshotLeadAttention.php),
  [`src/Lead/LeadAttentionSnapshot.php`](/src/Lead/LeadAttentionSnapshot.php) +
  [`src/Lead/Repositories/LeadAttentionSnapshotRepository.php`](/src/Lead/Repositories/LeadAttentionSnapshotRepository.php),
  [`app/Http/Controllers/Manage/Leads/LeadAttentionTrendController.php`](/app/Http/Controllers/Manage/Leads/LeadAttentionTrendController.php),
  [`resources/js/Pages/Manage/Leads/AttentionTrend.vue`](/resources/js/Pages/Manage/Leads/AttentionTrend.vue) +
  `Partials/AttentionTrend/FlagLine.vue`; test `tests/Feature/Leads/LeadAttentionTrendTest.php`
- [`resources/js/Pages/Manage/Leads/Partials/LeadFilterBar.vue`](/resources/js/Pages/Manage/Leads/Partials/LeadFilterBar.vue) — the grouped checkboxes
- Tests: `tests/Feature/Manage/Leads/LeadAttentionPrioritiesTest.php`, `LeadAttentionTest.php`,
  `resources/js/Pages/Manage/Leads/Partials/LeadFilterBar.test.js`, `AttentionWhyCell.test.js`
