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

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

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

View File

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