Files
compass_teams_nhs/internal/web/templates/dashboard.html
Sam Rolfe 09639c5125 Phase 7: notify and send via Apprise (Email + WhatsApp)
The morning briefs go out through the house Apprise API on `.35:8210`: one
brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service
names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an
address, a phone number or an SMTP password (AGENTS.md §10 rule 3).

- internal/apprise: keyed and stateless client, tag filtering, and error
  redaction so nothing sensitive reaches a log, a row or a screen.
- internal/brief: builds the per-boy and the combined brief, delivers on email
  and WhatsApp, records every attempt in `notifications` (migration 004 adds
  scope, channel, tag, ok and error) and fails loudly.
- Send panel (§5 screen 4): recipients, channels, an exact preview, Send now,
  plus the Send action on a stored draft (FR-10). The dashboard shows a
  Morning briefs banner and the Sends list.
- POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule
  calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring
  without messaging the family.
- scripts/morning-brief.sh: scan both children, then send. It is the n8n job;
  deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily).
- FR-12: a failed scan is never "nothing due" -- the brief says so, and the run
  exits non-zero. A failed delivery is recorded with no sent_at.
- The long no-due-date tail is capped in the sent brief so it stays brief
  (§10 rule 6); overdue work is never capped.

Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and
sent_at, and the endpoint answers 401/200/207 as documented. Evidence in
docs/phase-7-notify-notes.md.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-15 19:28:05 +11:00

247 lines
10 KiB
HTML

{{define "dashboard.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Compass / Teams</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
<script src="/static/htmx.min.js" defer></script>
<script src="/static/theme.js" defer></script>
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Compass / Teams
</a>
<div class="nav-bar__right">
<button class="button-utility" type="button" data-theme-toggle>Dark</button>
<span class="nav-bar__user">{{.Username}}</span>
<form method="post" action="/logout" class="nav-bar__logout">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-utility" type="submit">Sign out</button>
</form>
</div>
</nav>
<main class="container">
<header class="page-head">
<h1 class="heading-1">What is due</h1>
<p class="page-head__sub">Overdue first. Compass for the schedule and notices,
Teams for homework.</p>
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
{{if .Flash}}<p class="flash flash--{{$.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
{{template "scan-status.html" .Scan}}
</div>
{{template "calendar-status.html" .Calendar}}
<section class="scan scan--{{.Notify.Level}}" aria-labelledby="notify-title">
<div class="scan__head">
<h2 id="notify-title" class="heading-2">Morning briefs</h2>
<span class="category-pill scan__pill">
{{if eq .Notify.Level "ok"}}sending
{{else if eq .Notify.Level "error"}}failed
{{else if eq .Notify.Level "warn"}}attention
{{else}}off{{end}}
</span>
</div>
<p class="scan__message">{{.Notify.Message}}</p>
{{if or (gt .Notify.Sent 0) (gt .Notify.Failed 0)}}
<p class="scan__meta">{{.Notify.Sent}} delivered · {{.Notify.Failed}} failed{{if .Notify.LastAt}} · last attempt {{.Notify.LastAt}}{{end}}</p>
{{end}}
</section>
{{if .Tabs}}
<nav class="child-tabs" aria-label="Children">
{{range .Tabs}}
<a class="child-tab{{if .Active}} child-tab--active{{end}}" href="/?child={{.ID}}"
{{if .Active}}aria-current="page"{{end}}>
{{.Name}}
{{if .Overdue}}<span class="child-tab__badge">{{.Overdue}}</span>{{end}}
</a>
{{end}}
</nav>
{{end}}
{{if .HasChild}}
<section class="child" aria-labelledby="child-title">
<div class="child__head">
<div>
<h2 id="child-title" class="heading-2">{{.Child.Name}}</h2>
<p class="child__meta">{{if .Child.Year}}Year {{.Child.Year}} · {{end}}{{.Child.Headline}}</p>
</div>
<span class="category-pill scan__pill scan__pill--{{.Child.Scan.Level}}">
{{if eq .Child.Scan.Level "ok"}}scan healthy
{{else if eq .Child.Scan.Level "error"}}scan failed
{{else}}no scan yet{{end}}
</span>
</div>
{{if not .Child.Scan.OK}}
<p class="flash flash--error" role="alert">
{{.Child.Scan.Message}} The lists below may be incomplete — this is not a clean
“nothing due”.
</p>
{{end}}
{{range .Child.Sections}}
<section class="section" id="sec-{{.Key}}" aria-labelledby="sec-{{.Key}}-title">
<div class="section__head">
<h3 id="sec-{{.Key}}-title" class="heading-3{{if eq .Tone "danger"}} heading-3--danger{{end}}">{{.Title}}</h3>
<span class="category-pill"><span class="dot{{if eq .Tone "danger"}} dot--danger{{end}}"></span>{{len .Items}}</span>
</div>
{{if .Items}}
<ul class="item-list">
{{range .Items}}
<li class="item{{if eq .StatusLabel "Overdue"}} item--overdue{{end}}" id="item-{{.ID}}">
<div class="item__body">
<p class="item__title"><a href="/items/{{.ID}}">{{.Title}}</a></p>
<p class="item__meta">
<span class="tag tag--type">{{.TypeLabel}}</span>
{{if .Subject}}<span class="item__subject">{{.Subject}}</span>{{end}}
{{if .DueLabel}}<span class="item__due">due {{.DueLabel}}</span>{{end}}
{{if .StatusLabel}}<span class="tag tag--status">{{.StatusLabel}}</span>{{end}}
</p>
</div>
<form class="item__actions" method="post" action="/items/{{.ID}}/delete"
hx-post="/items/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<p class="section__empty">{{.Empty}}</p>
{{end}}
</section>
{{end}}
<div class="child__foot">
{{if .Child.LaterCount}}<span>{{.Child.LaterCount}} due later</span>{{end}}
{{if .Child.DoneCount}}<span>{{.Child.DoneCount}} submitted</span>{{end}}
<form method="post" action="/summaries/build" class="inline-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="child" value="{{.Child.ID}}">
<button class="button-utility" type="submit">Build {{.Child.Name}}'s brief (draft)</button>
</form>
</div>
</section>
{{else}}
<div class="empty-state">
<p class="empty-state__title">No children on file</p>
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
the database was not seeded.</p>
</div>
{{end}}
{{if .Send.Scopes}}
<section class="section" id="send-panel" aria-labelledby="send-title">
<div class="section__head">
<h2 id="send-title" class="heading-2">Send a brief</h2>
<span class="category-pill">Apprise</span>
</div>
{{if not .Send.Enabled}}
<p class="flash flash--error" role="alert">
NOTIFICATIONS NOT CONFIGURED — nothing can be sent. Set {{range $i, $m := .Send.Missing}}{{if $i}}, {{end}}{{$m}}{{end}}.
</p>
{{else}}
{{range .Send.Scopes}}
<details class="send-card">
<summary class="send-card__summary">{{.Label}}</summary>
<form method="post" action="/summaries/send" class="send-card__form">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="scope" value="{{.Key}}">
<fieldset class="field">
<legend class="field__label">Recipients</legend>
<div class="check-row">
{{range .Recipients}}
<label class="check"><input type="checkbox" name="recipient" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Name}}</label>
{{end}}
</div>
</fieldset>
<fieldset class="field">
<legend class="field__label">Channels</legend>
<div class="check-row">
{{range $.Send.Channels}}
<label class="check"><input type="checkbox" name="channel" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Label}}</label>
{{end}}
</div>
</fieldset>
{{if .Warning}}<p class="flash flash--error" role="alert">{{.Warning}}</p>{{end}}
<div class="send-card__actions">
<button class="button-utility" type="submit">Send now</button>
<span class="send-card__hint">The preview is exactly what is sent. Overdue first, lists only.</span>
</div>
<details class="brief-wrap" open>
<summary class="send-card__summary">preview ({{len .Preview}} bytes)</summary>
<pre class="brief">{{.Preview}}</pre>
</details>
</form>
</details>
{{end}}
{{end}}
</section>
{{end}}
{{if .Summaries}}
<section class="section" aria-labelledby="sends-title">
<div class="section__head">
<h2 id="sends-title" class="heading-2">Sends</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
</div>
<ul class="item-list">
{{range .Summaries}}
<li class="item{{if .Failed}} item--overdue{{end}}" id="summary-{{.ID}}">
<div class="item__body">
<p class="item__title">{{.Recipient}} · {{.Sent}}{{if .Channel}} · {{.Channel}}{{end}}</p>
<p class="item__meta">
<span class="tag tag--type">{{.Scope}}</span>
{{if .Tag}}<span class="item__subject">{{.Tag}}</span>{{end}}
{{if .Draft}}<span class="item__subject">built {{.Created}}</span>{{end}}
</p>
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<details class="brief-wrap">
<summary class="send-card__summary">brief text ({{len .Text}} bytes)</summary>
<pre class="brief">{{.Text}}</pre>
</details>
</div>
<div class="item__actions">
{{if .Draft}}
<form method="post" action="/summaries/{{.ID}}/send">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility" type="submit">Send</button>
</form>
{{end}}
<form method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief and its delivery record?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</div>
</li>
{{end}}
</ul>
</section>
{{end}}
</main>
<footer class="app-foot">
<span>Compass / Teams · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>up {{.ServerUp}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}