Phase 5: per-child dashboard (overdue first), item detail, delete

- internal/dashboard: classify items into overdue / due today / due this week /
  no due date / messages; render the plain-text brief (FR-6, FR-7, FR-9).
- internal/store: ItemsForChild, ItemByID, DeleteItemByID, ScanSummaryForChild
  and the stored-brief CRUD helpers (FR-10).
- internal/web: routes /?child=, /items/{id}, POST /items/{id}/delete,
  POST /summaries/build, POST /summaries/{id}/delete, plus a per-child scan
  banner (FR-12) and a phone-first server-rendered template.
- Calendar events are never "overdue": a past lesson is history, not work.
- cmd/school brief build|list; scripts/screenshot-phase5.mjs for phone evidence.
- Tests: bucket classification, stored-brief round-trip, and HTTP routes
  including the NO SCAN / SCAN FAILED / healthy banner behaviour.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 17:50:29 +11:00
parent 207a4978a9
commit 37d82b1a0a
16 changed files with 2223 additions and 65 deletions

View File

@@ -1,5 +1,5 @@
IMAGE ?= compass-teams-nhs/school:0.3.0-phase4 IMAGE ?= compass-teams-nhs/school:0.3.0-phase5
BUILD_VERSION ?= 0.3.0-phase4 BUILD_VERSION ?= 0.3.0-phase5
# Parse options (see scripts/parse-compass.sh). # Parse options (see scripts/parse-compass.sh).
CHILD ?= Harry CHILD ?= Harry

View File

@@ -4,8 +4,22 @@ A school service for Harry and Finn: **Compass** for the daily schedule and
notices, **Teams** for homework and assignments. It stores what is due per child, notices, **Teams** for homework and assignments. It stores what is due per child,
puts overdue work first, and tells the family before work is late. puts overdue work first, and tells the family before work is late.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 4** (both > Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 5** (the
> children, per-child sessions). See §11 of the spec for the phase order. > per-child dashboard). See §11 of the spec for the phase order.
## Status — Phase 5
Phase 5 adds the per-child dashboard: one tab per child, **overdue first**, then
due today, due this week and messages, plus the item detail screen and the
Delete actions. See [`docs/phase-5-dashboard-notes.md`](docs/phase-5-dashboard-notes.md).
```bash
# Phone-width evidence screenshots (writes under var/, git-ignored).
SCHOOL_USER=sam SCHOOL_PASS=... node scripts/screenshot-phase5.mjs
# Build a draft brief for one child.
docker exec compass-teams-nhs /app/school brief build --child Harry
```
## Status — Phase 4 ## Status — Phase 4
@@ -41,9 +55,9 @@ Built and deployed on `.13`:
- Its own container and port **8792**, behind Caddy at - Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`. `school.lab.audasmedia.com.au`.
Not built yet (later phases): the dashboard data, the calendar write, Not built yet (later phases): the calendar write, notifications and the Hub API.
notifications, and the Hub API. The service itself does **not** scan the school; The service itself does **not** scan the school; n8n triggers the scan later.
n8n triggers the scan later. Teams (FR-15) is a separate source. Teams (FR-15) is a separate source.
### Fail loudly (FR-12) ### Fail loudly (FR-12)
@@ -80,8 +94,8 @@ docker compose up -d --build
Generate secrets with the built image: Generate secrets with the built image:
```bash ```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase4 gen-secret docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase4 hash-password 'the-password' docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 hash-password 'the-password'
``` ```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash

123
cmd/school/brief.go Normal file
View File

@@ -0,0 +1,123 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// briefCommand is the `school brief` entry point. Phase 5 builds and deletes the
// stored brief (the summary half of FR-10); sending it is Phase 7.
func briefCommand(args []string) {
if len(args) == 0 {
usage("school brief build --child NAME | school brief list")
}
switch args[0] {
case "build":
briefBuild(args[1:])
case "list":
briefList()
default:
usage("school brief build --child NAME | school brief list")
}
}
func briefBuild(args []string) {
fs := flag.NewFlagSet("brief build", flag.ContinueOnError)
var child string
fs.StringVar(&child, "child", "", "child name (Harry or Finn)")
_ = fs.Parse(args)
if child == "" {
fmt.Fprintln(os.Stderr, "school brief build: --child is required")
os.Exit(2)
}
if err := runBriefBuild(child); err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1)
}
}
func runBriefBuild(child string) error {
ctx := context.Background()
cfg, err := config.LoadDB()
if err != nil {
return err
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(ctx); err != nil {
return err
}
c, err := st.ChildByName(ctx, child)
if err != nil {
return err
}
if c == nil {
return fmt.Errorf("unknown child %q", child)
}
items, err := st.ItemsForChild(ctx, c.ID)
if err != nil {
return err
}
text := dashboard.Render(c.Name, dashboard.Classify(items, time.Now(), cfg.Location), cfg.Location)
id, err := st.CreateSummary(ctx, c.Name, text)
if err != nil {
return err
}
fmt.Printf("brief=%d recipient=%s bytes=%d\n", id, c.Name, len(text))
return nil
}
func briefList() {
ctx := context.Background()
cfg, err := config.LoadDB()
if err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1)
}
st, err := store.Open(cfg.DBPath)
if err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1)
}
defer st.Close()
summaries, err := st.ListSummaries(ctx, 20)
if err != nil {
fmt.Fprintln(os.Stderr, "school brief:", err)
os.Exit(1)
}
if len(summaries) == 0 {
fmt.Println("no briefs stored")
return
}
for _, sm := range summaries {
status := "draft"
if sm.SentAt != nil {
status = "sent " + sm.SentAt.Format("2006-01-02 15:04")
}
fmt.Printf("%d\t%s\t%s\t%s\n", sm.ID, sm.Recipient, status, firstLine(sm.Text))
}
}
func firstLine(s string) string {
for i, r := range s {
if r == '\n' {
return s[:i]
}
}
return s
}
func usage(msg string) {
fmt.Fprintln(os.Stderr, "usage:", msg)
os.Exit(2)
}

View File

@@ -53,6 +53,9 @@ func main() {
case "items": case "items":
itemsCommand(os.Args[2:]) itemsCommand(os.Args[2:])
return return
case "brief":
briefCommand(os.Args[2:])
return
case "version": case "version":
fmt.Println(version) fmt.Println(version)
return return

View File

@@ -10,8 +10,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.3.0-phase4" VERSION: "0.3.0-phase5"
image: compass-teams-nhs/school:0.3.0-phase4 image: compass-teams-nhs/school:0.3.0-phase5
container_name: compass-teams-nhs container_name: compass-teams-nhs
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -0,0 +1,114 @@
# Phase 5 — the per-child dashboard
Status: **done.** Sam can see, per child and on a phone, what is overdue, what is
due today, what is due this week, and the messages. See AUD-109.
## What the dashboard shows
One tab per child (Harry, Finn). The selected tab shows, in order (FR-7):
1. **Overdue** — overdue first, with a red marker.
2. **Due today**.
3. **Due this week** — the next seven days.
4. **No due date** — only when the child has such tasks.
5. **Messages** — the Compass news feed, newest first.
A task is **overdue** when Compass says so (`Status: Overdue` from the JSON
service, `Submitted: Overdue` from the grid), or when it is not submitted and its
due date is before today. **Submitted** work (On Time, Received Late) is not
"due", so it stays out of the lists; the count is shown in the panel footer.
A **calendar event** is never overdue: a past lesson is history, not work to do.
Events appear under due today and due this week.
The page is server-rendered HTML with HTMX. It is phone-first: one column, 44px
touch targets, and the Delete button steps below the title under 520px.
## Routes
| Method | Path | Shows |
|---|---|---|
| `GET` | `/?child=<id>` | the per-child dashboard (first child when `child` is absent) |
| `GET` | `/items/{id}` | item detail: subject, teacher, due date, link, full text (§5 screen 3) |
| `POST` | `/items/{id}/delete` | the Delete action on an item (FR-10) |
| `POST` | `/summaries/build` | build a draft brief for one child (FR-10 summary half) |
| `POST` | `/summaries/{id}/delete` | the Delete action on a stored brief (FR-10) |
| `GET` | `/partials/scan-status` | the scan banner fragment (polled every 60s) |
Every `POST` checks the session CSRF token. An HTMX request gets `204 No
Content`, so the row is removed in place; a plain form post gets `303 See Other`
back to the dashboard, so the page works without JavaScript.
A deleted item returns on the next parse if the school still lists it. A deleted
brief is gone.
## Fail loudly (FR-12)
The banner reads the scan log. It is never an empty day:
- **NO SCAN HAS RUN YET** — amber. A child with no scan shows
"Unknown — the last scan did not complete." instead of "Nothing overdue.".
- **SCAN FAILED** — red, with the recorded error. The child panel repeats the
warning.
- **Last scan completed** — green. Only then may an empty section say
"Nothing overdue.".
The banner is per child (`ScanSummaryForChild`), so a failed scan for Finn is
never hidden by a successful scan for Harry.
## Where the items come from
No new scan code: Phase 3/4 store the items. Phase 5 adds the reads and the
classification (`internal/dashboard`).
- `internal/dashboard/dashboard.go` — classify items into buckets; render the
plain-text brief.
- `internal/store/dashboard.go` — `ItemsForChild`, `ItemByID`, `DeleteItemByID`,
`ScanSummaryForChild`, and the summary create/list/get/delete helpers.
- `internal/web/server.go` — the routes and handlers.
- `internal/web/templates/dashboard.html`, `item.html` — the screens.
## The stored brief (FR-10, delete half)
A brief is a row in `notifications` (AGENTS.md §7). `sent_at` stays NULL for a
draft. Building a brief stores the text; **sending** it is Phase 7.
```bash
docker exec compass-teams-nhs /app/school brief build --child Harry
docker exec compass-teams-nhs /app/school brief list
```
## Verification (2026-10-15, live)
```bash
make test # go test ./...
```
The tests cover the buckets, the brief text, the stored brief round-trip, and the
HTTP routes. `internal/web/server_test.go` logs in and proves:
- the selected child's items render and the other child's do not,
- the overdue item renders before the due-today item,
- the item detail carries subject, teacher and body,
- delete removes the row and a tokenless delete is refused (403),
- the banner shows `NO SCAN HAS RUN YET`, then `SCAN FAILED` with the error, then
`Last scan completed`, and only a healthy scan allows "Nothing overdue.".
Live counts on `.13` (Compass data of 2026-10-15):
| Child | Overdue | Due today | This week | No due date | Messages |
|---|---|---|---|---|---|
| Harry | 6 | 8 | 7 | 18 | 1 |
| Finn | 2 | 0 | 0 | 8 | 1 |
The overdue counts match the home-page reconciliation from Phase 4a (Harry 6,
Finn 2).
Phone-width screenshots (390 × 844) are attached to AUD-109:
`scripts/screenshot-phase5.mjs`.
## Known gaps
- Teams (FR-15) is a separate source; the dashboard shows Compass only.
- Sending a brief is Phase 7. Phase 5 stores and deletes a draft.
- The calendar write (Phase 6) and the Hub API (Phase 8) are not built yet.

View File

@@ -0,0 +1,240 @@
// Package dashboard turns stored items into the per-child view required by
// AGENTS.md §4 (FR-6, FR-7) and §5: overdue first, then due today, due this
// week, then messages. It holds no state; the same code feeds the web
// dashboard and the stored draft brief (FR-9/FR-10).
package dashboard
import (
"fmt"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// Buckets is one child's items, split into the sections the dashboard shows.
// Later and NoDue are counted but not given a heading of their own unless they
// carry work, so the page stays brief (AGENTS.md §10 rule 6).
type Buckets struct {
Overdue []store.Item
DueToday []store.Item
DueWeek []store.Item
NoDue []store.Item
Messages []store.Item
// Later is work due after this week; Done is submitted work. Both are kept
// so the summary can count them and nothing is silently dropped.
Later []store.Item
Done []store.Item
}
// Actionable is the number of task items that still need doing.
func (b Buckets) Actionable() int {
return len(b.Overdue) + len(b.DueToday) + len(b.DueWeek) + len(b.NoDue)
}
// Empty reports whether the child has nothing to show at all.
func (b Buckets) Empty() bool {
return b.Actionable() == 0 && len(b.Messages) == 0
}
// Headline is the one-line summary used at the top of a child tab and in the
// brief.
func (b Buckets) Headline() string {
return fmt.Sprintf("%d overdue · %d due today · %d this week · %d message(s)",
len(b.Overdue), len(b.DueToday), len(b.DueWeek), len(b.Messages))
}
// Classify splits items into buckets for one child.
//
// A task counts as overdue when Compass says so ("Status: Overdue" or
// "Submitted: Overdue"), or when it is not submitted and its due date is
// before today. Submitted work (On Time / Received Late) is not "due", so it
// is kept out of the due lists (the parser stores every historical task; the
// dashboard shows what still needs doing).
func Classify(items []store.Item, now time.Time, loc *time.Location) Buckets {
if loc == nil {
loc = time.UTC
}
startToday := startOfDay(now, loc)
endToday := startToday.AddDate(0, 0, 1)
endWeek := startToday.AddDate(0, 0, 7)
var b Buckets
for _, it := range items {
switch it.Type {
case "message":
b.Messages = append(b.Messages, it)
continue
case "task":
// handled below
case "event":
// A lesson or event is never "overdue": a past lesson is history, not
// work to do. Only today and the coming week are shown.
if it.DueAt == nil {
continue
}
due := it.DueAt.In(loc)
switch {
case !due.Before(startToday) && due.Before(endToday):
b.DueToday = append(b.DueToday, it)
case !due.Before(endToday) && due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it)
case !due.Before(endWeek):
b.Later = append(b.Later, it)
}
continue
default:
continue
}
status := Status(it.Body)
if isSubmitted(status) {
b.Done = append(b.Done, it)
continue
}
if it.DueAt == nil {
b.NoDue = append(b.NoDue, it)
continue
}
due := it.DueAt.In(loc)
switch {
case status == "overdue":
b.Overdue = append(b.Overdue, it)
case due.Before(startToday):
b.Overdue = append(b.Overdue, it)
case due.Before(endToday):
b.DueToday = append(b.DueToday, it)
case due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it)
default:
b.Later = append(b.Later, it)
}
}
for _, s := range [][]store.Item{b.Overdue, b.DueToday, b.DueWeek, b.NoDue, b.Later, b.Done} {
sort.SliceStable(s, func(i, j int) bool { return less(s[i], s[j]) })
}
sort.SliceStable(b.Messages, func(i, j int) bool {
ti, tj := timeOf(b.Messages[i]), timeOf(b.Messages[j])
return ti.After(tj)
})
return b
}
// less orders actionable items by due date, then title. A nil due date sorts last.
func less(a, b store.Item) bool {
switch {
case a.DueAt == nil && b.DueAt == nil:
return a.Title < b.Title
case a.DueAt == nil:
return false
case b.DueAt == nil:
return true
case a.DueAt.Equal(*b.DueAt):
return a.Title < b.Title
default:
return a.DueAt.Before(*b.DueAt)
}
}
func timeOf(it store.Item) time.Time {
if it.DueAt == nil {
return time.Time{}
}
return *it.DueAt
}
// Status reads the status line the parser writes into a task body: the JSON
// parser writes "Status: Overdue", the HTML grid writes "Submitted: Overdue".
// It returns a lower-case label, or "" when the body carries no status.
func Status(body string) string {
for _, line := range strings.Split(body, "\n") {
line = strings.TrimSpace(line)
for _, prefix := range []string{"Status:", "Submitted:"} {
if strings.HasPrefix(line, prefix) {
v := strings.TrimSpace(strings.TrimPrefix(line, prefix))
v = strings.TrimRight(v, ".")
return strings.ToLower(v)
}
}
}
return ""
}
// submittedStatuses are the labels that mean the work no longer needs doing.
var submittedStatuses = map[string]bool{
"on time": true,
"received late": true,
"submitted": true,
"completed": true,
}
func isSubmitted(status string) bool { return submittedStatuses[status] }
func startOfDay(t time.Time, loc *time.Location) time.Time {
t = t.In(loc)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
}
// Render writes one child's brief as plain text: overdue first, then due today,
// due this week, no due date and messages (FR-9, FR-14, AGENTS.md §4 rule 6).
func Render(child string, b Buckets, loc *time.Location) string {
if loc == nil {
loc = time.UTC
}
var sb strings.Builder
fmt.Fprintf(&sb, "%s — %s.\n", child, b.Headline())
writeSection(&sb, "OVERDUE", b.Overdue, loc)
writeSection(&sb, "DUE TODAY", b.DueToday, loc)
writeSection(&sb, "DUE THIS WEEK", b.DueWeek, loc)
writeSection(&sb, "NO DUE DATE", b.NoDue, loc)
writeMessages(&sb, b.Messages, loc)
return strings.TrimRight(sb.String(), "\n") + "\n"
}
func writeSection(sb *strings.Builder, title string, items []store.Item, loc *time.Location) {
if len(items) == 0 {
return
}
fmt.Fprintf(sb, "\n%s\n", title)
for _, it := range items {
fmt.Fprintf(sb, "- %s\n", line(it, loc))
}
}
func writeMessages(sb *strings.Builder, items []store.Item, loc *time.Location) {
if len(items) == 0 {
return
}
sb.WriteString("\nMESSAGES\n")
for _, it := range items {
when := ""
if it.DueAt != nil {
when = " (" + it.DueAt.In(loc).Format("Mon 02 Jan 15:04") + ")"
}
fmt.Fprintf(sb, "- %s%s\n", it.Title, when)
}
}
// line renders one actionable item for the brief.
func line(it store.Item, loc *time.Location) string {
subject := strings.TrimSpace(it.Subject)
title := strings.TrimSpace(it.Title)
switch {
case subject != "" && subject != title:
title = subject + ": " + title
}
due := ""
if it.DueAt != nil {
due = " (due " + it.DueAt.In(loc).Format("Mon 02 Jan 15:04") + ")"
}
status := Status(it.Body)
mark := ""
if status != "" && status != "pending" {
mark = " [" + status + "]"
}
return title + due + mark
}

View File

@@ -0,0 +1,169 @@
package dashboard
import (
"strings"
"testing"
"time"
_ "time/tzdata"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// sydney is the family timezone used in the tests.
func sydney(t *testing.T) *time.Location {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("load location: %v", err)
}
return loc
}
func due(t *testing.T, loc *time.Location, s string) *time.Time {
t.Helper()
v, err := time.ParseInLocation("2006-01-02 15:04", s, loc)
if err != nil {
t.Fatalf("parse %q: %v", s, err)
}
return &v
}
// TestClassifyBuckets proves FR-6/FR-7: overdue first, then due today, due this
// week, no due date and messages, and that submitted work is not "due".
func TestClassifyBuckets(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
items := []store.Item{
{Type: "task", Title: "Old essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
{Type: "task", Title: "Past pending", DueAt: due(t, loc, "2026-10-14 09:00"), Body: "Submitted: Pending"},
{Type: "task", Title: "Today task", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"},
{Type: "event", Title: "Today lesson", DueAt: due(t, loc, "2026-10-15 10:00")},
{Type: "task", Title: "Friday task", DueAt: due(t, loc, "2026-10-17 09:00"), Body: "Status: Pending"},
{Type: "task", Title: "Next month", DueAt: due(t, loc, "2026-11-02 09:00"), Body: "Status: Pending"},
{Type: "task", Title: "No date", Body: "Status: Pending"},
{Type: "task", Title: "Already done", DueAt: due(t, loc, "2026-10-01 09:00"), Body: "Status: On Time"},
{Type: "task", Title: "Late but in", DueAt: due(t, loc, "2026-10-02 09:00"), Body: "Status: Received Late"},
{Type: "event", Title: "Past lesson", DueAt: due(t, loc, "2026-10-12 09:00")},
{Type: "message", Title: "Notice", DueAt: due(t, loc, "2026-10-14 07:00")},
}
b := Classify(items, now, loc)
want := map[string][]string{
"overdue": {"Old essay", "Past pending"},
"today": {"Today lesson", "Today task"},
"week": {"Friday task"},
"later": {"Next month"},
"nodue": {"No date"},
"done": {"Already done", "Late but in"},
"messages": {"Notice"},
}
got := map[string][]string{
"overdue": titles(b.Overdue),
"today": titles(b.DueToday),
"week": titles(b.DueWeek),
"later": titles(b.Later),
"nodue": titles(b.NoDue),
"done": titles(b.Done),
"messages": titles(b.Messages),
}
for k, w := range want {
if !equal(got[k], w) {
t.Errorf("%s = %v, want %v", k, got[k], w)
}
}
// A past event is history, not overdue work: it must not appear anywhere.
for k, rows := range got {
for _, title := range rows {
if title == "Past lesson" {
t.Errorf("past event appeared in %s", k)
}
}
}
if b.Actionable() != 6 {
t.Errorf("Actionable = %d, want 6", b.Actionable())
}
if b.Empty() {
t.Error("Empty = true with items present")
}
}
// TestOverdueFromDueDateOnly proves a task with no status line still lands in
// overdue when it is past due.
func TestOverdueFromDueDateOnly(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
b := Classify([]store.Item{
{Type: "task", Title: "No status", DueAt: due(t, loc, "2026-10-01 09:00")},
}, now, loc)
if len(b.Overdue) != 1 {
t.Fatalf("overdue = %v, want the no-status past task", titles(b.Overdue))
}
}
func TestStatusParsing(t *testing.T) {
cases := map[string]string{
"Status: Overdue\nClass: X": "overdue",
"Class: X\nSubmitted: On Time": "on time",
"Submitted: Received Late\nx": "received late",
"Status: Pending": "pending",
"no status here": "",
"Submitted: Overdue.": "overdue",
}
for body, want := range cases {
if got := Status(body); got != want {
t.Errorf("Status(%q) = %q, want %q", body, got, want)
}
}
}
// TestRenderOverdueFirst proves the brief lists overdue before anything else and
// stays list-shaped (rule 6).
func TestRenderOverdueFirst(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 15, 9, 0, 0, 0, loc)
b := Classify([]store.Item{
{Type: "task", Subject: "11EENG", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
{Type: "task", Subject: "11MGMA", Title: "Chapter 4", DueAt: due(t, loc, "2026-10-15 15:00"), Body: "Status: Pending"},
{Type: "message", Title: "Sports day"},
}, now, loc)
text := Render("Harry", b, loc)
if !strings.Contains(text, "Harry — 1 overdue · 1 due today") {
t.Errorf("headline missing: %q", text)
}
oi := strings.Index(text, "OVERDUE")
ti := strings.Index(text, "DUE TODAY")
if oi < 0 || ti < 0 || oi > ti {
t.Errorf("overdue must come first:\n%s", text)
}
if !strings.Contains(text, "- 11EENG: Essay (due Sat 10 Oct 09:00) [overdue]") {
t.Errorf("overdue line wrong:\n%s", text)
}
if !strings.Contains(text, "MESSAGES\n- Sports day") {
t.Errorf("messages missing:\n%s", text)
}
}
func titles(items []store.Item) []string {
out := make([]string, 0, len(items))
for _, it := range items {
out = append(out, it.Title)
}
return out
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

223
internal/store/dashboard.go Normal file
View File

@@ -0,0 +1,223 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// This file holds the read/write helpers the Phase 5 dashboard needs:
// per-child item reads, one item, item delete, a per-child scan summary, and
// the draft summaries (FR-7, FR-10, FR-12).
// itemSelect is the shared column list for an item row joined to its child.
const itemSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, '')
FROM items i LEFT JOIN children ch ON ch.id = i.child_id`
// scanItem reads one item row from the shared column list.
func scanItem(r rowScanner) (Item, error) {
var it Item
var due sql.NullString
if err := r.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
&it.SourcePage, &it.Importance, &it.ExternalRef); err != nil {
return it, err
}
if due.Valid {
t := parseTime(due.String)
it.DueAt = &t
}
return it, nil
}
// ItemsForChild returns every stored item for one child. The dashboard classifies
// the rows into overdue / due today / due this week / messages (FR-7); a child
// never sees another child's rows.
func (s *Store) ItemsForChild(ctx context.Context, childID int64) ([]Item, error) {
rows, err := s.db.QueryContext(ctx,
itemSelect+` WHERE i.child_id = ? ORDER BY i.type, i.due_at, i.id`, childID)
if err != nil {
return nil, fmt.Errorf("items for child %d: %w", childID, err)
}
defer rows.Close()
var out []Item
for rows.Next() {
it, err := scanItem(rows)
if err != nil {
return nil, fmt.Errorf("scan item: %w", err)
}
out = append(out, it)
}
return out, rows.Err()
}
// ItemByID returns one item, or nil when it does not exist. The item detail
// screen uses it (AGENTS.md §5 screen 3).
func (s *Store) ItemByID(ctx context.Context, id int64) (*Item, error) {
row := s.db.QueryRowContext(ctx, itemSelect+` WHERE i.id = ?`, id)
it, err := scanItem(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("item %d: %w", id, err)
}
return &it, nil
}
// DeleteItemByID removes one item. It is the Delete half of FR-10: a deleted
// item returns on the next parse if the school still lists it. It returns the
// number of rows removed (0 when the id was already gone).
func (s *Store) DeleteItemByID(ctx context.Context, id int64) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM items WHERE id = ?`, id)
if err != nil {
return 0, fmt.Errorf("delete item %d: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete item %d: %w", id, err)
}
return n, nil
}
// ChildByID returns one child, or nil when it does not exist.
func (s *Store) ChildByID(ctx context.Context, id int64) (*Child, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, name, COALESCE(year, ''), COALESCE(compass_id, ''), created_at
FROM children WHERE id = ?`, id)
var c Child
var created string
if err := row.Scan(&c.ID, &c.Name, &c.Year, &c.CompassID, &created); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("child %d: %w", id, err)
}
c.CreatedAt = parseTime(created)
return &c, nil
}
// ScanSummaryForChild is ScanSummary limited to one child, so a failed scan for
// Finn is never hidden by a successful scan for Harry (FR-12).
func (s *Store) ScanSummaryForChild(ctx context.Context, childID int64) (ScanStatus, error) {
var st ScanStatus
if err := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0)
FROM scans WHERE child_id = ?`, childID).
Scan(&st.Total, &st.Failed); err != nil {
return st, fmt.Errorf("scan summary for child %d: %w", childID, err)
}
st.EverRun = st.Total > 0
row := s.db.QueryRowContext(ctx,
`SELECT sc.id, sc.child_id, COALESCE(ch.name, ''), sc.started_at,
sc.finished_at, sc.pages_fetched, sc.items_found, sc.ok, COALESCE(sc.error, '')
FROM scans sc LEFT JOIN children ch ON ch.id = sc.child_id
WHERE sc.child_id = ?
ORDER BY sc.started_at DESC, sc.id DESC LIMIT 1`, childID)
sc, err := scanRow(row)
if err == sql.ErrNoRows {
return st, nil
}
if err != nil {
return st, err
}
st.Last = &sc
return st, nil
}
// Summary is one stored brief (AGENTS.md §7 notifications): either a draft
// (SentAt nil) or a delivered brief. It is the summary half of FR-10.
type Summary struct {
ID int64
Recipient string
Text string
SentAt *time.Time
CreatedAt time.Time
}
// CreateSummary stores a brief for one recipient. sent_at stays NULL for a
// draft; Phase 7 sets it when the brief is sent.
func (s *Store) CreateSummary(ctx context.Context, recipient, text string) (int64, error) {
res, err := s.db.ExecContext(ctx,
`INSERT INTO notifications (recipient, summary, sent_at) VALUES (?, ?, NULL)`,
recipient, text)
if err != nil {
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("create summary for %q: %w", recipient, err)
}
return id, nil
}
// ListSummaries returns recent briefs, newest first.
func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) {
if limit <= 0 {
limit = 20
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, recipient, summary, sent_at, created_at
FROM notifications ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list summaries: %w", err)
}
defer rows.Close()
var out []Summary
for rows.Next() {
var sm Summary
var sent sql.NullString
var created string
if err := rows.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
return nil, fmt.Errorf("scan summary: %w", err)
}
if sent.Valid {
t := parseTime(sent.String)
sm.SentAt = &t
}
sm.CreatedAt = parseTime(created)
out = append(out, sm)
}
return out, rows.Err()
}
// SummaryByID returns one brief, or nil when it does not exist.
func (s *Store) SummaryByID(ctx context.Context, id int64) (*Summary, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, recipient, summary, sent_at, created_at FROM notifications WHERE id = ?`, id)
var sm Summary
var sent sql.NullString
var created string
if err := row.Scan(&sm.ID, &sm.Recipient, &sm.Text, &sent, &created); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("summary %d: %w", id, err)
}
if sent.Valid {
t := parseTime(sent.String)
sm.SentAt = &t
}
sm.CreatedAt = parseTime(created)
return &sm, nil
}
// DeleteSummary removes one stored brief. It returns the number of rows removed.
func (s *Store) DeleteSummary(ctx context.Context, id int64) (int64, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM notifications WHERE id = ?`, id)
if err != nil {
return 0, fmt.Errorf("delete summary %d: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("delete summary %d: %w", id, err)
}
return n, nil
}

View File

@@ -0,0 +1,132 @@
package store
import (
"context"
"testing"
"time"
)
// TestItemsForChildAndDelete covers the Phase 5 reads and the Delete half of
// FR-10: one child's items are read alone, one item is fetched by id, and
// deleting it removes exactly one row.
func TestItemsForChildAndDelete(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
due := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
{Source: "compass", Type: "task", Subject: "English", Title: "Essay", ExternalRef: "h1", DueAt: &due, Body: "Status: Overdue"},
{Source: "compass", Type: "message", Title: "Notice", ExternalRef: "h2"},
}); err != nil {
t.Fatalf("upsert harry: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
{Source: "compass", Type: "task", Title: "Lab report", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
rows, err := st.ItemsForChild(ctx, harry.ID)
if err != nil {
t.Fatalf("items for child: %v", err)
}
if len(rows) != 2 {
t.Fatalf("harry rows = %d, want 2", len(rows))
}
var essay *Item
for i := range rows {
if rows[i].ChildID != harry.ID {
t.Errorf("row belongs to child %d", rows[i].ChildID)
}
if rows[i].ExternalRef == "h1" {
essay = &rows[i]
}
}
if essay == nil || essay.DueAt == nil || !essay.DueAt.Equal(due) {
t.Fatalf("essay due not stored: %+v", essay)
}
got, err := st.ItemByID(ctx, essay.ID)
if err != nil || got == nil {
t.Fatalf("item by id: %v %v", got, err)
}
if got.Title != "Essay" || got.Child != "Harry" {
t.Errorf("item by id = %+v", got)
}
n, err := st.DeleteItemByID(ctx, essay.ID)
if err != nil || n != 1 {
t.Fatalf("delete = %d, %v", n, err)
}
if got, _ := st.ItemByID(ctx, essay.ID); got != nil {
t.Error("item survived delete")
}
if rows, _ := st.ItemsForChild(ctx, finn.ID); len(rows) != 1 {
t.Errorf("finn rows = %d, want 1 (delete must not cross children)", len(rows))
}
// Deleting a missing id is a quiet no-op, not an error.
if n, err := st.DeleteItemByID(ctx, essay.ID); err != nil || n != 0 {
t.Errorf("second delete = %d, %v", n, err)
}
}
// TestSummariesRoundTrip covers the stored brief: create a draft, list it,
// fetch it and delete it (FR-10 summary half).
func TestSummariesRoundTrip(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
id, err := st.CreateSummary(ctx, "Harry", "Harry — 1 overdue.\n\nOVERDUE\n- Essay\n")
if err != nil || id == 0 {
t.Fatalf("create: %v id=%d", err, id)
}
list, err := st.ListSummaries(ctx, 10)
if err != nil || len(list) != 1 {
t.Fatalf("list: %v len=%d", err, len(list))
}
if list[0].Recipient != "Harry" || list[0].SentAt != nil {
t.Errorf("summary = %+v, want draft for Harry", list[0])
}
got, err := st.SummaryByID(ctx, id)
if err != nil || got == nil || got.Text != list[0].Text {
t.Fatalf("by id: %v %+v", err, got)
}
n, err := st.DeleteSummary(ctx, id)
if err != nil || n != 1 {
t.Fatalf("delete = %d, %v", n, err)
}
if got, _ := st.SummaryByID(ctx, id); got != nil {
t.Error("summary survived delete")
}
}
// TestScanSummaryForChild proves a failed scan for one child is visible for
// that child even when the other child has not been scanned (FR-12).
func TestScanSummaryForChild(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
if _, err := st.RecordScan(ctx, &finn.ID, 0, 0, false, "login challenge"); err != nil {
t.Fatalf("record: %v", err)
}
if _, err := st.RecordScan(ctx, &harry.ID, 3, 12, true, ""); err != nil {
t.Fatalf("record: %v", err)
}
hs, err := st.ScanSummaryForChild(ctx, harry.ID)
if err != nil || !hs.EverRun || hs.Last == nil || !hs.Last.OK {
t.Fatalf("harry scan summary = %+v, %v", hs, err)
}
fs, err := st.ScanSummaryForChild(ctx, finn.ID)
if err != nil || !fs.EverRun || fs.Last == nil || fs.Last.OK {
t.Fatalf("finn scan summary = %+v, %v", fs, err)
}
if fs.Failed != 1 {
t.Errorf("finn failed = %d, want 1", fs.Failed)
}
}

View File

@@ -1,6 +1,7 @@
// Package web is the HTTP surface of the Compass/NHS service: /health, the // Package web is the HTTP surface of the Compass/NHS service: /health, the
// service's own session login, and the server-rendered HTMX dashboard // service's own session login, and the server-rendered HTMX dashboard
// (AGENTS.md §5, §9). // (AGENTS.md §5, §9). Phase 5 adds the per-child dashboard, the item detail
// screen, and the Delete actions (FR-7, FR-10, FR-12).
package web package web
import ( import (
@@ -12,11 +13,13 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
) )
@@ -57,7 +60,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /login", s.handleLoginSubmit)
mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("POST /logout", s.handleLogout)
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard)) mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial)) mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
staticSub, err := fs.Sub(staticFS, "static") staticSub, err := fs.Sub(staticFS, "static")
@@ -130,7 +138,7 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
} }
} }
// ---------- handlers ---------- // ---------- health ----------
type healthResponse struct { type healthResponse struct {
Status string `json:"status"` Status string `json:"status"`
@@ -164,6 +172,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(resp) _ = json.NewEncoder(w).Encode(resp)
} }
// ---------- login ----------
type loginData struct { type loginData struct {
CSRF string CSRF string
Error string Error string
@@ -220,19 +230,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther) http.Redirect(w, r, "/login", http.StatusSeeOther)
} }
// scanStatusView is the fail-loudly view of scan health (FR-12). It is shared // ---------- fail-loudly scan banner (FR-12) ----------
// by the dashboard and the HTMX partial.
// scanStatusView is the fail-loudly view of scan health. It is shared by the
// dashboard, the item detail page and the HTMX partial. The template never
// renders an empty day as "nothing due".
type scanStatusView struct { type scanStatusView struct {
store.ScanStatus store.ScanStatus
// Level is "none", "error", "ok" or "warn". The template never renders an // Level is "none", "error", "ok" or "warn".
// empty day as "nothing due".
Level string Level string
Message string Message string
LastAt string LastAt string
} }
func (s *Server) scanStatus(ctx context.Context) scanStatusView { // scanStatusFor builds the banner from a scan summary. The caller supplies the
st, err := s.store.ScanSummary(ctx) // summary so the dashboard can show the global view and the per-child view.
func (s *Server) scanStatusFor(st store.ScanStatus, err error) scanStatusView {
if err != nil { if err != nil {
s.log.Error("scan summary", "err", err) s.log.Error("scan summary", "err", err)
return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()} return scanStatusView{Level: "error", Message: "Could not read the scan log: " + err.Error()}
@@ -246,6 +259,9 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
if st.Last != nil && !st.Last.OK { if st.Last != nil && !st.Last.OK {
v.Level = "error" v.Level = "error"
v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN." v.Message = "SCAN FAILED — the last scan did not complete, so due work is UNKNOWN."
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04") v.LastAt = st.Last.StartedAt.Format("Mon 02 Jan 2006 15:04")
return v return v
} }
@@ -257,50 +273,413 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
return v return v
} }
type childView struct { func (s *Server) scanStatus(ctx context.Context) scanStatusView {
store.Child st, err := s.store.ScanSummary(ctx)
return s.scanStatusFor(st, err)
}
// scanOK reports whether the banner is healthy, so an empty section may say
// "nothing due" instead of "unknown".
func (v scanStatusView) OK() bool { return v.Level == "ok" }
// ---------- dashboard (FR-6, FR-7) ----------
type itemView struct {
store.Item
DueLabel string
StatusLabel string
TypeLabel string
BodyLines []string
}
type sectionView struct {
Key string
Title string
Tone string
Empty string
Items []itemView
}
type childPanel struct {
ID int64
Name string
Year string
Headline string
Overdue int
Scan scanStatusView
LaterCount int
DoneCount int
Sections []sectionView
}
type childTab struct {
ID int64
Name string
Overdue int
Active bool
}
type summaryView struct {
ID int64
Recipient string
Created string
Sent string
Draft bool
Text string
} }
type dashboardData struct { type dashboardData struct {
Title string Title string
Username string Username string
CSRF string CSRF string
Version string Version string
AppEnv string AppEnv string
Children []childView Scan scanStatusView
Scan scanStatusView Tabs []childTab
ServerUp string Child childPanel
ErrMsg string HasChild bool
Summaries []summaryView
ServerUp string
ErrMsg string
} }
var serverStarted = time.Now() var serverStarted = time.Now()
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()
children, err := s.store.ListChildren(ctx) loc := s.cfg.Location
if err != nil { data := dashboardData{
s.log.Error("list children", "err", err) Title: "What is due",
}
views := make([]childView, 0, len(children))
for _, c := range children {
views = append(views, childView{Child: c})
}
s.render(w, r, "dashboard.html", dashboardData{
Title: "Dashboard",
Username: s.cfg.AdminUsername, Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r), CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version, Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv, AppEnv: s.cfg.AppEnv,
Children: views,
Scan: s.scanStatus(ctx), Scan: s.scanStatus(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
}
children, err := s.store.ListChildren(ctx)
if err != nil {
s.log.Error("list children", "err", err)
data.ErrMsg = "Could not read the children: " + err.Error()
}
// Which tab? ?child=ID, else the first child.
selected := int64(0)
if v, err := strconv.ParseInt(r.URL.Query().Get("child"), 10, 64); err == nil {
selected = v
}
found := false
for _, c := range children {
if selected == 0 || c.ID == selected {
if !found {
selected = c.ID
found = true
}
}
}
if !found && len(children) > 0 {
selected = children[0].ID
found = true
}
overdueByChild := map[int64]int{}
for _, c := range children {
items, err := s.store.ItemsForChild(ctx, c.ID)
if err != nil {
s.log.Error("items for child", "child", c.Name, "err", err)
continue
}
overdueByChild[c.ID] = len(dashboard.Classify(items, time.Now(), loc).Overdue)
}
for _, c := range children {
tab := childTab{ID: c.ID, Name: c.Name, Overdue: overdueByChild[c.ID], Active: c.ID == selected}
data.Tabs = append(data.Tabs, tab)
if c.ID == selected {
data.Child = s.buildChildPanel(ctx, c, loc)
data.HasChild = true
}
}
summaries, err := s.store.ListSummaries(ctx, 10)
if err != nil {
s.log.Error("list summaries", "err", err)
}
for _, sm := range summaries {
v := summaryView{
ID: sm.ID,
Recipient: sm.Recipient,
Created: sm.CreatedAt.Format("Mon 02 Jan 15:04"),
Text: sm.Text,
Draft: sm.SentAt == nil,
}
if sm.SentAt != nil {
v.Sent = sm.SentAt.Format("Mon 02 Jan 15:04")
} else {
v.Sent = "draft"
}
data.Summaries = append(data.Summaries, v)
}
s.render(w, r, "dashboard.html", data)
}
// buildChildPanel classifies one child's items and prepares the sections.
func (s *Server) buildChildPanel(ctx context.Context, c store.Child, loc *time.Location) childPanel {
items, err := s.store.ItemsForChild(ctx, c.ID)
if err != nil {
s.log.Error("items for child", "child", c.Name, "err", err)
}
b := dashboard.Classify(items, time.Now(), loc)
sc, scErr := s.store.ScanSummaryForChild(ctx, c.ID)
scan := s.scanStatusFor(sc, scErr)
p := childPanel{
ID: c.ID,
Name: c.Name,
Year: c.Year,
Headline: b.Headline(),
Overdue: len(b.Overdue),
Scan: scan,
LaterCount: len(b.Later),
DoneCount: len(b.Done),
}
known := scan.OK()
p.Sections = []sectionView{
{Key: "overdue", Title: "Overdue", Tone: "danger", Items: itemViews(b.Overdue, loc),
Empty: emptyText(known, "Nothing overdue.")},
{Key: "today", Title: "Due today", Items: itemViews(b.DueToday, loc),
Empty: emptyText(known, "Nothing due today.")},
{Key: "week", Title: "Due this week", Items: itemViews(b.DueWeek, loc),
Empty: emptyText(known, "Nothing else due this week.")},
}
if len(b.NoDue) > 0 {
p.Sections = append(p.Sections, sectionView{Key: "nodue", Title: "No due date",
Items: itemViews(b.NoDue, loc)})
}
p.Sections = append(p.Sections, sectionView{Key: "messages", Title: "Messages",
Items: itemViews(b.Messages, loc), Empty: emptyText(known, "No messages.")})
return p
}
// emptyText never turns a failed or missing scan into "nothing due" (FR-12).
func emptyText(scanKnown bool, ok string) string {
if scanKnown {
return ok
}
return "Unknown — the last scan did not complete."
}
func itemViews(items []store.Item, loc *time.Location) []itemView {
out := make([]itemView, 0, len(items))
for _, it := range items {
v := itemView{Item: it, TypeLabel: typeLabel(it.Type)}
if it.DueAt != nil {
v.DueLabel = it.DueAt.In(loc).Format("Mon 02 Jan 15:04")
}
if st := dashboard.Status(it.Body); st != "" && st != "pending" {
v.StatusLabel = strings.ToUpper(st[:1]) + st[1:]
}
if strings.TrimSpace(it.Body) != "" {
v.BodyLines = strings.Split(strings.TrimSpace(it.Body), "\n")
}
out = append(out, v)
}
return out
}
func typeLabel(t string) string {
switch t {
case "task":
return "Task"
case "event":
return "Event"
case "message":
return "Message"
default:
return t
}
}
// ---------- item detail + delete (FR-10, §5 screen 3) ----------
type itemData struct {
Title string
Username string
CSRF string
Version string
AppEnv string
Item itemView
Child store.Child
Scan scanStatusView
ServerUp string
}
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
it, err := s.store.ItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if it == nil {
http.NotFound(w, r)
return
}
child, err := s.store.ChildByID(ctx, it.ChildID)
if err != nil || child == nil {
child = &store.Child{ID: it.ChildID, Name: it.Child}
}
s.render(w, r, "item.html", itemData{
Title: it.Title,
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
Item: itemViews([]store.Item{*it}, s.cfg.Location)[0],
Child: *child,
Scan: s.scanStatus(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}) })
} }
func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("delete rejected: bad csrf token", "path", r.URL.Path)
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
it, err := s.store.ItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if it == nil {
http.NotFound(w, r)
return
}
if _, err := s.store.DeleteItemByID(ctx, id); err != nil {
s.log.Error("delete item", "id", id, "err", err)
http.Error(w, "could not delete the item", http.StatusInternalServerError)
return
}
s.log.Info("item deleted", "id", id, "child", it.Child, "title", it.Title)
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(it.ChildID, 10))
}
func (s *Server) handleSummaryBuild(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
childID, err := strconv.ParseInt(r.PostFormValue("child"), 10, 64)
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
child, err := s.store.ChildByID(ctx, childID)
if err != nil || child == nil {
http.NotFound(w, r)
return
}
items, err := s.store.ItemsForChild(ctx, childID)
if err != nil {
s.log.Error("items for child", "child", child.Name, "err", err)
http.Error(w, "could not read the items", http.StatusInternalServerError)
return
}
text := dashboard.Render(child.Name, dashboard.Classify(items, time.Now(), s.cfg.Location), s.cfg.Location)
id, err := s.store.CreateSummary(ctx, child.Name, text)
if err != nil {
s.log.Error("create summary", "child", child.Name, "err", err)
http.Error(w, "could not store the summary", http.StatusInternalServerError)
return
}
s.log.Info("summary built", "id", id, "recipient", child.Name)
s.redirectAfterWrite(w, r, "/?child="+strconv.FormatInt(childID, 10))
}
func (s *Server) handleSummaryDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
if _, err := s.store.DeleteSummary(ctx, id); err != nil {
s.log.Error("delete summary", "id", id, "err", err)
http.Error(w, "could not delete the summary", http.StatusInternalServerError)
return
}
s.log.Info("summary deleted", "id", id)
s.redirectAfterWrite(w, r, "/")
}
// redirectAfterWrite answers an HTMX request with 204 so the element can be
// removed, and a plain form post with a redirect to the dashboard.
func (s *Server) redirectAfterWrite(w http.ResponseWriter, r *http.Request, to string) {
if r.Header.Get("HX-Request") != "" {
w.WriteHeader(http.StatusNoContent)
return
}
http.Redirect(w, r, to, http.StatusSeeOther)
}
func pathID(r *http.Request, name string) (int64, bool) {
v, err := strconv.ParseInt(r.PathValue(name), 10, 64)
if err != nil || v <= 0 {
return 0, false
}
return v, true
}
// ---------- partials ----------
func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) { func (s *Server) handleScanStatusPartial(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel() defer cancel()

309
internal/web/server_test.go Normal file
View File

@@ -0,0 +1,309 @@
package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
"time"
_ "time/tzdata"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
const (
testUser = "sam"
testPass = "correct horse battery staple"
)
var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
t.Helper()
loc, err := time.LoadLocation("Australia/Sydney")
if err != nil {
t.Fatalf("location: %v", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry", "Finn"); err != nil {
t.Fatalf("seed: %v", err)
}
cfg := &config.Config{
Addr: ":0",
DBPath: "test",
SessionSecret: []byte("0123456789abcdef0123456789abcdef"),
AdminUsername: testUser,
AdminPassword: testPass,
SessionTTL: time.Hour,
AppEnv: "test",
Version: "test",
Location: loc,
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil {
t.Fatalf("auth: %v", err)
}
srv, err := New(cfg, st, am, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("web: %v", err)
}
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return ts, st
}
// login signs in and returns an HTTP client with the session cookie.
func login(t *testing.T, ts *httptest.Server) *http.Client {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatalf("jar: %v", err)
}
client := &http.Client{Jar: jar}
page := getBody(t, client, ts.URL+"/login")
token := csrfToken(t, page)
form := url.Values{"csrf": {token}, "username": {testUser}, "password": {testPass}}
resp, err := client.PostForm(ts.URL+"/login", form)
if err != nil {
t.Fatalf("login: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("login status = %d, want 200", resp.StatusCode)
}
// After login, do not follow redirects: a mutation's 303 is the assertion.
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
return client
}
func getBody(t *testing.T, client *http.Client, url string) string {
t.Helper()
resp, err := client.Get(url)
if err != nil {
t.Fatalf("get %s: %v", url, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("get %s = %d, want 200", url, resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
return string(b)
}
func csrfToken(t *testing.T, html string) string {
t.Helper()
m := csrfRe.FindStringSubmatch(html)
if m == nil {
t.Fatalf("no csrf token in page")
}
return m[1]
}
// TestDashboardShowsChildItems proves FR-7: the selected child's items appear,
// overdue first, and another child's items do not leak in.
func TestDashboardShowsChildItems(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
overdue := time.Date(2026, 10, 10, 9, 0, 0, 0, time.UTC)
today := time.Now().Add(2 * time.Hour)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Title: "Overdue essay", ExternalRef: "h1", DueAt: &overdue, Body: "Status: Overdue"},
{Source: "compass", Type: "task", Subject: "11MGMA", Title: "Today chapter", ExternalRef: "h2", DueAt: &today, Body: "Status: Pending"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []store.Item{
{Source: "compass", Type: "task", Title: "Finn secret task", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
for _, want := range []string{
"Overdue essay", "Today chapter", "Overdue", "Due today", "11EENG",
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard missing %q", want)
}
}
if strings.Contains(body, "Finn secret task") {
t.Error("Harry's dashboard leaked Finn's item")
}
oi := strings.Index(body, "Overdue essay")
ti := strings.Index(body, "Today chapter")
if oi < 0 || ti < 0 || oi > ti {
t.Error("overdue item must render before the due-today item")
}
}
// TestItemDetailAndDelete proves §5 screen 3 and the Delete half of FR-10.
func TestItemDetailAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{
{Source: "compass", Type: "task", Subject: "11EENG", Teacher: "Ms Lee",
Title: "Essay draft", Body: "Status: Overdue\nClass: Class Task", ExternalRef: "h1"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
if len(items) != 1 {
t.Fatalf("items = %d, want 1", len(items))
}
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
for _, want := range []string{"Essay draft", "11EENG", "Ms Lee", "Class Task"} {
if !strings.Contains(detail, want) {
t.Errorf("detail missing %q", want)
}
}
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("delete status = %d, want 303", resp.StatusCode)
}
if got, _ := st.ItemByID(ctx, id); got != nil {
t.Error("item survived delete")
}
// The delete without a CSRF token is refused.
resp2, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/delete", url.Values{})
if err != nil {
t.Fatalf("delete2: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusForbidden {
t.Errorf("tokenless delete = %d, want 403", resp2.StatusCode)
}
}
// TestFailLoudlyBanner proves FR-12: no scan is shown as NO SCAN, and a failed
// scan is shown as SCAN FAILED, never as an empty day.
func TestFailLoudlyBanner(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
body := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "NO SCAN HAS RUN YET") {
t.Error("missing NO SCAN HAS RUN YET banner")
}
if strings.Contains(body, "Nothing overdue.") {
t.Error("an unscanned child must not read as 'nothing overdue'")
}
if !strings.Contains(body, "Unknown — the last scan did not complete.") {
t.Error("missing the unknown empty state for a child with no scan")
}
if _, err := st.RecordScan(ctx, &harry.ID, 1, 0, false, "Cloudflare challenge"); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "SCAN FAILED") {
t.Error("missing SCAN FAILED banner")
}
if !strings.Contains(body, "Cloudflare challenge") {
t.Error("failure message not shown")
}
if _, err := st.RecordScan(ctx, &harry.ID, 4, 9, true, ""); err != nil {
t.Fatalf("record: %v", err)
}
body = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(body, "Last scan completed.") {
t.Error("healthy scan banner missing")
}
if !strings.Contains(body, "Nothing overdue.") {
t.Error("a healthy scan should allow the quiet empty state")
}
}
// TestSummaryBuildAndDelete proves the stored brief round-trips through the
// dashboard (FR-10 summary half).
func TestSummaryBuildAndDelete(t *testing.T) {
ts, st := newTestServer(t)
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
client := login(t, ts)
page := getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
resp, err := client.PostForm(ts.URL+"/summaries/build",
url.Values{"csrf": {csrfToken(t, page)}, "child": {itoa(harry.ID)}})
if err != nil {
t.Fatalf("build: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSeeOther {
t.Fatalf("build status = %d, want 303", resp.StatusCode)
}
summaries, err := st.ListSummaries(ctx, 10)
if err != nil || len(summaries) != 1 {
t.Fatalf("summaries = %d, %v", len(summaries), err)
}
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Harry — 0 overdue") {
t.Errorf("brief not shown on dashboard: %s", snippet(page))
}
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",
url.Values{"csrf": {csrfToken(t, page)}})
if err != nil {
t.Fatalf("delete: %v", err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusSeeOther {
t.Fatalf("summary delete = %d, want 303", resp2.StatusCode)
}
if got, _ := st.SummaryByID(ctx, summaries[0].ID); got != nil {
t.Error("summary survived delete")
}
}
func itoa(v int64) string { return strconv.FormatInt(v, 10) }
func snippet(s string) string {
if len(s) > 400 {
return s[:400]
}
return s
}

View File

@@ -316,6 +316,173 @@ a:hover { text-decoration: underline; }
} }
.app-foot__sep { color: var(--ink-faint); } .app-foot__sep { color: var(--ink-faint); }
/* ---------- Child tabs (Phase 5) ---------- */
.child-tabs {
display: flex;
gap: var(--space-xs);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
padding-bottom: var(--space-xxs);
}
.child-tab {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: 44px;
padding: 8px 18px;
border: 1px solid var(--hairline);
border-radius: var(--rounded-full);
background: var(--surface);
color: var(--ink-secondary);
font: var(--type-button);
white-space: nowrap;
}
.child-tab:hover { text-decoration: none; box-shadow: var(--shadow-level-1); }
.child-tab--active {
background: var(--primary);
border-color: var(--primary);
color: var(--on-primary);
}
.child-tab__badge {
min-width: 20px;
padding: 0 6px;
border-radius: var(--rounded-full);
background: var(--sticker-red);
color: #ffffff;
font: var(--type-eyebrow);
text-align: center;
}
.heading-3 { font: var(--type-title); color: var(--ink); }
.heading-3--danger { color: var(--sticker-red); }
.dot--danger { background: var(--sticker-red); }
/* ---------- Child panel ---------- */
.child { display: grid; gap: var(--space-lg); }
.child__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.child__meta { margin: var(--space-xxs) 0 0; font: var(--type-body-sm); color: var(--ink-muted); }
.child__foot {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-md);
font: var(--type-caption);
color: var(--ink-muted);
}
.inline-form { margin: 0; }
.scan__pill--ok { background: color-mix(in srgb, var(--sticker-green) 16%, var(--surface)); color: #0f7a28; }
.scan__pill--warn { background: color-mix(in srgb, var(--sticker-orange) 16%, var(--surface)); color: var(--sticker-orange-deep); }
.scan__pill--error { background: color-mix(in srgb, var(--sticker-red) 16%, var(--surface)); color: var(--sticker-red); }
/* ---------- Item list ---------- */
.item-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--space-sm);
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.item--overdue { border-left: 4px solid var(--sticker-red); }
.item__body { min-width: 0; display: grid; gap: var(--space-xxs); }
.item__title { margin: 0; font: var(--type-title); color: var(--ink); overflow-wrap: anywhere; }
.item__meta {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
font: var(--type-caption);
color: var(--ink-muted);
}
.item__subject { color: var(--ink-secondary); }
.item__due { color: var(--ink-secondary); }
.item__actions { margin: 0; flex: 0 0 auto; }
.tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--rounded-full);
font: var(--type-eyebrow);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tag--type { background: var(--surface-2); color: var(--ink-muted); }
.tag--status { background: color-mix(in srgb, var(--sticker-red) 14%, var(--surface)); color: var(--sticker-red); }
.button-utility--danger { color: var(--sticker-red); border-color: color-mix(in srgb, var(--sticker-red) 45%, var(--hairline)); }
.button-utility--danger:hover { background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface)); }
.section__empty { margin: 0; padding: var(--space-sm) var(--space-md); font: var(--type-body-sm); color: var(--ink-muted); }
.brief {
margin: var(--space-xs) 0 0;
padding: var(--space-sm) var(--space-md);
background: var(--surface-2);
border-radius: var(--rounded-sm);
font: var(--type-caption);
color: var(--ink-secondary);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
/* ---------- Item detail ---------- */
.breadcrumb { margin: 0; font: var(--type-body-sm); }
.detail {
display: grid;
gap: var(--space-lg);
padding: var(--space-xl);
background: var(--surface);
border: 1px solid var(--hairline);
border-radius: var(--rounded-lg);
box-shadow: var(--shadow-level-1);
}
.detail__head { display: grid; gap: var(--space-xs); }
.detail__meta {
margin: 0;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
font: var(--type-body-sm);
color: var(--ink-muted);
}
.detail__body { display: grid; gap: var(--space-xs); }
.detail__body p { margin: 0; font: var(--type-body-md); color: var(--ink-secondary); overflow-wrap: anywhere; }
.detail__facts {
display: grid;
grid-template-columns: minmax(90px, auto) 1fr;
gap: var(--space-xxs) var(--space-md);
margin: 0;
font: var(--type-body-sm);
}
.detail__facts dt { color: var(--ink-muted); }
.detail__facts dd { margin: 0; color: var(--ink-secondary); overflow-wrap: anywhere; }
.detail__actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
.detail__actions form { margin: 0; }
/* ---------- Responsive ---------- */ /* ---------- Responsive ---------- */
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -325,4 +492,12 @@ a:hover { text-decoration: underline; }
.nav-bar { padding: var(--space-sm) var(--space-md); } .nav-bar { padding: var(--space-sm) var(--space-md); }
.nav-bar__user { display: none; } .nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; } .section__head { flex-wrap: wrap; }
.detail { padding: var(--space-lg); }
}
@media (max-width: 520px) {
.item { flex-direction: column; align-items: stretch; gap: var(--space-sm); }
.item__actions { align-self: flex-end; }
.item__actions .button-utility { width: 100%; }
.child__head { flex-direction: column; }
} }

View File

@@ -29,8 +29,8 @@
<main class="container"> <main class="container">
<header class="page-head"> <header class="page-head">
<h1 class="heading-1">What is due</h1> <h1 class="heading-1">What is due</h1>
<p class="page-head__sub">Overdue work first. Compass for the schedule, Teams for homework. <p class="page-head__sub">Overdue first. Compass for the schedule and notices,
This is Phase 1: the skeleton is live, the school scan is not connected yet.</p> Teams for homework.</p>
</header> </header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}} {{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
@@ -39,32 +39,116 @@
{{template "scan-status.html" .Scan}} {{template "scan-status.html" .Scan}}
</div> </div>
<section class="section" aria-labelledby="children-title"> {{if .Tabs}}
<div class="section__head"> <nav class="child-tabs" aria-label="Children">
<h2 id="children-title" class="heading-2">Children</h2> {{range .Tabs}}
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Children}} on file</span> <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> </div>
{{if .Children}}
<ul class="child-list"> {{if not .Child.Scan.OK}}
{{range .Children}} <p class="flash flash--error" role="alert">
<li class="child-card"> {{.Child.Scan.Message}} The lists below may be incomplete — this is not a clean
<p class="child-card__name">{{.Name}}</p> “nothing due”.
<p class="child-card__meta"> </p>
{{if .Year}}Year {{.Year}}{{else}}Year not set{{end}} {{end}}
{{if not .CompassID}}· Compass ID not set{{end}}
</p> {{range .Child.Sections}}
<p class="child-card__body">No items stored yet. The Compass scan arrives in Phase 2.</p> <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> </li>
{{end}} {{end}}
</ul> </ul>
{{else}} {{else}}
<div class="empty-state"> <p class="section__empty">{{.Empty}}</p>
<p class="empty-state__title">No children on file</p> {{end}}
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty, </section>
the database was not seeded.</p>
</div>
{{end}} {{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> </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 .Summaries}}
<section class="section" aria-labelledby="summaries-title">
<div class="section__head">
<h2 id="summaries-title" class="heading-2">Briefs</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
</div>
<ul class="item-list">
{{range .Summaries}}
<li class="item" id="summary-{{.ID}}">
<div class="item__body">
<p class="item__title">{{.Recipient}} · {{.Sent}}</p>
<p class="item__meta"><span class="item__subject">built {{.Created}}</span></p>
<pre class="brief">{{.Text}}</pre>
</div>
<form class="item__actions" method="post" action="/summaries/{{.ID}}/delete"
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this brief?">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li>
{{end}}
</ul>
</section>
{{end}}
</main> </main>
<footer class="app-foot"> <footer class="app-foot">

View File

@@ -0,0 +1,81 @@
{{define "item.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">
<p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p>
<article class="detail">
<header class="detail__head">
<h1 class="heading-1">{{.Item.Title}}</h1>
<p class="detail__meta">
<span class="tag tag--type">{{.Item.TypeLabel}}</span>
{{if .Item.Subject}}<span>{{.Item.Subject}}</span>{{end}}
{{if .Item.Teacher}}<span>Teacher: {{.Item.Teacher}}</span>{{end}}
{{if .Item.DueLabel}}<span class="item__due">due {{.Item.DueLabel}}</span>{{end}}
{{if .Item.StatusLabel}}<span class="tag tag--status">{{.Item.StatusLabel}}</span>{{end}}
</p>
</header>
{{if .Item.BodyLines}}
<section class="detail__body">
{{range .Item.BodyLines}}<p>{{.}}</p>{{end}}
</section>
{{end}}
<dl class="detail__facts">
<dt>Child</dt><dd>{{.Child.Name}}</dd>
<dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd>
{{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}}
{{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}}
{{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}}
</dl>
<div class="detail__actions">
{{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}}
<form method="post" action="/items/{{.Item.ID}}/delete"
hx-post="/items/{{.Item.ID}}/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>
</div>
{{if not .Scan.OK}}
<p class="flash flash--error" role="alert">{{.Scan.Message}}</p>
{{end}}
</article>
</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}}

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* Phone-width screenshots of the Phase 5 dashboard.
*
* Evidence for AUD-109: the per-child dashboard (overdue first) and the item
* detail screen at a phone width (390 x 844). It logs into the live service with
* the dashboard session login, then follows the child tabs and the first overdue
* item.
*
* Usage (secrets from the environment, never committed):
* SCHOOL_USER=sam SCHOOL_PASS=... node scripts/screenshot-phase5.mjs
*
* Environment:
* SCHOOL_BASE default https://school.lab.audasmedia.com.au
* SCHOOL_SHOT_DIR default var/screenshots (git-ignored)
* COMPASS_CHROME default /etc/profiles/per-user/sam/bin/google-chrome
*
* The output is personal data (children's homework). It stays under var/
* (git-ignored) and is only uploaded as issue evidence.
*/
import { chromium } from "patchright";
import fs from "node:fs";
import path from "node:path";
const BASE = (process.env.SCHOOL_BASE || "https://school.lab.audasmedia.com.au").replace(/\/$/, "");
const USER = process.env.SCHOOL_USER;
const PASS = process.env.SCHOOL_PASS;
const OUT = path.resolve(process.env.SCHOOL_SHOT_DIR || "var/screenshots");
const CHROME = process.env.COMPASS_CHROME || "/etc/profiles/per-user/sam/bin/google-chrome";
if (!USER || !PASS) {
console.error("school: SCHOOL_USER and SCHOOL_PASS are required");
process.exit(2);
}
const VIEWPORT = { width: 390, height: 844 }; // iPhone 14/15 class width
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-");
fs.mkdirSync(OUT, { recursive: true });
const browser = await chromium.launch({
headless: true,
executablePath: CHROME,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({
viewport: VIEWPORT,
deviceScaleFactor: 2,
locale: "en-AU",
timezoneId: "Australia/Sydney",
});
const page = await context.newPage();
try {
// 1. Sign in.
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.fill('input[name="username"]', USER);
await page.fill('input[name="password"]', PASS);
await Promise.all([
page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 20000 }),
page.click('button[type="submit"]'),
]);
await page.waitForSelector(".child-tabs, .empty-state", { timeout: 20000 });
// 2. One full-page shot per child, with that child selected.
const tabs = await page.$$eval(".child-tab", (nodes) =>
nodes.map((n) => ({ name: n.textContent.trim().split("\n")[0].trim(), href: n.getAttribute("href") })),
);
const shots = [];
for (const tab of tabs) {
await page.goto(`${BASE}${tab.href}`, { waitUntil: "networkidle" });
await page.waitForSelector(".child", { timeout: 20000 });
const file = path.join(OUT, `dashboard-${slug(tab.name)}.png`);
await page.screenshot({ path: file, fullPage: true });
shots.push(file);
console.log(`shot ${file}`);
// The top of the page at phone width: the scan banner, the tabs and the
// overdue list, which is what Sam sees first.
const topFile = path.join(OUT, `dashboard-${slug(tab.name)}-top.png`);
await page.screenshot({ path: topFile });
shots.push(topFile);
console.log(`shot ${topFile}`);
// 3. Item detail of the first overdue item, when the child has one.
const link = await page.$("#sec-overdue .item__title a");
if (link) {
await Promise.all([page.waitForLoadState("networkidle"), link.click()]);
await page.waitForSelector(".detail", { timeout: 20000 });
const detailFile = path.join(OUT, `item-detail-${slug(tab.name)}.png`);
await page.screenshot({ path: detailFile, fullPage: true });
shots.push(detailFile);
console.log(`shot ${detailFile}`);
} else {
console.log(`no overdue item for ${tab.name}`);
}
}
// 4. The scan banner on its own, cropped to the top of the dashboard.
await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForSelector(".scan", { timeout: 20000 });
const bannerFile = path.join(OUT, "scan-banner.png");
await page.locator(".scan").screenshot({ path: bannerFile });
shots.push(bannerFile);
console.log(`shot ${bannerFile}`);
await sleep(100);
console.log(`done: ${shots.length} screenshot(s) in ${OUT}`);
} finally {
await browser.close();
}