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

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

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

View File

@@ -1,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}}