diff --git a/Makefile b/Makefile index 6883c78..67d2b72 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.3.0-phase4 -BUILD_VERSION ?= 0.3.0-phase4 +IMAGE ?= compass-teams-nhs/school:0.3.0-phase5 +BUILD_VERSION ?= 0.3.0-phase5 # Parse options (see scripts/parse-compass.sh). CHILD ?= Harry diff --git a/README.md b/README.md index ea99f74..bae099c 100644 --- a/README.md +++ b/README.md @@ -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, 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 -> children, per-child sessions). See §11 of the spec for the phase order. +> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 5** (the +> 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 @@ -41,9 +55,9 @@ Built and deployed on `.13`: - Its own container and port **8792**, behind Caddy at `school.lab.audasmedia.com.au`. -Not built yet (later phases): the dashboard data, the calendar write, -notifications, and the Hub API. The service itself does **not** scan the school; -n8n triggers the scan later. Teams (FR-15) is a separate source. +Not built yet (later phases): the calendar write, notifications and the Hub API. +The service itself does **not** scan the school; n8n triggers the scan later. +Teams (FR-15) is a separate source. ### Fail loudly (FR-12) @@ -80,8 +94,8 @@ docker compose up -d --build Generate secrets with the built image: ```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-phase4 hash-password 'the-password' +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-phase5 hash-password 'the-password' ``` Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash diff --git a/cmd/school/brief.go b/cmd/school/brief.go new file mode 100644 index 0000000..0cf4676 --- /dev/null +++ b/cmd/school/brief.go @@ -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) +} diff --git a/cmd/school/main.go b/cmd/school/main.go index afd41e4..1e46fbc 100644 --- a/cmd/school/main.go +++ b/cmd/school/main.go @@ -53,6 +53,9 @@ func main() { case "items": itemsCommand(os.Args[2:]) return + case "brief": + briefCommand(os.Args[2:]) + return case "version": fmt.Println(version) return diff --git a/docker-compose.yml b/docker-compose.yml index 6f1c5b0..2e1a7de 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.3.0-phase4" - image: compass-teams-nhs/school:0.3.0-phase4 + VERSION: "0.3.0-phase5" + image: compass-teams-nhs/school:0.3.0-phase5 container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/phase-5-dashboard-notes.md b/docs/phase-5-dashboard-notes.md new file mode 100644 index 0000000..63d094f --- /dev/null +++ b/docs/phase-5-dashboard-notes.md @@ -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=` | 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. diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go new file mode 100644 index 0000000..727dbaa --- /dev/null +++ b/internal/dashboard/dashboard.go @@ -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 +} diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go new file mode 100644 index 0000000..347e3a9 --- /dev/null +++ b/internal/dashboard/dashboard_test.go @@ -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 +} diff --git a/internal/store/dashboard.go b/internal/store/dashboard.go new file mode 100644 index 0000000..ffd56c2 --- /dev/null +++ b/internal/store/dashboard.go @@ -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 +} diff --git a/internal/store/dashboard_test.go b/internal/store/dashboard_test.go new file mode 100644 index 0000000..151a2fa --- /dev/null +++ b/internal/store/dashboard_test.go @@ -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) + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 8dcc0a0..ec9554a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -1,6 +1,7 @@ // Package web is the HTTP surface of the Compass/NHS service: /health, the // 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 import ( @@ -12,11 +13,13 @@ import ( "io/fs" "log/slog" "net/http" + "strconv" "strings" "time" "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/dashboard" "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("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) + 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)) staticSub, err := fs.Sub(staticFS, "static") @@ -130,7 +138,7 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { } } -// ---------- handlers ---------- +// ---------- health ---------- type healthResponse struct { Status string `json:"status"` @@ -164,6 +172,8 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(resp) } +// ---------- login ---------- + type loginData struct { CSRF string Error string @@ -220,19 +230,22 @@ func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/login", http.StatusSeeOther) } -// scanStatusView is the fail-loudly view of scan health (FR-12). It is shared -// by the dashboard and the HTMX partial. +// ---------- fail-loudly scan banner (FR-12) ---------- + +// 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 { store.ScanStatus - // Level is "none", "error", "ok" or "warn". The template never renders an - // empty day as "nothing due". + // Level is "none", "error", "ok" or "warn". Level string Message string LastAt string } -func (s *Server) scanStatus(ctx context.Context) scanStatusView { - st, err := s.store.ScanSummary(ctx) +// scanStatusFor builds the banner from a scan summary. The caller supplies the +// 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 { s.log.Error("scan summary", "err", err) 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 { v.Level = "error" 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") return v } @@ -257,50 +273,413 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView { return v } -type childView struct { - store.Child +func (s *Server) scanStatus(ctx context.Context) scanStatusView { + 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 { - Title string - Username string - CSRF string - Version string - AppEnv string - Children []childView - Scan scanStatusView - ServerUp string - ErrMsg string + Title string + Username string + CSRF string + Version string + AppEnv string + Scan scanStatusView + Tabs []childTab + Child childPanel + HasChild bool + Summaries []summaryView + ServerUp string + ErrMsg string } var serverStarted = time.Now() 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() - children, err := s.store.ListChildren(ctx) - if err != nil { - s.log.Error("list children", "err", err) - } - 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", + loc := s.cfg.Location + data := dashboardData{ + Title: "What is due", Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, - Children: views, Scan: s.scanStatus(ctx), ServerUp: time.Since(serverStarted).Round(time.Second).String(), 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) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..6bf96e1 --- /dev/null +++ b/internal/web/server_test.go @@ -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 +} diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 11f74fd..555a65b 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -316,6 +316,173 @@ a:hover { text-decoration: underline; } } .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 ---------- */ @media (max-width: 760px) { @@ -325,4 +492,12 @@ a:hover { text-decoration: underline; } .nav-bar { padding: var(--space-sm) var(--space-md); } .nav-bar__user { display: none; } .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; } } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index 55781aa..08275a3 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -29,8 +29,8 @@

What is due

-

Overdue work first. Compass for the schedule, Teams for homework. - This is Phase 1: the skeleton is live, the school scan is not connected yet.

+

Overdue first. Compass for the schedule and notices, + Teams for homework.

{{if .ErrMsg}}{{end}} @@ -39,32 +39,116 @@ {{template "scan-status.html" .Scan}} -
-
-

Children

- {{len .Children}} on file + {{if .Tabs}} + + {{end}} + + {{if .HasChild}} +
+
+
+

{{.Child.Name}}

+

{{if .Child.Year}}Year {{.Child.Year}} · {{end}}{{.Child.Headline}}

+
+ + {{if eq .Child.Scan.Level "ok"}}scan healthy + {{else if eq .Child.Scan.Level "error"}}scan failed + {{else}}no scan yet{{end}} +
- {{if .Children}} -
    - {{range .Children}} -
  • -

    {{.Name}}

    -

    - {{if .Year}}Year {{.Year}}{{else}}Year not set{{end}} - {{if not .CompassID}}· Compass ID not set{{end}} -

    -

    No items stored yet. The Compass scan arrives in Phase 2.

    + + {{if not .Child.Scan.OK}} + + {{end}} + + {{range .Child.Sections}} +
    +
    +

    {{.Title}}

    + {{len .Items}} +
    + {{if .Items}} +
      + {{range .Items}} +
    • +
      +

      {{.Title}}

      +

      + {{.TypeLabel}} + {{if .Subject}}{{.Subject}}{{end}} + {{if .DueLabel}}due {{.DueLabel}}{{end}} + {{if .StatusLabel}}{{.StatusLabel}}{{end}} +

      +
      +
      + + +
    • {{end}}
    - {{else}} -
    -

    No children on file

    -

    Harry and Finn are seeded on first start. If this is empty, - the database was not seeded.

    -
    + {{else}} +

    {{.Empty}}

    + {{end}} +
    {{end}} + +
    + {{if .Child.LaterCount}}{{.Child.LaterCount}} due later{{end}} + {{if .Child.DoneCount}}{{.Child.DoneCount}} submitted{{end}} +
    + + + +
    +
+ {{else}} +
+

No children on file

+

Harry and Finn are seeded on first start. If this is empty, + the database was not seeded.

+
+ {{end}} + + {{if .Summaries}} +
+
+

Briefs

+ {{len .Summaries}} +
+
    + {{range .Summaries}} +
  • +
    +

    {{.Recipient}} · {{.Sent}}

    +

    built {{.Created}}

    +
    {{.Text}}
    +
    +
    + + +
    +
  • + {{end}} +
+
+ {{end}}