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

@@ -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
// 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()

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); }
/* ---------- 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; }
}

View File

@@ -29,8 +29,8 @@
<main class="container">
<header class="page-head">
<h1 class="heading-1">What is due</h1>
<p class="page-head__sub">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.</p>
<p class="page-head__sub">Overdue first. Compass for the schedule and notices,
Teams for homework.</p>
</header>
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
@@ -39,32 +39,116 @@
{{template "scan-status.html" .Scan}}
</div>
<section class="section" aria-labelledby="children-title">
<div class="section__head">
<h2 id="children-title" class="heading-2">Children</h2>
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Children}} on file</span>
{{if .Tabs}}
<nav class="child-tabs" aria-label="Children">
{{range .Tabs}}
<a class="child-tab{{if .Active}} child-tab--active{{end}}" href="/?child={{.ID}}"
{{if .Active}}aria-current="page"{{end}}>
{{.Name}}
{{if .Overdue}}<span class="child-tab__badge">{{.Overdue}}</span>{{end}}
</a>
{{end}}
</nav>
{{end}}
{{if .HasChild}}
<section class="child" aria-labelledby="child-title">
<div class="child__head">
<div>
<h2 id="child-title" class="heading-2">{{.Child.Name}}</h2>
<p class="child__meta">{{if .Child.Year}}Year {{.Child.Year}} · {{end}}{{.Child.Headline}}</p>
</div>
<span class="category-pill scan__pill scan__pill--{{.Child.Scan.Level}}">
{{if eq .Child.Scan.Level "ok"}}scan healthy
{{else if eq .Child.Scan.Level "error"}}scan failed
{{else}}no scan yet{{end}}
</span>
</div>
{{if .Children}}
<ul class="child-list">
{{range .Children}}
<li class="child-card">
<p class="child-card__name">{{.Name}}</p>
<p class="child-card__meta">
{{if .Year}}Year {{.Year}}{{else}}Year not set{{end}}
{{if not .CompassID}}· Compass ID not set{{end}}
</p>
<p class="child-card__body">No items stored yet. The Compass scan arrives in Phase 2.</p>
{{if not .Child.Scan.OK}}
<p class="flash flash--error" role="alert">
{{.Child.Scan.Message}} The lists below may be incomplete — this is not a clean
“nothing due”.
</p>
{{end}}
{{range .Child.Sections}}
<section class="section" id="sec-{{.Key}}" aria-labelledby="sec-{{.Key}}-title">
<div class="section__head">
<h3 id="sec-{{.Key}}-title" class="heading-3{{if eq .Tone "danger"}} heading-3--danger{{end}}">{{.Title}}</h3>
<span class="category-pill"><span class="dot{{if eq .Tone "danger"}} dot--danger{{end}}"></span>{{len .Items}}</span>
</div>
{{if .Items}}
<ul class="item-list">
{{range .Items}}
<li class="item{{if eq .StatusLabel "Overdue"}} item--overdue{{end}}" id="item-{{.ID}}">
<div class="item__body">
<p class="item__title"><a href="/items/{{.ID}}">{{.Title}}</a></p>
<p class="item__meta">
<span class="tag tag--type">{{.TypeLabel}}</span>
{{if .Subject}}<span class="item__subject">{{.Subject}}</span>{{end}}
{{if .DueLabel}}<span class="item__due">due {{.DueLabel}}</span>{{end}}
{{if .StatusLabel}}<span class="tag tag--status">{{.StatusLabel}}</span>{{end}}
</p>
</div>
<form class="item__actions" method="post" action="/items/{{.ID}}/delete"
hx-post="/items/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="button-utility button-utility--danger" type="submit">Delete</button>
</form>
</li>
{{end}}
</ul>
{{else}}
<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>
{{else}}
<p class="section__empty">{{.Empty}}</p>
{{end}}
</section>
{{end}}
<div class="child__foot">
{{if .Child.LaterCount}}<span>{{.Child.LaterCount}} due later</span>{{end}}
{{if .Child.DoneCount}}<span>{{.Child.DoneCount}} submitted</span>{{end}}
<form method="post" action="/summaries/build" class="inline-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="child" value="{{.Child.ID}}">
<button class="button-utility" type="submit">Build {{.Child.Name}}'s brief (draft)</button>
</form>
</div>
</section>
{{else}}
<div class="empty-state">
<p class="empty-state__title">No children on file</p>
<p class="empty-state__body">Harry and Finn are seeded on first start. If this is empty,
the database was not seeded.</p>
</div>
{{end}}
{{if .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>
<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}}