# Dashboard content spec — every row, every string, every CTA

> # ⛔ UNAPPROVED. NOTHING HERE IS A SPEC. DO NOT BUILD FROM IT.
>
> **Part 1 (the attention inventory) is FACT** — every row and kind was read out
> of the code and is cited. Trust it as a survey.
>
> **Part 2 (the channel sentence) is a PROPOSAL I invented and the operator has
> not ruled on.** No code produces those sentences today. The live screen renders
> raw gate pills (`Home.tsx:216-231`: `comments · live`, `moderation · live`).
> Two of the proposal's inputs — the `dms` and `comment_to_dm` gates — **do not
> exist on the payload at all**, so part of it describes a claim the API cannot
> make.
>
> Written down 2026-07-31 after the operator's correction: *"MD files is mostly
> instruction for sessions like you, so if you put stale specs or even wrong ones
> or ones not asked for or informed to me it will mess up with you and the other
> sessions."* Correct. A proposal filed as a spec becomes law by accident.

> Answers three operator questions (2026-07-31): what can the attention list
> actually contain, what decides the channel description text, and what kinds of
> CTA a row can carry. Everything marked **BUILT** was read out of the code this
> session and is cited. Everything else is labelled.
>
> Scope note from the operator, same day: **design may propose backend changes**,
> provided the change gets a code and quality review. So the gaps below are
> written as proposals, not as complaints.

---

## Part 1 — The attention list

### 1.1 It has TWO sources, and they behave completely differently

This is the single most important thing about it, and neither the old Home nor
any round-4 mockup made it visible.

| | **Computed items** | **Notifications** |
|---|---|---|
| type | `HomeAttentionItem` (`home-api.ts:55`) | `Notification` (`notifications-api.ts:3`) |
| where from | recomputed per request from live DB state (`home.service.ts:334`) | persisted rows, written when an alert fires (`alert-delivery.service.ts:68`) |
| how it clears | **by itself**, when the condition stops being true | only when a person dismisses it (`readAt`) |
| dismissible | **no** — no control is rendered | **yes** — single, plus "Dismiss all" at ≥2 |
| carries a link | **yes**, `href` | **has one and cannot use it** — see 1.4 |
| survives a restart | no | yes |

They are rendered as one undifferentiated list today (`Home.tsx:423-440`), which
is why the list cannot be read: half the rows are facts that will fix themselves
and half are messages that sit there until dismissed.

### 1.2 BUILT — every computed row that can appear

Eight shapes. Read from `home.service.ts:398-509`.

| # | id | severity | text | count | goes to |
|---|---|---|---|---|---|
| 1 | `llm:last-error` | red | LLM provider failing (*reason*) with no success since | — | `/agent/models` |
| 2 | `campaign:stuck:{id}` | red | Campaign "*name*" is sending but has not moved for *N* minutes | — | `/campaigns/{id}` |
| 3 | `campaign:failure-spike:{id}` | red | Campaign "*name*" failure spike: *N* failed sends in the last hour | N | `/campaigns/{id}` |
| 4a | `review:approve` | yellow | Held comment replies waiting for approval | N | `/agent/activity?preset=needs` |
| 4b | `review:label` | yellow | DM replies waiting for a label | N | `/agent/activity?preset=needs` |
| 4c | `review:{other}` | yellow | Review items waiting (*kind*) — the fallback for a kind nobody labelled | N | `/agent/activity?preset=needs` |
| 5 | `review:moderate:hidden` | yellow | Comments hidden, waiting for a delete or restore decision | N | `/agent/activity?preset=needs` |
| 6 | `review:moderate:failed:{channel}` | yellow | *Channel* comments still public, the bot's hide or delete failed | N | `/agent/activity?preset=needs` |
| 7 | `comment:failed:{channel}` | yellow | *Channel* comment actions that failed | N | `/agent/activity` |
| 8 | `agent:failsafes` | yellow | Agent fail-safe outcomes in the last 24h | N | `/agent/activity` |

Two behaviours worth keeping in the redesign because they are already correct:
**rows 6 and 7 carry the real channel from data**, never a hardcoded "Instagram";
and **row 8 subtracts anything already shown by rows 6/7**, so one underlying
issue is one row.

### 1.3 BUILT — every notification that can appear

Seven, from `red-rules.ts:69-173`, plus the digest.

| kind | title | fires when |
|---|---|---|
| `red.silence` | *Channel* webhooks silent for *N*h (threshold *T*h) | one per channel past the silence threshold. A muted channel is suppressed **from email only** |
| `red.campaign.stuck` | Campaign "*name*" is stuck | no movement for the stall window |
| `red.campaign.failure_spike` | Campaign "*name*" failure spike: *N* failed sends in the last hour | failures ≥ threshold in the window |
| `red.llm` | LLM provider hard-failing (*reason*) | last error fresher than 60 minutes |
| `red.health` | Health check failing: database unreachable | `SELECT 1` fails |
| `red.health` | Health check failing: Redis unreachable | `PING` fails |
| `red.moderation.failsafe` | Moderator failing safe: *N* comments left unmoderated in the last *W*m | fail-safe count ≥ threshold |
| *(digest)* | the daily summary | once a day |

`schema.prisma:1186` names `quality.drop` and `limit.decrease` as example kinds.
**I found no code that emits either.** Listed as a documented intention, not a
built row.

### 1.4 Three defects this inventory exposed

**① The same problem can appear twice.** `campaign.stuck`,
`campaign.failure_spike` and `llm` exist as **both** a computed item and a
notification kind. One stuck campaign therefore produces a self-clearing row
*and* a dismissible row, sitting next to each other saying the same thing. That
is a large part of why the list reads as "we have a ton of problems".

**② A notification cannot be acted on.** `alert-evaluator.service.ts:98` stores
`data: { href: issue.href }`, and `listUnread` returns the whole row, so **the
link is already on the wire.** The web type just does not declare `data`
(`notifications-api.ts:3-11`), so the UI renders a dismiss button and no way to
go fix the thing. A critical alert's only affordance is to make it disappear.

**③ Dismiss and Dismiss all are invisible in every round-4 mockup**, including
G. They exist and ship today (`Home.tsx:349-371`) with optimistic removal, one
quiet retry and a restore-on-failure. That is good behaviour that my mockups
silently dropped.

### 1.5 CTA taxonomy — what a row can offer

**BUILT, two kinds only:**

1. **Navigate** — `go fix →`, a link to the row's `href`. Every computed row has
   exactly one. Never mutates.
2. **Dismiss** — notifications only. Single `✕` per row, plus a `Dismiss all`
   that appears at ≥2. Optimistic, one retry, restores the row and shows a notice
   on failure. No undo window; the restore is a failure path, not an undo.

**PROPOSED — each is a real gap, none is speculative furniture:**

3. **Navigate on a notification.** Zero backend work: the href is already stored
   and already returned. Add `data` to the client type. *Fixes defect ②.*
4. **Resolve in place.** Rows 4a/4b/5 are counts of review items that each need a
   one-click decision. Sending the operator to a list to click one thing is the
   long way round. Proposal: the row opens the first waiting item directly.
5. **Retry.** Rows 6 and 7 are *failed Graph calls*. The only honest CTA for a
   failed call is to try it again; today it navigates to a list that also cannot
   retry. Needs a backend endpoint — this is the one CTA that is real new work.
6. **Snooze.** Different from dismiss: it comes back. `red.silence` on a channel
   you already know about is the case that trained everyone to ignore the list.
7. **Mute this kind.** `alerting-config.ts` already supports
   `silence.mutedChannels` / `mutedLabels`, seeded, **with no UI at all.** The
   WhatsApp mute was applied by editing seeded config. A row-level "stop telling
   me this" would expose what already exists.
8. **Undo after dismiss.** `CONVENTIONS §1.4` says undo beats confirm, and a
   dismissed notification is currently unrecoverable from the UI.

### 1.6 What I recommend the redesign does with all this

- **Split the list by how a row clears**, not by severity. "Not working" =
  computed and self-clearing. "Waiting for you" = your queue. A third group,
  "Told you about" = notifications, dismissible. Three kinds, three behaviours,
  three treatments. A single list cannot be honest about three lifecycles.
- **Deduplicate ① at the source**, in `home.service.ts`: if a computed item and a
  notification describe the same `dedupKey`, the computed one wins and the
  notification is suppressed from the list. It is the same fold the fail-safe row
  already does at line 497.

---

## Part 2 — What decides the channel text

> ⛔ **PROPOSAL, NOT IN FORCE.** Nothing below is implemented or approved.

Three independent strings, assembled, never authored. `CLAUDE.md` requires
anything an operator might change to be **seeded data, not a constant**, so a
gate flip must rewrite the text with no deploy.

### 2.1 The variables available

| variable | type | source |
|---|---|---|
| `connection.configured` | bool | `home-api.ts:9` |
| `connection.active` | bool | `home-api.ts:10` |
| `webhook.mutedLabel` | string \| null | `home-api.ts:24` — the standing status for a muted channel |
| `webhook.silent` | bool \| null | null means unknowable, never false |
| `webhook.hoursSince` | number \| null | |
| `lastInboundAt` / `lastOutboundAt` | ISO \| null | |
| `errors24h.webhookErrors` / `.failsafes` | number | |
| `gates.agent` (WhatsApp) | `off` \| `canary` \| `live` | `home-api.ts:42` |
| `gates.comments`, `gates.moderation` (Instagram) | `off` \| `shadow` \| `live` | `home-api.ts:47-50` |

### 2.2 String 1 — the verdict. First match wins.

| # | condition | text | tone |
|---|---|---|---|
| 1 | `!connection.configured` | Not set up | muted |
| 2 | `webhook.mutedLabel !== null` | *the label verbatim* — "Switched off by Meta" | info |
| 3 | `!connection.active` | Turned off | muted |
| 4 | `webhook.silent === true` | Connected, but nothing is arriving | warn |
| 5 | every gate `off` | Connected, but answering nothing | warn |
| 6 | any gate `shadow`/`canary`, none `live` | Practising — it writes, nothing sends | warn |
| 7 | any gate `live` | Answering on its own | ok |

Order is load-bearing. A muted channel outranks its gates because *"answering on
its own"* is literally true and completely misleading on a number Meta disabled.

### 2.3 String 2 — the capability sentence. One clause per gate, fixed order.

| gate | `live` | `shadow` / `canary` | `off` |
|---|---|---|---|
| comments | It replies to comments | It drafts comment replies for you to check | *silent* |
| moderation | and hides the bad ones | and flags the bad ones for you | *silent* |
| dms | It answers DMs | It drafts DM replies for you to check | It does not answer DMs yet |
| comment_to_dm | When someone comments it opens a DM | — | *silent* |

`off` is silent **except** where the absence is the fact the operator is waiting
on. DMs get an explicit "not yet"; nobody needs telling comment-to-DM is off.
Every string is seeded data, the treatment
`packages/shared/src/connections/presentation.ts` already gets — rewording is a
data edit, not a deploy.

### 2.4 String 3 — the recency clause

| condition | text |
|---|---|
| channel can still receive, `lastInboundAt` set | Someone wrote *4 minutes* ago. |
| channel cannot receive (verdict 1, 2 or 3) | The last message was *3 days* ago. |
| `lastInboundAt` is null | Nobody has written yet. |

Past tense when nothing can arrive. *"Someone wrote"* implies a channel that
could be written to again.

### 2.5 String 4 — the waiting line

Count of open review items whose channel matches. Zero → "Nothing waiting". Zero
**and** the channel cannot receive → "Nothing waiting, and nothing can arrive".

### 2.6 ⚠ Three of these cannot be built today

1. **No `dms` gate and no `comment_to_dm` gate on the payload.**
   `HomeInstagramChannel.gates` is `{comments, moderation}` only. Both keys exist
   live in prod `settings.channelGates`. **The DM clause — the one the operator
   most wants to read — has no data behind it.**
2. **No Facebook.** `HomeChannel = HomeWhatsAppChannel | HomeInstagramChannel`
   (`home-api.ts:53`), while `facebook:comments` is live on prod.
3. **WhatsApp's gate has a different shape** (`agent`, not `comments`), so the
   clause table is per-capability, not global.

All three are the same defect: **the payload was built for two channels and two
gates; the product has three channels and five.** With backend now in scope this
is one focused change to `home.service.ts` + `home-api.ts`, and it is a
prerequisite for any of these designs being honest rather than a mockup that
lies.
