From 047af0f31dba6e60f6f6f84bd6795cc8fa7821fc Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Fri, 16 Oct 2026 10:25:55 +1100 Subject: [PATCH] Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119) - Parse each child's own "Today" day widget for the timetable (FR-16); drop the school-wide week calendar's lesson grid and clean the leaked routine rows. - Add the editable codes map (FR-17): subjects, rooms, teachers and period labels, seeded, an unknown code falls back to the raw code; `school codes` CLI. - Brief: Finn's block first, then Harry's, four sections always present (FR-9); periods decoded into plain English; the undated tail is one counted line (FR-18). - Calendar: write periods + work due + work overdue + major events, per child, on every pass. Identity is the stable per-item key, so a re-run never re-inserts (FR-8). morning-brief.sh now runs scan -> calendar push -> notify. - Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods. Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing. Co-Authored-By: Paperclip --- AGENTS.md | 40 +++- Makefile | 4 +- README.md | 56 ++++- cmd/school/calendar.go | 20 +- cmd/school/codes.go | 160 ++++++++++++++ cmd/school/items.go | 34 ++- cmd/school/main.go | 6 + cmd/school/notify.go | 1 + cmd/school/parse.go | 33 ++- deploy/DEPLOYMENT.md | 2 +- docker-compose.yml | 4 +- docs/outline/04-agent-runbook.md | 2 +- docs/phase-6-calendar-notes.md | 6 + docs/phase-6b7b-notes.md | 243 ++++++++++++++++++++++ docs/phase-7-notify-notes.md | 5 + internal/brief/brief.go | 39 +++- internal/brief/brief_test.go | 32 ++- internal/calendar/ha.go | 4 +- internal/calendar/sync.go | 213 ++++++++++++++++--- internal/calendar/sync_test.go | 82 ++++++++ internal/dashboard/dashboard.go | 140 ++++++++++++- internal/dashboard/dashboard_test.go | 84 ++++++++ internal/decode/decode.go | 84 ++++++++ internal/decode/decode_test.go | 52 +++++ internal/parse/calendar.go | 32 +-- internal/parse/daywidget.go | 114 ++++++++++ internal/parse/importance.go | 6 +- internal/parse/parse.go | 48 +++-- internal/parse/parse_test.go | 101 ++++++++- internal/period/period.go | 54 +++++ internal/store/calendar.go | 94 ++++++++- internal/store/calendar_test.go | 33 +++ internal/store/codes.go | 263 ++++++++++++++++++++++++ internal/store/migrations/005_codes.sql | 19 ++ internal/store/store.go | 34 +++ internal/web/server_test.go | 2 +- scripts/calendar-push.sh | 4 +- scripts/morning-brief.sh | 51 +++-- scripts/parse-compass.sh | 4 +- scripts/scan-compass.sh | 4 +- 40 files changed, 2049 insertions(+), 160 deletions(-) create mode 100644 cmd/school/codes.go create mode 100644 docs/phase-6b7b-notes.md create mode 100644 internal/decode/decode.go create mode 100644 internal/decode/decode_test.go create mode 100644 internal/parse/daywidget.go create mode 100644 internal/period/period.go create mode 100644 internal/store/codes.go create mode 100644 internal/store/migrations/005_codes.sql diff --git a/AGENTS.md b/AGENTS.md index c2a3f2c..5865e9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,13 +84,16 @@ Items marked **not verified** need a decision or a test. | FR-6 | Detect overdue work and work due today and this week. | | FR-7 | Show a per-child view: overdue, due today, due this week, and messages. | | FR-8 | Write to a **dedicated Home Assistant calendar** (suggested `School`), per child, on **EVERY pass**: (a) **that day's periods**, (b) **work due** — any future date, (c) **work overdue**, (d) **major events** (concerts, excursions, exams). Every item carries a **date from Compass**, so the same item is seen on many passes — a piece of work due in two weeks, or a concert, appears on **every** pass until it passes. **It must never be re-inserted.** Identity is a **stable per-item key**, not the date. | -| FR-9 | Send a **morning brief to each boy** (Harry, Finn) **by email**, in this order: (a) **today's periods**; (b) **today's major events**; (c) **work overdue**; (d) **work due in the future**. Lists only, no fluff. | +| FR-9 | Send a brief **for each boy, SEPARATED — Finn's block first, then Harry's**. **Never interleave them** in one list. Each child's block has, in this order: (a) **today's periods**, **decoded into plain English**; (b) **today's major events**; (c) **work overdue**; (d) **work due in the future**. Lists only, no fluff. | | FR-10 | Provide a **Delete** action and a **Send** action on items and summaries. | | FR-11 | Hub API: `GET /capabilities`, `POST /ask` (read-only), `POST /notify`. | | FR-12 | Fail **loudly** on a scrape failure. Never report "nothing due" when the scan failed. | | FR-13 | Log every scan: time, child, pages, items, errors. | | FR-14 | Send a **combined brief** for both boys to **Jo and Sam** via Apprise. | | FR-15 | **Teams — DEFERRED.** Do not build. Compass Learning Tasks supplies the due work (see §13). | +| FR-16 | **Periods are PER-CHILD.** Each child's timetable comes from **their own** schedule page. Never treat it as school-wide. Finn is Year 7, Harry is Year 11 — they must **never** show the same periods. | +| FR-17 | **Decode periods into plain English** from an editable `codes` map: subject code → subject name, room code → room, teacher initials → teacher name. An unknown code renders as the **raw code**, never blank. | +| FR-18 | **Drop the "NO DUE DATE" dump.** Do not list an unbounded set of undated tasks. If shown at all, cap it to a single line with a count, linking to the dashboard. | ## 5. Screens and UX flow @@ -166,12 +169,25 @@ Real examples from the Phase 2 capture: Decoding `11MGMAC2`: `11` = Year level · `M` = Faculty (Maths) · `GM` = Subject (General Maths) · `AC2` = timetable block. -**Decoding is OPTIONAL and NOT yet instructed.** The raw codes are what Compass -supplies, and rendering them as-is (`11XMUSA6 · A1 · JBR`) is acceptable. +**Decoding is REQUIRED (FR-17).** Sam wants periods in plain English, not codes. -If Sam later asks for readable names, store them as an **editable `codes` map** — -never hardcode, because initials and room codes change between years. An unknown code -must render as the **raw code**, never blank. +Store the mappings as an **editable `codes` map** — never hardcode, because teacher +initials and room codes change between years and new subject codes appear each term. + +Examples to seed it: + +| Raw | Reads as | +|---|---| +| `7SCF` | Year 7 Science | +| `7DESF` | Year 7 Design | +| `7TUTF` | Year 7 Tutorial | +| `11MGMAC2` | Year 11 General Maths | +| `11XMUSA6` | Year 11 Music | +| `B11` / `M6` / `A1` | Room B11 / M6 / A1 | +| `GT` / `ANE` / `JBR` | teacher surname | + +An unknown code renders as the **raw code** — never blank. Seed the map in Phase 6b +and let Sam correct it in the dashboard. ## 8. Integrations @@ -302,8 +318,16 @@ Resolved: key, never the date or title. 15. **Brief order (FR-9):** today's periods → today's major events → work overdue → future due. Lists only. -16. **Codes are NOT decoded (yet).** Raw Compass codes (`11XMUSA6 · A1 · JBR`) are - acceptable. Decoding was raised as a question, not an instruction — see §7. +16. **Codes ARE decoded (FR-17).** Periods must read as plain English: + "Period 1 — Year 11 General Maths — Room B11 — Mr GT". Editable `codes` map, never + hardcoded, unknown codes render raw. +17. **Kids are separated in the email.** Finn's block first, then Harry's. Never + interleaved — the current combined single list is wrong. +18. **Periods are per-child (FR-16).** A bug was found where Finn's brief carried + Harry's Year 11 periods. Finn is Year 7 (`7SCF - H14LAB - KNI`). The timetable + must come from each child's OWN page, never treated as school-wide. +19. **No undated-task dump (FR-18).** The current brief lists 20+ "no due date" items, + truncated mid-list. Drop it, or cap to one line with a count. 13. **Not spoken unless asked.** The HA calendar stays silent unless Sam adds an automation. Google Calendar is separate and already speaks. 14. **Email is the report channel.** Each boy gets his own by email; Jo and Sam get diff --git a/Makefile b/Makefile index 5154dd5..e1450a3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= compass-teams-nhs/school:0.6.1-phase8 -BUILD_VERSION ?= 0.6.1-phase8 +IMAGE ?= compass-teams-nhs/school:0.6.2-phase6b7b +BUILD_VERSION ?= 0.6.2-phase6b7b # Parse options (see scripts/parse-compass.sh). CHILD ?= Harry diff --git a/README.md b/README.md index e76f649..0f3033f 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,11 @@ Phase 9 publishes the project documentation across five places. The daily trigger is **06:00** in `Australia/Melbourne` (n8n). The briefs go out on **Email and WhatsApp** through Apprise on `.35:8210`: one brief per boy, plus -one combined brief to Jo and Sam. Important school events go to the Home -Assistant Local Calendar `calendar.school`. The Hub reads items through the +one combined brief to Jo and Sam. Each boy's block comes **separately, Finn +first**, with his own **decoded** periods, today's events, overdue work and due +work. Every pass also writes that child's periods, due work, overdue work and +the major school events to the Home Assistant Local Calendar +`calendar.school`, once and never twice. The Hub reads items through the read-only `POST /ask` contract. The accepted scraper is **patchright** (headless Playwright), not the @@ -104,10 +107,24 @@ shown on the dashboard, so a failed send is loud rather than silent (FR-12). `POST /notify/morning` is the endpoint the 6:00am n8n schedule calls. See [`docs/phase-7-notify-notes.md`](docs/phase-7-notify-notes.md). +Each child's block is separate and **Finn comes first** (FR-9). The four +headings are always present and say `none` when empty: + +``` +=== FINN === +PERIODS TODAY <- decoded from the child's own day widget (FR-16/FR-17) +EVENTS TODAY +OVERDUE +DUE IN FUTURE +``` + +The undated tail is never dumped: it is one counted line with a pointer to the +dashboard (FR-18). + ```bash make notify # send the briefs from the latest scan make preview # print the briefs, send nothing -make morning-brief # scan both children, then send (the n8n job) +make morning-brief # scan, write the calendar, then send # Delivery log: per recipient per channel, plus the scan state. docker exec compass-teams-nhs /app/school notify status @@ -116,12 +133,28 @@ docker exec compass-teams-nhs /app/school notify status The destinations live in the Apprise configuration, not here: this service only names a tag such as `harry_email` (AGENTS.md §10 rule 3). +### The decode map (FR-17) + +Periods read as plain English from an editable `codes` table. Seed rows ship for +the captures (for example `7SCF` -> `Year 7 Science`, `H14LAB` -> `Room H14LAB`, +`KNI` -> `Mr KNI`); an unknown code renders as the **raw code**, never blank. +Correct or add a mapping without a rebuild: + +```bash +docker exec compass-teams-nhs /app/school codes list +docker exec compass-teams-nhs /app/school codes set --kind teacher --code KNI --name 'Mr Knight' +docker exec compass-teams-nhs /app/school codes delete --kind room --code Z9 +``` + ## Status — Phase 6 -Phase 6 writes the **important** school events to **Sam's Home Assistant -calendar** through the Home Assistant REST API (FR-8). It is idempotent (a -re-run never duplicates an event) and it fails loudly (a failed write is shown -on the dashboard and recorded in `calendar_pushes`). See +Phase 6 writes, for **each child separately**, that day's **periods**, the +**work due** (any future date), the **work overdue**, and the **major school +events** to **Sam's Home Assistant calendar** through the Home Assistant REST +API (FR-8). It is idempotent: identity is the stable per-item Compass key, so a +re-run checks the item and never re-inserts it — a piece of work due in two +weeks appears on every pass until it passes. A failed write is shown on the +dashboard and recorded in `calendar_pushes`. See [`docs/phase-6-calendar-notes.md`](docs/phase-6-calendar-notes.md). The target is a **Home Assistant Local Calendar**, `calendar.school` (added @@ -133,8 +166,9 @@ Assistant had). The entity stays configurable with scripts/calendar-push.sh entities # list the writable calendar entities scripts/calendar-push.sh status # target, reachability, pending, last push scripts/calendar-push.sh push --dry-run -scripts/calendar-push.sh push # write the pending important events -scripts/calendar-push.sh events # read the calendar back +scripts/calendar-push.sh push # write the pending periods/work/events +scripts/calendar-push.sh events --past 120 --days 60 # read the calendar back +scripts/calendar-push.sh events --past 120 --days 60 --json make calendar CALENDAR_ARGS='push --dry-run' ``` @@ -229,8 +263,8 @@ docker compose up -d --build Generate secrets with the built image: ```bash -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.1-phase8 gen-secret -docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.1-phase8 hash-password 'the-password' +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.2-phase6b7b gen-secret +docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.2-phase6b7b hash-password 'the-password' ``` Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash diff --git a/cmd/school/calendar.go b/cmd/school/calendar.go index 738e4a8..513a921 100644 --- a/cmd/school/calendar.go +++ b/cmd/school/calendar.go @@ -125,14 +125,11 @@ func calendarStatus(args []string) { os.Exit(1) } now := time.Now().In(cfg.Location) - candidates, err := st.CalendarCandidates(ctx, nil, nil, calendar.StartOfDay(now), 0) + pendingEvents, err := calendar.Pending(ctx, st, calendar.Options{Now: now}) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) } - // Count events, not rows: a school-wide event is stored against each - // child but written once. - pendingEvents := len(calendar.GroupDuplicates(candidates)) if asJSON { out := struct { @@ -174,7 +171,7 @@ func calendarStatus(args []string) { } else { fmt.Printf("reachable yes\n") } - fmt.Printf("pending events %d (important, not in the calendar yet)\n", pendingEvents) + fmt.Printf("pending events %d (periods + due work + overdue + school events, not in the calendar yet)\n", pendingEvents) fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed) if summary.Last != nil { state := "ok" @@ -272,7 +269,11 @@ func printCalendarResult(res calendar.Result, loc *time.Location) { if uid == "" { uid = "-" } - fmt.Printf(" item %-5d %-6s %-10s %-45s uid=%s\n", ev.ItemID, ev.Child, start, truncate(ev.Title, 45), uid) + kind := string(ev.Kind) + if kind == "" { + kind = "event" + } + fmt.Printf(" item %-5d %-8s %-6s %-10s %-45s uid=%s\n", ev.ItemID, kind, ev.Child, start, truncate(ev.Title, 45), uid) } for _, msg := range res.Errors { fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg) @@ -285,9 +286,11 @@ func calendarEvents(args []string) { fs := flag.NewFlagSet("calendar events", flag.ContinueOnError) var ( days int + past int asJSON bool ) fs.IntVar(&days, "days", 14, "how many days ahead to read") + fs.IntVar(&past, "past", 1, "how many days back to read (overdue work sits in the past)") fs.BoolVar(&asJSON, "json", false, "print the events as JSON") _ = fs.Parse(args) @@ -302,8 +305,9 @@ func calendarEvents(args []string) { defer st.Close() now := time.Now().In(cfg.Location) + start := now.AddDate(0, 0, -past) end := now.AddDate(0, 0, days) - events, err := client.Events(ctx, now.Add(-24*time.Hour), end) + events, err := client.Events(ctx, start, end) if err != nil { fmt.Fprintln(os.Stderr, "school calendar:", err) os.Exit(1) @@ -321,7 +325,7 @@ func calendarEvents(args []string) { if len(events) == 0 { fmt.Printf("no events on %s between %s and %s\n", cfg.Calendar.EntityID, - now.Format("2006-01-02 15:04"), + start.Format("2006-01-02 15:04"), end.Format("2006-01-02 15:04")) return } diff --git a/cmd/school/codes.go b/cmd/school/codes.go new file mode 100644 index 0000000..ef0c081 --- /dev/null +++ b/cmd/school/codes.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + "text/tabwriter" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +// codesCommand is the `school codes` entry point: the editable decode map for +// Compass periods (AGENTS.md §4 FR-17). An unknown code renders as the raw +// code, so a missing entry is safe but unreadable; this is how Sam corrects it. +func codesCommand(args []string) { + if len(args) == 0 { + usage("school codes list [--kind KIND] | set --kind KIND --code CODE --name NAME | delete --kind KIND --code CODE") + } + switch args[0] { + case "list": + codesList(args[1:]) + case "set": + codesSet(args[1:]) + case "delete": + codesDelete(args[1:]) + default: + usage("school codes list | set | delete") + } +} + +func openCodes(ctx context.Context) (*store.Store, error) { + cfg, err := config.LoadDB() + if err != nil { + return nil, err + } + st, err := store.Open(cfg.DBPath) + if err != nil { + return nil, err + } + if err := st.Migrate(ctx); err != nil { + _ = st.Close() + return nil, err + } + if err := st.SeedCodes(ctx); err != nil { + _ = st.Close() + return nil, err + } + return st, nil +} + +func codesList(args []string) { + fs := flag.NewFlagSet("codes list", flag.ContinueOnError) + var ( + kind string + asJSON bool + ) + fs.StringVar(&kind, "kind", "", "limit to subject, room, teacher or period") + fs.BoolVar(&asJSON, "json", false, "print as JSON") + _ = fs.Parse(args) + + ctx := context.Background() + st, err := openCodes(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + defer st.Close() + + codes, err := st.ListCodes(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + if kind != "" { + filtered := codes[:0] + for _, c := range codes { + if c.Kind == kind { + filtered = append(filtered, c) + } + } + codes = filtered + } + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(codes); err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + return + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + fmt.Fprintln(tw, "KIND\tCODE\tREADS AS") + for _, c := range codes { + fmt.Fprintf(tw, "%s\t%s\t%s\n", c.Kind, c.Code, c.Name) + } + _ = tw.Flush() +} + +func codesSet(args []string) { + fs := flag.NewFlagSet("codes set", flag.ContinueOnError) + var kind, code, name string + fs.StringVar(&kind, "kind", "", "subject, room, teacher or period") + fs.StringVar(&code, "code", "", "the raw Compass code") + fs.StringVar(&name, "name", "", "the plain-English name") + _ = fs.Parse(args) + + if kind == "" || code == "" || name == "" { + fmt.Fprintln(os.Stderr, "school codes set: --kind, --code and --name are required") + os.Exit(2) + } + ctx := context.Background() + st, err := openCodes(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + defer st.Close() + + c, err := st.SetCode(ctx, kind, code, name) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + fmt.Printf("%s %s reads as %q\n", c.Kind, c.Code, c.Name) +} + +func codesDelete(args []string) { + fs := flag.NewFlagSet("codes delete", flag.ContinueOnError) + var kind, code string + fs.StringVar(&kind, "kind", "", "subject, room, teacher or period") + fs.StringVar(&code, "code", "", "the raw Compass code") + _ = fs.Parse(args) + + if kind == "" || code == "" { + fmt.Fprintln(os.Stderr, "school codes delete: --kind and --code are required") + os.Exit(2) + } + ctx := context.Background() + st, err := openCodes(ctx) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + defer st.Close() + + ok, err := st.DeleteCode(ctx, kind, code) + if err != nil { + fmt.Fprintln(os.Stderr, "school codes:", err) + os.Exit(1) + } + if !ok { + fmt.Printf("%s %s was not mapped\n", kind, code) + return + } + fmt.Printf("%s %s now renders as the raw code\n", kind, code) +} diff --git a/cmd/school/items.go b/cmd/school/items.go index 9de4aeb..d6e6251 100644 --- a/cmd/school/items.go +++ b/cmd/school/items.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "strings" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" @@ -15,31 +16,43 @@ import ( // stored against the wrong child ("nothing may leak between children"). func itemsCommand(args []string) { if len(args) == 0 || args[0] != "purge" { - fmt.Fprintln(os.Stderr, "usage: school items purge --child NAME [--source compass|teams] [--type message|task|event]") + fmt.Fprintln(os.Stderr, "usage: school items purge --child NAME [--source compass|teams] [--type message|task|event] [--source-page SLUG ...]") os.Exit(2) } fs := flag.NewFlagSet("items purge", flag.ContinueOnError) var ( - child string - source string - typ string + child string + source string + typ string + sourcePages multiFlag ) fs.StringVar(&child, "child", "", "child name (Harry or Finn)") fs.StringVar(&source, "source", "", "limit to one source") fs.StringVar(&typ, "type", "", "limit to one type") + fs.Var(&sourcePages, "source-page", "limit to one stored page slug (repeatable)") _ = fs.Parse(args[1:]) if child == "" { fmt.Fprintln(os.Stderr, "school items purge: --child is required") os.Exit(2) } - if err := runItemsPurge(child, source, typ); err != nil { + if err := runItemsPurge(child, source, typ, sourcePages); err != nil { fmt.Fprintln(os.Stderr, "school items:", err) os.Exit(1) } } -func runItemsPurge(child, source, typ string) error { +// multiFlag collects a repeatable string flag. +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } + +func (m *multiFlag) Set(v string) error { + *m = append(*m, v) + return nil +} + +func runItemsPurge(child, source, typ string, sourcePages []string) error { ctx := context.Background() cfg, err := config.LoadDB() @@ -61,10 +74,15 @@ func runItemsPurge(child, source, typ string) error { if c == nil { return fmt.Errorf("unknown child %q", child) } - n, err := st.DeleteItems(ctx, c.ID, source, typ) + var n int64 + if len(sourcePages) > 0 { + n, err = st.DeleteItemsFromPages(ctx, c.ID, source, typ, sourcePages...) + } else { + n, err = st.DeleteItems(ctx, c.ID, source, typ) + } if err != nil { return err } - fmt.Printf("child=%s purged=%d source=%q type=%q\n", c.Name, n, source, typ) + fmt.Printf("child=%s purged=%d source=%q type=%q pages=%v\n", c.Name, n, source, typ, sourcePages) return nil } diff --git a/cmd/school/main.go b/cmd/school/main.go index 39ef74f..53f7233 100644 --- a/cmd/school/main.go +++ b/cmd/school/main.go @@ -62,6 +62,9 @@ func main() { case "calendar": calendarCommand(os.Args[2:]) return + case "codes": + codesCommand(os.Args[2:]) + return case "version": fmt.Println(version) return @@ -97,6 +100,9 @@ func run() error { if err := st.SeedChildren(context.Background(), seedChildren...); err != nil { return err } + if err := st.SeedCodes(context.Background()); err != nil { + return err + } am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure, cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) diff --git a/cmd/school/notify.go b/cmd/school/notify.go index 6726a87..b501fcc 100644 --- a/cmd/school/notify.go +++ b/cmd/school/notify.go @@ -118,6 +118,7 @@ func buildOnly(ctx context.Context, svc *brief.Service, st *store.Store, only st if len(children) == 0 { return nil, fmt.Errorf("no children on file") } + children = brief.OrderChildren(children) var out []brief.Send if only != "combined" { diff --git a/cmd/school/parse.go b/cmd/school/parse.go index ff8b448..34067fd 100644 --- a/cmd/school/parse.go +++ b/cmd/school/parse.go @@ -150,6 +150,19 @@ func runParse(f parseFlags) error { } } + // The week calendar is school-wide and carries the eldest child's lesson + // grid. Now that this child's own day widget is parsed, drop the leaked + // routine periods; the real school events from that page are kept (FR-16). + // The day-widget rows themselves are not deleted: the same day's period + // upserts onto its own stable reference, so its Home Assistant marker (and + // the calendar write's idempotency) is preserved across passes. + if hasPage(pages, parse.PageChildProfile) || hasPage(pages, parse.PageLearningTasks) { + if _, err := st.DeleteChildRoutineCalendarEvents(ctx, child.ID); err != nil { + _, _ = st.RecordScan(ctx, &child.ID, len(pages), len(items), false, err.Error()) + return err + } + } + inserted, updated, err := st.UpsertItems(ctx, child.ID, stored) if err != nil { _, _ = st.RecordScan(ctx, &child.ID, len(pages), len(items), false, err.Error()) @@ -326,6 +339,7 @@ func loadRunPages(rawRoot, child, run string) (string, []parse.Page, error) { type rawPage struct { htmlBytes, jsonBytes []byte url, user string + fetchedAt time.Time } bySlug := map[string]*rawPage{} var order []string @@ -353,13 +367,17 @@ func loadRunPages(rawRoot, child, run string) (string, []parse.Page, error) { return "", nil, fmt.Errorf("read %s: %w", name, err) } var meta struct { - URL string `json:"url"` - UserID string `json:"userId"` + URL string `json:"url"` + UserID string `json:"userId"` + FetchedAt string `json:"fetchedAt"` } _ = json.Unmarshal(raw, &meta) p := get(slug) p.url = strings.TrimSpace(meta.URL) p.user = strings.TrimSpace(meta.UserID) + if ts, err := time.Parse(time.RFC3339, strings.TrimSpace(meta.FetchedAt)); err == nil { + p.fetchedAt = ts + } case strings.HasSuffix(lower, ".json"): if strings.EqualFold(name, "manifest.json") { continue @@ -387,11 +405,12 @@ func loadRunPages(rawRoot, child, run string) (string, []parse.Page, error) { continue } pages = append(pages, parse.Page{ - Slug: slug, - URL: rp.url, - HTML: rp.htmlBytes, - JSON: rp.jsonBytes, - UserID: rp.user, + Slug: slug, + URL: rp.url, + HTML: rp.htmlBytes, + JSON: rp.jsonBytes, + UserID: rp.user, + FetchedAt: rp.fetchedAt, }) } if len(pages) == 0 { diff --git a/deploy/DEPLOYMENT.md b/deploy/DEPLOYMENT.md index a4b013c..9cc1bd6 100644 --- a/deploy/DEPLOYMENT.md +++ b/deploy/DEPLOYMENT.md @@ -76,7 +76,7 @@ the API directly on the internal address. `SCHOOL_APPRISE_KEY`, and generate the n8n token: ```bash - docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.5.0-phase7 gen-secret + docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.6.2-phase6b7b gen-secret # -> SCHOOL_NOTIFY_TOKEN= ``` diff --git a/docker-compose.yml b/docker-compose.yml index cbca7e8..f7e486c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,8 @@ services: build: context: . args: - VERSION: "0.6.1-phase8" - image: compass-teams-nhs/school:0.6.1-phase8 + VERSION: "0.6.2-phase6b7b" + image: compass-teams-nhs/school:0.6.2-phase6b7b container_name: compass-teams-nhs restart: unless-stopped env_file: diff --git a/docs/outline/04-agent-runbook.md b/docs/outline/04-agent-runbook.md index 20e02f0..0739ae7 100644 --- a/docs/outline/04-agent-runbook.md +++ b/docs/outline/04-agent-runbook.md @@ -34,7 +34,7 @@ make restart # restart the service only ``` The container is `compass-teams-nhs`. The image tag is -`compass-teams-nhs/school:0.6.1-phase8`. +`compass-teams-nhs/school:0.6.2-phase6b7b`. ## Operate diff --git a/docs/phase-6-calendar-notes.md b/docs/phase-6-calendar-notes.md index 8164fe3..e6ddb60 100644 --- a/docs/phase-6-calendar-notes.md +++ b/docs/phase-6-calendar-notes.md @@ -1,5 +1,11 @@ # Phase 6 — the calendar write (Sam's Home Assistant calendar) +> **Updated by Phase 6b (AUD-119):** the calendar now holds each child's +> **periods**, **work due**, **work overdue** and the **major school events**, +> per child, on every pass. See +> [`phase-6b7b-notes.md`](phase-6b7b-notes.md). The Phase 6 mechanism below +> (Local Calendar, marker idempotency, fail loud) is unchanged. + Status: **done** (AUD-110). Sam chose a **Home Assistant Local Calendar**, and the service writes important school events to it through the Home Assistant REST API. diff --git a/docs/phase-6b7b-notes.md b/docs/phase-6b7b-notes.md new file mode 100644 index 0000000..cbb64be --- /dev/null +++ b/docs/phase-6b7b-notes.md @@ -0,0 +1,243 @@ +# Phase 6b/7b — per-child periods, decoded brief, idempotent calendar writes + +Status: **done** (AUD-119). Supersedes the scope notes in +[`phase-6-calendar-notes.md`](phase-6-calendar-notes.md) and +[`phase-7-notify-notes.md`](phase-7-notify-notes.md) where they differ. + +Spec: `AGENTS.md` §4 FR-8, FR-9, FR-16, FR-17, FR-18; §7 data model; §11 +Phase 6/7. Image `compass-teams-nhs/school:0.6.2-phase6b7b`. + +## 1. Periods are per child (FR-16) — the bug fixed + +The Compass **week calendar** is school-wide: its lesson grid belongs to the +eldest child, so every child's items carried Harry's Year 11 periods. Finn (Year +7) was shown `11MGMAC2 - B11 - GT`. + +The per-child timetable is the **day widget** Compass renders on each child's own +pages (`/Records/User.aspx?userId=…` and the Learning Tasks page), labelled +"Today": + +``` +8:55: 1 - 7SCF - H14LAB - KNI +``` + +- `internal/parse/daywidget.go` reads that widget from the child's own pages and + stores the periods against that child. +- `internal/parse/calendar.go` now drops routine lessons from the school-wide + week calendar and keeps only real school events. +- A re-parse deletes the child's leaked routine `calendar`-page rows, so the + wrong periods cannot survive. + +The day-widget label prints a 12-hour clock with no AM/PM (`1:26` is the +afternoon period), so the parser applies the school-day rule: 1–6 are PM, 7–11 +are AM, 12 is noon. The widget's grid offset is *not* used, because its start +hour moves with the day's first event (07:00 on one page, 06:00 on another). + +## 2. The decode map (FR-17) + +`codes` (migration `005_codes.sql`) is the editable map: `subject`, `room`, +`teacher` and `period`. It is seeded on first read and never overwrites a +correction. An unknown code renders as the **raw code**, never blank. + +``` +=== FINN === +PERIODS TODAY +- Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55) +... +- Period 4 — Year 7 Physical Education — Gym 4 — Mr LTA (13:26) +``` + +Sam edits it without a rebuild: + +```bash +docker exec compass-teams-nhs /app/school codes list +docker exec compass-teams-nhs /app/school codes set --kind teacher --code KNI --name 'Mr Knight' +docker exec compass-teams-nhs /app/school codes delete --kind room --code Z9 +``` + +Teacher rows are placeholders (`KNI` -> `Mr KNI`) because Compass gives only +initials; Sam replaces them with surnames. Real unknown-code fallback from the +live brief: Harry's period 2 teacher field is `GT AGE_13`, and `AGE_13` is not +mapped, so it renders raw (`Mr GT / AGE_13`). + +Seed size: 65 rows — 23 subjects, 22 teachers, 18 rooms, 2 period labels. + +## 3. The calendar (FR-8) + +For **each child separately**, every pass writes to `calendar.school`: + +- that day's **periods**; +- **work due** — any future date; +- **work overdue**; +- **major school events** (concerts, excursions, exams). + +Selection: `internal/store/calendar.go` adds `CalendarPeriodCandidates` (today's +day-widget periods) and `CalendarTaskCandidates` (outstanding work with a due +date); the existing `CalendarCandidates` keeps the important school events. +Submitted work and work with no due date are never written. + +Idempotency is unchanged and still uses the stable per-item key: an item with a +Home Assistant event id is not a candidate, and an event whose description +carries `source: [compass-nhs:]` is adopted instead of written again. +Periods and work are per child; a school-wide event is still written once and +recorded against every child. + +`scripts/morning-brief.sh` now runs **scan → calendar push → notify**, so the +6:00 pass writes the calendar. `scripts/calendar-push.sh events` gained `--past` +so overdue work (which sits at its Compass due date) can be read back. + +## Evidence (2026-10-15 UTC / 2026-10-16 AEDT) + +### The rendered brief — real pass + +`Finn` block, first, with his own decoded Year 7 periods: + +``` +=== FINN === + +PERIODS TODAY +- Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55) +- Period 2 — Year 7 Design — Room B1 — Mr PSI (09:54) +- Connect — Year 7 Tutorial — Room G7 — Mr KDI (11:18) +- Period 3 — Year 7 English — Room G7 — Mr DCA (11:47) +- Period 4 — Year 7 Physical Education — Gym 4 — Mr LTA (13:26) +- Period 5 — Year 7 Drama — Theatre — EBA (14:20) + +EVENTS TODAY +- JAZZ ENSEMBLE - ENS - KWO (07:45) +- JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG (07:45) +- HHL GUITAR E - IM3 - HHL (09:54) + +OVERDUE +- 7PE PHYSICAL EDUCATION: CAT 3: Cardiorespiratory Fitness (due Thu 03 Sep 16:00) [overdue] +- 7DES PRODUCT DESIGN: CAT 2: Eco Light - Research and Development (due Thu 17 Sep 16:00) [overdue] + +DUE IN FUTURE +- 7DRA DRAMA: CAT 2: Ancient Greek Theatre (due Mon 26 Oct 16:00) +- 7TUT CONNECT: Learning Behaviour Report - Self Reflection Term 4 (due Tue 03 Nov 11:45) +- 7DES PRODUCT DESIGN: CAT 3: Eco Light - Making and Evaluating (due Thu 19 Nov 15:15) +- 7MA MATHEMATICS: CAT 6: Measurement and Geometry Test (due Fri 20 Nov 16:00) +- 7DRA DRAMA: CAT 3: Performance or Research Project (due Mon 07 Dec 16:00) +- 8 task(s) with no due date (see the dashboard) +``` + +`Harry` block, second, with his own Year 11 periods: + +``` +=== HARRY === + +PERIODS TODAY +- Period 1 — Year 11 General Maths — Room B11 — Mr GT / SMH (08:55) +- Period 2 — Year 11 General Maths — Room B11 — Mr GT / AGE_13 (09:54) +- Connect — Year 11 Tutorial — Room M6 — Mr ANE (11:18) +- Period 3 — Year 11 Art Creative Practice — Room C1 — Mr BDR (11:47) +- Period 4 — Year 11 English — Room H3 — Mr CHA (13:26) +- Period 5 — Year 11 Music — Room A1 — Mr JBR (14:20) + +EVENTS TODAY +- none +``` + +The combined brief for Jo and Sam carries the same two blocks, `=== FINN ===` +first and `=== HARRY ===` second, never interleaved. The undated tail is one +line with a count (`17 task(s) with no due date …`), never the old 20+ dump. + +### The Home Assistant calendar — per child (summary + start) + +Read back from the Home Assistant API. 35 events in total. + +Finn — 16 events: + +``` +2026-09-03T16:00 Finn — 7PE PHYSICAL EDUCATION: CAT 3: Cardiorespiratory Fitness +2026-09-17T16:00 Finn — 7DES PRODUCT DESIGN: CAT 2: Eco Light - Research and Development +2026-10-16T07:45 Finn — JAZZ ENSEMBLE - ENS - KWO +2026-10-16T07:45 Finn — JUNIOR GUITAR ENSEMBLE - THEATRE - HHL, OFG +2026-10-16T08:55 Finn — Period 1 — Year 7 Science — Room H14LAB — Mr KNI +2026-10-16T09:54 Finn — HHL GUITAR E - IM3 - HHL +2026-10-16T09:54 Finn — Period 2 — Year 7 Design — Room B1 — Mr PSI +2026-10-16T11:18 Finn — Connect — Year 7 Tutorial — Room G7 — Mr KDI +2026-10-16T11:47 Finn — Period 3 — Year 7 English — Room G7 — Mr DCA +2026-10-16T13:26 Finn — Period 4 — Year 7 Physical Education — Gym 4 — Mr LTA +2026-10-16T14:20 Finn — Period 5 — Year 7 Drama — Theatre — EBA +2026-10-26T16:00 Finn — 7DRA DRAMA: CAT 2: Ancient Greek Theatre +2026-11-03T11:45 Finn — 7TUT CONNECT: Learning Behaviour Report - Self Reflection Term 4 +2026-11-19T15:15 Finn — 7DES PRODUCT DESIGN: CAT 3: Eco Light - Making and Evaluating +2026-11-20T16:00 Finn — 7MA MATHEMATICS: CAT 6: Measurement and Geometry Test +2026-12-07T16:00 Finn — 7DRA DRAMA: CAT 3: Performance or Research Project +``` + +Harry — 17 events: + +``` +2026-06-09T12:00 Harry — 11SBIO 11 SCIENCE - BIOLOGY: Unit 1 Biology Exam +2026-09-01T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: SAC #2: Creating +2026-09-01T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: VCE Unit #2 Outcome #2 Creating +2026-10-05T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: SAC #1: Performing +2026-10-05T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: VCE Unit #2 Outcome #1 Performing +2026-10-09T16:00 Harry — 11HLEG 11 HUMANITIES - LEGAL STUDIES: SAC 6: Human Rights Presentation +2026-10-16T08:55 Harry — Period 1 — Year 11 General Maths — Room B11 — Mr GT / SMH +2026-10-16T09:54 Harry — Period 2 — Year 11 General Maths — Room B11 — Mr GT / AGE_13 +2026-10-16T11:18 Harry — Connect — Year 11 Tutorial — Room M6 — Mr ANE +2026-10-16T11:47 Harry — Period 3 — Year 11 Art Creative Practice — Room C1 — Mr BDR +2026-10-16T13:26 Harry — Period 4 — Year 11 English — Room H3 — Mr CHA +2026-10-16T14:20 Harry — Period 5 — Year 11 Music — Room A1 — Mr JBR +2026-10-19T16:00 Harry — 11EENG 11 ENGLISH - ENGLISH: SAC 3: Responding to Texts: The Handmaid's Tale +2026-10-30T16:00 Harry — 11MGMA 11 MATHS - GENERAL MATHS: SAC 4: Networks +2026-11-03T10:45 Harry — 11TUT CONNECT: Learning Behaviour Report - Self Reflection Term 4 +2026-11-11T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: SAC #3: Analysing and Responding +2026-11-11T16:00 Harry — 11XMUS 11 PERF ARTS - MUSIC: VCE Unit #2 Outcome #3 Analysing and Responding +``` + +School-wide — 2 events (written once, shared by both children): + +``` +2026-10-15T15:05 Senior Sub-School Community Service for Uniform Infringement - A2 - PSU, CMA, JGE, LHT, QLM, ECU +2026-10-15T18:00 End of Year Senior Music Concert - HALL (Senior Music Concert) - KSH, CFR, AMG, OFG, NBA, CNI, JDF, SLT, RLE +``` + +### The 3-run idempotency proof + +Three real passes (`scripts/scan-compass.sh` then `scripts/calendar-push.sh +push`), read back after each: + +| Pass | `candidates` | `created` | `failed` | HA events in `calendar.school` | +|---|---|---|---|---| +| 1 | 33 | 33 | 0 | 35 | +| 2 | 0 | 0 | 0 | 35 | +| 3 | 0 | 0 | 0 | 35 | + +Runs 2 and 3 added nothing; the event list after run 2 and after run 3 is +byte-identical. + +## Files changed + +``` +internal/store/migrations/005_codes.sql the editable codes table +internal/store/codes.go CodeMap, seed, list/set/delete +internal/store/calendar.go period + work candidate queries, leak cleanup +internal/store/store.go DeleteItemsFromPages +internal/period/period.go lesson-label split (period/subject/room/teacher) +internal/decode/decode.go plain-English lesson rendering + raw fallback +internal/parse/daywidget.go the per-child "Today" widget +internal/parse/parse.go, calendar.go, importance.go per-child hook, week-calendar filter +internal/dashboard/dashboard.go brief buckets + the required 4-section renderers +internal/brief/brief.go Finn-first order, codes decoder, new renderers +internal/calendar/sync.go periods + due + overdue + events, decoded, once +internal/calendar/ha.go Event JSON exposes start/end for evidence +cmd/school/{main,parse,calendar,codes,items,notify}.go wiring, codes CLI, purge by page +scripts/{morning-brief,scan-compass,calendar-push,parse-compass}.sh calendar step, version +docker-compose.yml, Makefile image 0.6.2-phase6b7b +``` + +## Not tested + +- A **real email/WhatsApp send** of the new brief was not repeated; the send path + is unchanged and was last proven in Phase 7. The dry run prints exactly what is + sent. +- The **6:00 n8n job** was not re-published. It calls `scripts/morning-brief.sh`, + which now includes the calendar step; Sam owns the shared n8n service. +- The **week calendar** is still not fetched by the daily scan, so a *new* + major event is only written if a run fetches that page. The two existing + school-wide events remain in the calendar. +- **Teacher names** are placeholders; only Sam can supply the surnames. diff --git a/docs/phase-7-notify-notes.md b/docs/phase-7-notify-notes.md index d005efe..0551003 100644 --- a/docs/phase-7-notify-notes.md +++ b/docs/phase-7-notify-notes.md @@ -1,5 +1,10 @@ # Phase 7 — Notify and send (Apprise: Email + WhatsApp) +> **Updated by Phase 7b (AUD-119):** the brief now sends **Finn's block first, +> then Harry's**, never interleaved; periods are **decoded into plain English**; +> and the undated tail is **one counted line**, not a dump. See +> [`phase-6b7b-notes.md`](phase-6b7b-notes.md). + Status: **done** (2026-10-15). A brief was delivered to real recipients on both channels. See *Evidence* at the end. diff --git a/internal/brief/brief.go b/internal/brief/brief.go index dae9a61..4ea7cac 100644 --- a/internal/brief/brief.go +++ b/internal/brief/brief.go @@ -15,12 +15,14 @@ import ( "context" "fmt" "log/slog" + "sort" "strings" "time" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise" "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/decode" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) @@ -119,7 +121,11 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, return "", false, fmt.Errorf("items for %s: %w", child.Name, err) } buckets := dashboard.Classify(items, s.Now(), s.Loc) - text := dashboard.RenderWithLimits(child.Name, buckets, s.Loc, dashboard.MorningLimits) + dec, err := s.decoder(ctx) + if err != nil { + return "", false, err + } + text := dashboard.RenderBrief(child.Name, buckets, dec, s.Loc) sc, err := s.Store.ScanSummaryForChild(ctx, child.ID) if err != nil { @@ -132,18 +138,32 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string, return notice + "\n" + text, false, nil } +// decoder loads the editable codes map and returns the lesson-title decoder the +// brief uses (FR-17). An unknown code renders raw, never blank. +func (s *Service) decoder(ctx context.Context) (func(string) string, error) { + codes, err := s.Store.Codes(ctx) + if err != nil { + return nil, fmt.Errorf("decode map: %w", err) + } + return func(title string) string { return decode.Period(title, codes) }, nil +} + // CombinedText returns the single brief for Jo and Sam — both boys in one // message — and the children whose scan did not complete. func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) { - parts := make([]dashboard.Combined, 0, len(children)) + parts := make([]dashboard.CombinedBrief, 0, len(children)) var failed []string var notices []string + dec, err := s.decoder(ctx) + if err != nil { + return "", nil, err + } for _, c := range children { items, err := s.Store.ItemsForChild(ctx, c.ID) if err != nil { return "", nil, fmt.Errorf("items for %s: %w", c.Name, err) } - parts = append(parts, dashboard.Combined{ + parts = append(parts, dashboard.CombinedBrief{ Child: c.Name, Buckets: dashboard.Classify(items, s.Now(), s.Loc), }) @@ -156,7 +176,7 @@ func (s *Service) CombinedText(ctx context.Context, children []store.Child) (str failed = append(failed, c.Name) } } - text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits) + text := dashboard.RenderCombinedBrief(parts, dec, s.Loc) if len(notices) > 0 { text = strings.Join(notices, "\n") + "\n\n" + text } @@ -328,6 +348,8 @@ func (s *Service) plan(ctx context.Context) ([]planned, error) { if len(children) == 0 { return nil, fmt.Errorf("no children on file: nothing to brief") } + // Finn's block first, then Harry's: the required order (FR-9). + children = OrderChildren(children) var out []planned for _, c := range children { @@ -379,6 +401,15 @@ func (s *Service) plan(ctx context.Context) ([]planned, error) { return out, nil } +// OrderChildren returns the children in brief order: Finn's block first, then +// Harry's (FR-9). The order is by name, which puts Finn before Harry; it is +// shared by the send path and the dry-run build so both agree. +func OrderChildren(children []store.Child) []store.Child { + out := append([]store.Child(nil), children...) + sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + func combinedNames(children []store.Child) string { names := make([]string, 0, len(children)) for _, c := range children { diff --git a/internal/brief/brief_test.go b/internal/brief/brief_test.go index af9d5e2..7548154 100644 --- a/internal/brief/brief_test.go +++ b/internal/brief/brief_test.go @@ -148,16 +148,20 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) { t.Fatalf("morning: %v", err) } if len(sends) != 3 { - t.Fatalf("sends = %d, want Harry, Finn and combined", len(sends)) + t.Fatalf("sends = %d, want Finn, Harry and combined", len(sends)) } for _, s := range sends { if !s.OK() { t.Errorf("send %s not OK: %+v", s.Scope, s) } } + // Finn's block comes first, then Harry's (FR-9). + if sends[0].Scope != "Finn" || sends[1].Scope != "Harry" { + t.Fatalf("send order = %s, %s; want Finn then Harry", sends[0].Scope, sends[1].Scope) + } // Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels. - if got := len(sends[0].Deliveries); got != 2 { + if got := len(sends[1].Deliveries); got != 2 { t.Errorf("Harry deliveries = %d, want 2", got) } combined := sends[2] @@ -193,10 +197,16 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) { } } if !strings.Contains(combined.Text, "OVERDUE") || - !strings.Contains(combined.Text, "Harry: English: Essay") || - !strings.Contains(combined.Text, "Finn: Science: Lab report") { + !strings.Contains(combined.Text, "=== FINN ===") || + !strings.Contains(combined.Text, "=== HARRY ===") || + !strings.Contains(combined.Text, "Science: Lab report") || + !strings.Contains(combined.Text, "English: Essay") { t.Errorf("combined text = %q", combined.Text) } + // Finn's block is first and the two children are never interleaved. + if i, j := strings.Index(combined.Text, "=== FINN ==="), strings.Index(combined.Text, "=== HARRY ==="); i < 0 || j < 0 || i > j { + t.Errorf("combined blocks are not Finn then Harry: %q", combined.Text) + } rows, err := st.RecentNotifications(context.Background(), 20) if err != nil { @@ -278,19 +288,19 @@ func TestFailedScanIsNeverNothingDue(t *testing.T) { if err != nil { t.Fatalf("morning: %v", err) } - if len(sends[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" { - t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed) + if len(sends[1].ScanFailed) != 1 || sends[1].ScanFailed[0] != "Harry" { + t.Errorf("Harry scan failure not reported: %+v", sends[1].ScanFailed) } - if !strings.Contains(sends[0].Text, "SCAN FAILED") { - t.Errorf("brief does not say the scan failed: %q", sends[0].Text) + if !strings.Contains(sends[1].Text, "SCAN FAILED") { + t.Errorf("brief does not say the scan failed: %q", sends[1].Text) } - if !strings.Contains(sends[0].Text, "UNKNOWN") { - t.Errorf("brief does not say due work is unknown: %q", sends[0].Text) + if !strings.Contains(sends[1].Text, "UNKNOWN") { + t.Errorf("brief does not say due work is unknown: %q", sends[1].Text) } if !strings.Contains(sends[2].Text, "SCAN FAILED") { t.Errorf("combined brief does not carry the failure: %q", sends[2].Text) } - if sends[0].OK() { + if sends[1].OK() { t.Error("a scan failure must make the run not OK") } } diff --git a/internal/calendar/ha.go b/internal/calendar/ha.go index b55604d..bcbc561 100644 --- a/internal/calendar/ha.go +++ b/internal/calendar/ha.go @@ -156,8 +156,8 @@ type Event struct { Summary string `json:"summary"` Description string `json:"description,omitempty"` Location string `json:"location,omitempty"` - Start *time.Time `json:"-"` - End *time.Time `json:"-"` + Start *time.Time `json:"start,omitempty"` + End *time.Time `json:"end,omitempty"` StartRaw string `json:"-"` EndRaw string `json:"-"` } diff --git a/internal/calendar/sync.go b/internal/calendar/sync.go index 5c6429f..9228634 100644 --- a/internal/calendar/sync.go +++ b/internal/calendar/sync.go @@ -9,6 +9,8 @@ import ( "strings" "time" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/decode" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) @@ -26,6 +28,17 @@ func Marker(itemID int64) string { // ItemMarker is the full description tag written to the calendar. func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) } +// Kind is the category of a calendar entry (FR-8): a routine class period, a +// piece of work due in the future, overdue work, or a major school event. +type Kind string + +const ( + KindPeriod Kind = "period" + KindDue Kind = "due" + KindOverdue Kind = "overdue" + KindEvent Kind = "event" +) + // Options narrows one sync run. type Options struct { // ChildID limits the run to one child (nil means all children). @@ -55,6 +68,7 @@ func StartOfDay(t time.Time) time.Time { // Created is one event that the run wrote (or would write on a dry run). type Created struct { ItemID int64 `json:"item_id"` + Kind Kind `json:"kind"` Child string `json:"child"` Title string `json:"title"` Start string `json:"start"` @@ -76,13 +90,27 @@ type Result struct { DryRun bool `json:"dry_run,omitempty"` } -// Sync pushes every important school event that is not in the calendar yet. +// candidate is one item selected for the calendar, with the category it belongs +// to. +type candidate struct { + store.CalendarItem + Kind Kind +} + +// Sync writes every period, piece of work and school event that belongs in the +// calendar and is not there yet (FR-8): +// +// - that day's periods, per child (from the child's own day widget); +// - work due — any future date; +// - work overdue; +// - major school events. // // It is idempotent in two ways: an item that already carries a Home Assistant // event id is not a candidate, and an event whose description carries this -// item's marker is adopted instead of written again. Every attempt is recorded -// in the append-only calendar_pushes table, so a failure is visible and loud -// rather than swallowed (FR-12). +// item's marker is adopted instead of written again. Identity is the stable +// per-item key, never the date or title. Every attempt is recorded in the +// append-only calendar_pushes table, so a failure is visible and loud rather +// than swallowed (FR-12). func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) { if st == nil { return Result{}, errors.New("calendar: store is required") @@ -108,26 +136,24 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl res := Result{Entity: c.Entity(), DryRun: opts.DryRun} - candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, from, opts.Limit) + candidates, err := buildCandidates(ctx, st, opts, from, now) if err != nil { return res, err } - // The Compass week calendar is school-wide, so the same event is stored - // against each child. Write it once, and keep every child's item in step. - groups := GroupDuplicates(candidates) + groups := groupCandidates(candidates) res.Candidates = len(groups) if len(groups) == 0 { return res, nil } - // One window covers every candidate, so the marker scan is a single call. - windowEnd := now.Add(24 * time.Hour) - for _, g := range groups { - if due := g[0].DueAt; due != nil && due.After(windowEnd) { - windowEnd = due.Add(24 * time.Hour) - } + codes, err := st.Codes(ctx) + if err != nil { + return res, err } - existing, err := c.Events(ctx, from.Add(-24*time.Hour), windowEnd) + + // One window covers every candidate, so the marker scan is a single call. + windowStart, windowEnd := candidateWindow(groups, from, now, duration) + existing, err := c.Events(ctx, windowStart, windowEnd) if err != nil { // A failed read is not a licence to write blind: it would risk // duplicates, so record one loud failure and stop. @@ -135,7 +161,7 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl res.Failed = len(groups) res.Errors = append(res.Errors, msg) for _, g := range groups { - recordPush(ctx, st, c.Entity(), g, "", false, msg, log) + recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, msg, log) } log.Error("calendar sync aborted", "entity", c.Entity(), "err", err) return res, nil @@ -158,17 +184,17 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl log.Error("mark calendar item", "item", it.ID, "err", err) } } - recordPush(ctx, st, c.Entity(), g, uid, true, "", log) + recordPush(ctx, st, c.Entity(), itemsOf(g), uid, true, "", log) } res.Events = append(res.Events, Created{ - ItemID: first.ID, Child: children, Title: first.Title, + ItemID: first.ID, Kind: first.Kind, Child: children, Title: displayTitle(g, codes), Start: start, UID: uid, Summary: "already in the calendar", }) continue } created := Created{ - ItemID: first.ID, Child: children, Title: first.Title, + ItemID: first.ID, Kind: first.Kind, Child: children, Title: displayTitle(g, codes), Start: start, DryRun: opts.DryRun, } if opts.DryRun { @@ -178,17 +204,17 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl } uid, err := c.CreateEvent(ctx, Event{ - Summary: eventSummary(g), + Summary: eventSummary(g, codes), Description: eventDescription(g), Start: first.DueAt, End: ptrTime(first.DueAt.Add(duration)), }) if err != nil { res.Failed++ - msg := fmt.Sprintf("item %d (%s %q): %v", first.ID, children, first.Title, err) + msg := fmt.Sprintf("item %d (%s %q): %v", first.ID, children, displayTitle(g, codes), err) res.Errors = append(res.Errors, msg) log.Error("calendar write failed", "item", first.ID, "child", children, "title", first.Title, "entity", c.Entity(), "err", err) - recordPush(ctx, st, c.Entity(), g, "", false, err.Error(), log) + recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, err.Error(), log) continue } if uid == "" { @@ -220,6 +246,120 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl return res, nil } +// Pending is the number of events a run would consider, grouped so a +// school-wide event counts once. It is used by `school calendar status`. +func Pending(ctx context.Context, st *store.Store, opts Options) (int, error) { + now := opts.Now + if now.IsZero() { + now = time.Now() + } + from := opts.From + if from.IsZero() { + from = StartOfDay(now) + } + candidates, err := buildCandidates(ctx, st, opts, from, now) + if err != nil { + return 0, err + } + return len(groupCandidates(candidates)), nil +} + +// buildCandidates selects every item that belongs in the calendar and tags it +// with its category. Work that is already submitted, and work with no due date, +// is never a candidate (FR-8, FR-18). +func buildCandidates(ctx context.Context, st *store.Store, opts Options, from, now time.Time) ([]candidate, error) { + var out []candidate + + events, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, from, opts.Limit) + if err != nil { + return nil, err + } + for _, it := range events { + out = append(out, candidate{CalendarItem: it, Kind: KindEvent}) + } + + dayEnd := from.AddDate(0, 0, 1) + periods, err := st.CalendarPeriodCandidates(ctx, opts.ChildID, opts.ItemID, from, dayEnd, opts.Limit) + if err != nil { + return nil, err + } + for _, it := range periods { + out = append(out, candidate{CalendarItem: it, Kind: KindPeriod}) + } + + tasks, err := st.CalendarTaskCandidates(ctx, opts.ChildID, opts.ItemID, opts.Limit) + if err != nil { + return nil, err + } + for _, it := range tasks { + if dashboard.IsSubmittedStatus(dashboard.Status(it.Body)) { + continue + } + out = append(out, candidate{CalendarItem: it, Kind: workKind(it, from)}) + } + return out, nil +} + +// workKind splits outstanding work into overdue and due. A task is overdue when +// its due date is before today or Compass says its status is Overdue; otherwise +// it is due today or later. +func workKind(it store.CalendarItem, dayStart time.Time) Kind { + if dashboard.Status(it.Body) == "overdue" { + return KindOverdue + } + if it.DueAt != nil && it.DueAt.Before(dayStart) { + return KindOverdue + } + return KindDue +} + +// groupCandidates collapses the same item stored against more than one child +// (a school-wide event) into one calendar write. The key is the source, the +// type and the stable external reference — never the date or the title. +func groupCandidates(items []candidate) [][]candidate { + index := make(map[string]int, len(items)) + var out [][]candidate + for _, it := range items { + key := it.Source + "\x00" + it.Type + "\x00" + it.ExternalRef + if i, ok := index[key]; ok { + out[i] = append(out[i], it) + continue + } + index[key] = len(out) + out = append(out, []candidate{it}) + } + return out +} + +// candidateWindow is the Home Assistant read window that covers every candidate +// (work may be overdue since the start of term), padded by a day. +func candidateWindow(groups [][]candidate, from, now time.Time, duration time.Duration) (time.Time, time.Time) { + start := from + end := now.Add(24 * time.Hour) + for _, g := range groups { + due := g[0].DueAt + if due == nil { + continue + } + if due.Before(start) { + start = *due + } + if e := due.Add(duration + 24*time.Hour); e.After(end) { + end = e + } + } + return start.Add(-24 * time.Hour), end +} + +// itemsOf returns the raw items of a group, for the push log. +func itemsOf(g []candidate) []store.CalendarItem { + out := make([]store.CalendarItem, 0, len(g)) + for _, it := range g { + out = append(out, it.CalendarItem) + } + return out +} + // recordPush appends one push-log row per item in a group, so the log always // names every child whose event was written (FR-12). func recordPush(ctx context.Context, st *store.Store, entity string, g []store.CalendarItem, uid string, ok bool, errMsg string, log *slog.Logger) { @@ -250,7 +390,7 @@ func GroupDuplicates(items []store.CalendarItem) [][]store.CalendarItem { // adopt reports the Home Assistant event id when any item in the group already // has its event in the calendar, so a re-run never duplicates it. -func adopt(known map[string]Event, g []store.CalendarItem) (string, bool) { +func adopt(known map[string]Event, g []candidate) (string, bool) { for _, it := range g { ev, ok := known[Marker(it.ID)] if !ok { @@ -267,7 +407,7 @@ func adopt(known map[string]Event, g []store.CalendarItem) (string, bool) { } // groupChildren lists the children that share one event, sorted. -func groupChildren(g []store.CalendarItem) []string { +func groupChildren(g []candidate) []string { seen := make(map[string]bool, len(g)) var out []string for _, it := range g { @@ -281,11 +421,27 @@ func groupChildren(g []store.CalendarItem) []string { return out } -// eventSummary is the calendar entry title. A per-child event carries the +// displayTitle is the one-line title used in the CLI result. A period is +// decoded into plain English (FR-17); work carries its subject. +func displayTitle(g []candidate, codes store.CodeMap) string { + first := g[0] + title := strings.TrimSpace(first.Title) + switch first.Kind { + case KindPeriod, KindEvent: + title = decode.Period(title, codes) + case KindDue, KindOverdue: + if s := strings.TrimSpace(first.Subject); s != "" && s != title { + title = s + ": " + title + } + } + return title +} + +// eventSummary is the calendar entry title. A per-child entry carries the // child's name; a school-wide event (stored against more than one child) // carries just its title, because it is one event for the whole family. -func eventSummary(g []store.CalendarItem) string { - title := strings.TrimSpace(g[0].Title) +func eventSummary(g []candidate, codes store.CodeMap) string { + title := displayTitle(g, codes) if children := groupChildren(g); len(children) == 1 { return children[0] + " — " + title } @@ -294,7 +450,7 @@ func eventSummary(g []store.CalendarItem) string { // eventDescription carries the school detail, the Compass link and the // idempotency markers. The markers are what make a re-run safe. -func eventDescription(g []store.CalendarItem) string { +func eventDescription(g []candidate) string { first := g[0] var b strings.Builder body := strings.TrimSpace(first.Body) @@ -309,6 +465,7 @@ func eventDescription(g []store.CalendarItem) string { if first.Subject != "" { b.WriteString("Subject: " + first.Subject + "\n") } + b.WriteString("Kind: " + string(first.Kind) + "\n") if children := groupChildren(g); len(children) > 1 { b.WriteString("Children: " + strings.Join(children, ", ") + "\n") } diff --git a/internal/calendar/sync_test.go b/internal/calendar/sync_test.go index fc82fae..8bf0e5c 100644 --- a/internal/calendar/sync_test.go +++ b/internal/calendar/sync_test.go @@ -330,3 +330,85 @@ func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) { t.Fatalf("re-run duplicated the event (%d create calls)", ha.created()) } } + +// seedCalendarItem stores one item and returns it. +func seedCalendarItem(t *testing.T, st *store.Store, childID int64, item store.Item) store.Item { + t.Helper() + if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil { + t.Fatalf("upsert: %v", err) + } + items, err := st.ItemsForChild(context.Background(), childID) + if err != nil { + t.Fatalf("items: %v", err) + } + for _, it := range items { + if it.ExternalRef == item.ExternalRef { + return it + } + } + t.Fatalf("seeded item %q not found", item.ExternalRef) + return store.Item{} +} + +// TestSyncWritesPeriodsWorkAndOverdueOnce proves FR-8: a child's own periods, +// the work due in the future and the overdue work all reach the calendar, and a +// re-run adds nothing. Submitted work is never written. +func TestSyncWritesPeriodsWorkAndOverdueOnce(t *testing.T) { + ctx := context.Background() + st, childID := testStore(t) + loc := time.Local + now := StartOfDay(time.Now().In(loc)).Add(6 * time.Hour) + period := now.Add(2 * time.Hour).Truncate(time.Minute) + overdue := now.AddDate(0, 0, -3).Truncate(time.Minute) + future := now.AddDate(0, 0, 3).Truncate(time.Minute) + + seedCalendarItem(t, st, childID, store.Item{ + Source: "compass", Type: "event", Subject: "7SCF", + Title: "1 - 7SCF - H14LAB - KNI", DueAt: &period, + SourcePage: "child-profile", Importance: "normal", ExternalRef: "period-1", + }) + seedCalendarItem(t, st, childID, store.Item{ + Source: "compass", Type: "task", Subject: "Science", Title: "Lab report", + Body: "Status: Overdue", DueAt: &overdue, Importance: "high", ExternalRef: "overdue-1", + }) + seedCalendarItem(t, st, childID, store.Item{ + Source: "compass", Type: "task", Subject: "Maths", Title: "Exercise 4", + Body: "Status: Pending", DueAt: &future, Importance: "normal", ExternalRef: "due-1", + }) + seedCalendarItem(t, st, childID, store.Item{ + Source: "compass", Type: "task", Subject: "English", Title: "Done essay", + Body: "Status: On Time", DueAt: &overdue, Importance: "normal", ExternalRef: "submitted-1", + }) + + ha := newFakeHA(t, "calendar.school") + c := testClient(t, ha.URL, "calendar.school") + + res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger()) + if err != nil { + t.Fatalf("first Sync: %v", err) + } + if res.Created != 3 || res.Failed != 0 { + t.Fatalf("first result = %+v", res) + } + if ha.created() != 3 { + t.Fatalf("first Sync made %d create calls, want 3", ha.created()) + } + kinds := map[Kind]int{} + for _, ev := range res.Events { + kinds[ev.Kind]++ + } + if kinds[KindPeriod] != 1 || kinds[KindOverdue] != 1 || kinds[KindDue] != 1 { + t.Errorf("kinds = %+v, want one period, one overdue and one due", kinds) + } + + second, err := Sync(ctx, st, c, Options{Now: now}, silentLogger()) + if err != nil { + t.Fatalf("second Sync: %v", err) + } + if second.Created != 0 || second.Candidates != 0 { + t.Fatalf("second result = %+v, want nothing new", second) + } + if ha.created() != 3 { + t.Fatalf("re-run duplicated events (%d create calls)", ha.created()) + } +} diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index 6ad7f4a..5816e11 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" ) @@ -26,6 +27,13 @@ type Buckets struct { // so the summary can count them and nothing is silently dropped. Later []store.Item Done []store.Item + // PeriodsToday is the child's own timetable for today (FR-16/FR-17). + PeriodsToday []store.Item + // EventsToday is today's major school events (concerts, excursions, exams). + EventsToday []store.Item + // FutureWork is the outstanding work due today or later. The morning brief + // lists it under "DUE IN FUTURE" (FR-9). + FutureWork []store.Item } // Actionable is the number of task items that still need doing. @@ -78,6 +86,11 @@ func Classify(items []store.Item, now time.Time, loc *time.Location) Buckets { switch { case !due.Before(startToday) && due.Before(endToday): b.DueToday = append(b.DueToday, it) + if isPeriodItem(it) { + b.PeriodsToday = append(b.PeriodsToday, it) + } else { + b.EventsToday = append(b.EventsToday, it) + } case !due.Before(endToday) && due.Before(endWeek): b.DueWeek = append(b.DueWeek, it) case !due.Before(endWeek): @@ -106,14 +119,17 @@ func Classify(items []store.Item, now time.Time, loc *time.Location) Buckets { b.Overdue = append(b.Overdue, it) case due.Before(endToday): b.DueToday = append(b.DueToday, it) + b.FutureWork = append(b.FutureWork, it) case due.Before(endWeek): b.DueWeek = append(b.DueWeek, it) + b.FutureWork = append(b.FutureWork, it) default: b.Later = append(b.Later, it) + b.FutureWork = append(b.FutureWork, it) } } - for _, s := range [][]store.Item{b.Overdue, b.DueToday, b.DueWeek, b.NoDue, b.Later, b.Done} { + for _, s := range [][]store.Item{b.Overdue, b.DueToday, b.DueWeek, b.NoDue, b.Later, b.Done, b.PeriodsToday, b.EventsToday, b.FutureWork} { sort.SliceStable(s, func(i, j int) bool { return less(s[i], s[j]) }) } sort.SliceStable(b.Messages, func(i, j int) bool { @@ -171,8 +187,20 @@ var submittedStatuses = map[string]bool{ "completed": true, } +// IsSubmittedStatus reports whether a task status means the work no longer +// needs doing, so it is excluded from the calendar's work lists. +func IsSubmittedStatus(status string) bool { return isSubmitted(status) } + func isSubmitted(status string) bool { return submittedStatuses[status] } +// isPeriodItem reports whether an event is a routine class period from the +// child's own timetable rather than a school event. Compass writes a lesson as +// "1 - 7SCF - H14LAB - KNI" (FR-16/FR-17). +func isPeriodItem(it store.Item) bool { + parts := period.SplitDash(it.Title) + return len(parts) > 0 && period.IsPeriodLabel(parts[0]) +} + 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) @@ -363,3 +391,113 @@ func line(it store.Item, loc *time.Location) string { } return title + due + mark } + +// CombinedBrief is one child's block of the combined brief, in the required +// order: Finn's block first, then Harry's (FR-9). +type CombinedBrief struct { + Child string + Buckets Buckets +} + +// RenderBrief writes one child's block in the required Phase 7 shape (FR-9, +// FR-16, FR-17, FR-18): +// +// === FINN === +// PERIODS TODAY +// EVENTS TODAY +// OVERDUE +// DUE IN FUTURE +// +// An empty section is printed with "none"; it is never omitted silently. +// decode turns a raw lesson title into plain English (FR-17); pass nil to +// render the raw codes. +func RenderBrief(child string, b Buckets, decode func(string) string, loc *time.Location) string { + if loc == nil { + loc = time.UTC + } + var sb strings.Builder + fmt.Fprintf(&sb, "=== %s ===\n", strings.ToUpper(strings.TrimSpace(child))) + + writeBriefPeriods(&sb, b.PeriodsToday, decode, loc) + writeBriefEvents(&sb, b.EventsToday, loc) + writeBriefWork(&sb, "OVERDUE", b.Overdue, loc) + writeBriefFuture(&sb, b, loc) + return sb.String() +} + +// RenderCombinedBrief writes the single brief for Jo and Sam (FR-14) as two +// separate blocks — Finn first, then Harry — and never interleaves them (FR-9). +func RenderCombinedBrief(parts []CombinedBrief, decode func(string) string, loc *time.Location) string { + blocks := make([]string, 0, len(parts)) + for _, p := range parts { + blocks = append(blocks, RenderBrief(p.Child, p.Buckets, decode, loc)) + } + return strings.Join(blocks, "\n") +} + +// writeBriefPeriods lists the child's own periods for today, decoded (FR-17). +func writeBriefPeriods(sb *strings.Builder, items []store.Item, decode func(string) string, loc *time.Location) { + sb.WriteString("\nPERIODS TODAY\n") + if len(items) == 0 { + sb.WriteString("- none\n") + return + } + for _, it := range items { + title := strings.TrimSpace(it.Title) + if decode != nil { + title = decode(title) + } + when := "" + if it.DueAt != nil { + when = " (" + it.DueAt.In(loc).Format("15:04") + ")" + } + fmt.Fprintf(sb, "- %s%s\n", title, when) + } +} + +// writeBriefEvents lists today's major school events (FR-9b). +func writeBriefEvents(sb *strings.Builder, items []store.Item, loc *time.Location) { + sb.WriteString("\nEVENTS TODAY\n") + if len(items) == 0 { + sb.WriteString("- none\n") + return + } + for _, it := range items { + when := "" + if it.DueAt != nil { + when = " (" + it.DueAt.In(loc).Format("15:04") + ")" + } + fmt.Fprintf(sb, "- %s%s\n", strings.TrimSpace(it.Title), when) + } +} + +// writeBriefWork lists outstanding work (FR-9c). +func writeBriefWork(sb *strings.Builder, title string, items []store.Item, loc *time.Location) { + fmt.Fprintf(sb, "\n%s\n", title) + if len(items) == 0 { + sb.WriteString("- none\n") + return + } + for _, it := range items { + fmt.Fprintf(sb, "- %s\n", line(it, loc)) + } +} + +// writeBriefFuture lists work due today or later (FR-9d). The undated tail is +// never dumped: it is one line with a count and a pointer to the dashboard +// (FR-18). +func writeBriefFuture(sb *strings.Builder, b Buckets, loc *time.Location) { + sb.WriteString("\nDUE IN FUTURE\n") + wrote := false + for _, it := range b.FutureWork { + fmt.Fprintf(sb, "- %s\n", line(it, loc)) + wrote = true + } + if n := len(b.NoDue); n > 0 { + fmt.Fprintf(sb, "- %d task(s) with no due date (see the dashboard)\n", n) + wrote = true + } + if !wrote { + sb.WriteString("- none\n") + } +} diff --git a/internal/dashboard/dashboard_test.go b/internal/dashboard/dashboard_test.go index 50e1b5d..b71fa52 100644 --- a/internal/dashboard/dashboard_test.go +++ b/internal/dashboard/dashboard_test.go @@ -260,3 +260,87 @@ func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) { t.Errorf("the full render must keep every item:\n%s", full) } } + +// TestRenderBriefShape proves the required Phase 7 shape (FR-9, FR-17, FR-18): +// the four headings are always present, empty sections say "none", periods are +// decoded, and the undated tail is one counted line. +func TestRenderBriefShape(t *testing.T) { + loc := sydney(t) + now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc) + + items := []store.Item{ + {Type: "event", Title: "1 - 7SCF - H14LAB - KNI", DueAt: due(t, loc, "2026-10-16 08:55")}, + {Type: "event", Title: "End of Year Concert - HALL", DueAt: due(t, loc, "2026-10-16 18:00")}, + {Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, + {Type: "task", Subject: "Maths", Title: "Exercise 4", DueAt: due(t, loc, "2026-10-20 09:00"), Body: "Status: Pending"}, + } + for i := 0; i < 7; i++ { + items = append(items, store.Item{Type: "task", Subject: "English", Title: "Task", Body: "Status: Pending"}) + } + + b := Classify(items, now, loc) + text := RenderBrief("Finn", b, func(s string) string { + if s == "1 - 7SCF - H14LAB - KNI" { + return "Period 1 — Year 7 Science — Room H14LAB — Mr KNI" + } + return s + }, loc) + + for _, want := range []string{ + "=== FINN ===", + "PERIODS TODAY", + "Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55)", + "EVENTS TODAY", + "End of Year Concert - HALL (18:00)", + "OVERDUE", + "Science: Lab report", + "DUE IN FUTURE", + "Maths: Exercise 4", + "7 task(s) with no due date (see the dashboard)", + } { + if !strings.Contains(text, want) { + t.Errorf("brief is missing %q:\n%s", want, text) + } + } + // The undated dump is not listed item by item (FR-18). + if strings.Count(text, "English: Task") != 0 { + t.Errorf("the undated tail was dumped:\n%s", text) + } +} + +// TestRenderBriefEmptySectionsSayNone proves an empty section is printed, never +// omitted silently. +func TestRenderBriefEmptySectionsSayNone(t *testing.T) { + loc := sydney(t) + now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc) + text := RenderBrief("Harry", Classify(nil, now, loc), nil, loc) + if strings.Count(text, "none") != 4 { + t.Errorf("want the four headings each to say none:\n%s", text) + } +} + +// TestRenderCombinedBriefKeepsChildrenSeparate proves FR-9: Finn's block is +// first, Harry's second, and the two are never interleaved. +func TestRenderCombinedBriefKeepsChildrenSeparate(t *testing.T) { + loc := sydney(t) + now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc) + finn := Classify([]store.Item{ + {Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, + }, now, loc) + harry := Classify([]store.Item{ + {Type: "task", Subject: "English", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"}, + }, now, loc) + + text := RenderCombinedBrief([]CombinedBrief{{Child: "Finn", Buckets: finn}, {Child: "Harry", Buckets: harry}}, nil, loc) + i, j := strings.Index(text, "=== FINN ==="), strings.Index(text, "=== HARRY ===") + if i < 0 || j < 0 || i > j { + t.Fatalf("blocks are not Finn then Harry:\n%s", text) + } + finnBlock, harryBlock := text[i:j], text[j:] + if !strings.Contains(finnBlock, "Science: Lab report") || strings.Contains(finnBlock, "English: Essay") { + t.Errorf("Finn's block is wrong or interleaved:\n%s", finnBlock) + } + if !strings.Contains(harryBlock, "English: Essay") || strings.Contains(harryBlock, "Science: Lab report") { + t.Errorf("Harry's block is wrong or interleaved:\n%s", harryBlock) + } +} diff --git a/internal/decode/decode.go b/internal/decode/decode.go new file mode 100644 index 0000000..6a93267 --- /dev/null +++ b/internal/decode/decode.go @@ -0,0 +1,84 @@ +// Package decode turns a raw Compass lesson label into plain English using the +// editable codes map (AGENTS.md §4 FR-17). An unknown code renders as the raw +// code, never blank. +package decode + +import ( + "strings" + "unicode" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period" + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +// Period renders one lesson title as plain English, for example +// +// "1 - 7SCF - H14LAB - KNI" -> "Period 1 — Year 7 Science — Room H14LAB — Mr KNI" +// +// A title that is not a lesson (a concert, an excursion) is returned unchanged. +// A code that is not in the map falls back to the raw code (FR-17). +func Period(title string, m store.CodeMap) string { + slot, subject, room, teacher, ok := period.Split(strings.TrimSpace(title)) + if !ok { + return strings.TrimSpace(title) + } + + parts := make([]string, 0, 4) + parts = append(parts, periodName(slot, m)) + if subject != "" { + parts = append(parts, codeName(m, store.CodeSubject, subject)) + } + if room != "" { + parts = append(parts, codeName(m, store.CodeRoom, room)) + } + if teacher != "" { + parts = append(parts, teacherName(m, teacher)) + } + return strings.Join(parts, " — ") +} + +// teacherName decodes a teacher field. Compass sometimes carries several codes +// in the one field ("GT SMH"), so every token is decoded and joined. +func teacherName(m store.CodeMap, field string) string { + tokens := strings.Fields(field) + if len(tokens) <= 1 { + return codeName(m, store.CodeTeacher, field) + } + out := make([]string, 0, len(tokens)) + for _, tok := range tokens { + out = append(out, codeName(m, store.CodeTeacher, tok)) + } + return strings.Join(out, " / ") +} + +// periodName renders the period slot: a word label such as "C" uses the map +// ("Connect"), a number becomes "Period 3", and anything unknown stays raw. +func periodName(slot string, m store.CodeMap) string { + if name := strings.TrimSpace(m.Lookup(store.CodePeriod, slot)); name != "" { + return name + } + if isNumber(slot) { + return "Period " + slot + } + return slot +} + +// codeName returns the mapped name, or the raw code when it is not mapped. +func codeName(m store.CodeMap, kind, code string) string { + if name := strings.TrimSpace(m.Lookup(kind, code)); name != "" { + return name + } + return code +} + +func isNumber(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !unicode.IsDigit(r) { + return false + } + } + return true +} diff --git a/internal/decode/decode_test.go b/internal/decode/decode_test.go new file mode 100644 index 0000000..6aac647 --- /dev/null +++ b/internal/decode/decode_test.go @@ -0,0 +1,52 @@ +package decode + +import ( + "testing" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" +) + +func testCodes() store.CodeMap { + return store.CodeMap{ + Subject: map[string]string{"7SCF": "Year 7 Science", "11MGMAC2": "Year 11 General Maths"}, + Room: map[string]string{"H14LAB": "Room H14LAB", "B11": "Room B11"}, + Teacher: map[string]string{"KNI": "Mr KNI", "GT": "Mr GT"}, + Period: map[string]string{"C": "Connect"}, + } +} + +// TestPeriodDecodesKnownCodes proves FR-17: a lesson reads as plain English. +func TestPeriodDecodesKnownCodes(t *testing.T) { + cases := map[string]string{ + "1 - 7SCF - H14LAB - KNI": "Period 1 — Year 7 Science — Room H14LAB — Mr KNI", + "3 - 11MGMAC2 - B11 - GT": "Period 3 — Year 11 General Maths — Room B11 — Mr GT", + "C - 11MGMAC2 - B11 - GT": "Connect — Year 11 General Maths — Room B11 — Mr GT", + "RLE GROUP D - IM1 - RLE": "RLE GROUP D — IM1 — RLE", + "5 - 11HLEGB5 - D8": "Period 5 — 11HLEGB5 — D8", + "2 - 11HLEGB5": "Period 2 — 11HLEGB5", + } + for raw, want := range cases { + if got := Period(raw, testCodes()); got != want { + t.Errorf("Period(%q) = %q, want %q", raw, got, want) + } + } +} + +// TestPeriodUnknownCodesRenderRaw proves the fallback: a code that is not in +// the editable map renders as the raw code, never blank (FR-17). +func TestPeriodUnknownCodesRenderRaw(t *testing.T) { + got := Period("1 - 7ZZZ - Z99 - ZZZ", testCodes()) + want := "Period 1 — 7ZZZ — Z99 — ZZZ" + if got != want { + t.Errorf("Period = %q, want %q", got, want) + } +} + +// TestPeriodLeavesEventsAlone proves a school event is not mangled by the +// lesson decoder. +func TestPeriodLeavesEventsAlone(t *testing.T) { + raw := "End of Year Senior Music Concert - HALL - KSH, CFR" + if got := Period(raw, testCodes()); got != raw { + t.Errorf("Period = %q, want the event unchanged", got) + } +} diff --git a/internal/parse/calendar.go b/internal/parse/calendar.go index 0b49d93..ee0f6b0 100644 --- a/internal/parse/calendar.go +++ b/internal/parse/calendar.go @@ -6,6 +6,8 @@ import ( "strconv" "strings" "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period" ) // calendarPxPerHour is the Compass week-grid scale: the day gutter is 1200px @@ -21,13 +23,12 @@ var ( calendarTop = regexp.MustCompile(`top:\s*([0-9.]+)px`) // clockPrefix matches the leading "8:55: " of an event label. clockPrefix = regexp.MustCompile(`^(\d{1,2}):(\d{2}):\s*(.*)$`) - // periodLabel matches the period part of a lesson label: "5", "C" or - // "RLE GROUP D". - periodLabel = regexp.MustCompile(`^(\d{1,2}|[A-Z]|RLE GROUP [A-Z])$`) ) -// parseCalendar parses the week calendar into event items. It fails loudly when -// the page has no calendar grid (FR-12). +// parseCalendar parses the week calendar page. The week calendar is +// school-wide and its lesson grid belongs to the eldest child, so routine +// lessons are dropped here: each child's own periods come from that child's +// day widget (FR-16). Only real school events are kept. func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error) { items, found, err := extractCalendar(child, page, loc) if err != nil { @@ -36,7 +37,13 @@ func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error) if !found { return nil, fmt.Errorf("parse: %s: no calendar grid found in %s (layout changed or fetch failed)", child, page.Slug) } - return items, nil + out := items[:0] + for _, it := range items { + if eventIsSchoolEvent(it.Title) { + out = append(out, it) + } + } + return out, nil } // extractCalendar returns the calendar events in one page. found reports whether @@ -140,8 +147,8 @@ func eventItem(child string, ev *node, page Page, loc *time.Location) (Item, boo // label is "5 - 11HLEGB5 - D8 - LHT"; a school event is // "End of Year Senior Music Concert - HALL (...) - KSH, CFR, ...". func splitEvent(label, title string) (subject, teacher, body string) { - parts := splitDash(title) - if len(parts) >= 2 && periodLabel.MatchString(parts[0]) { + parts := period.SplitDash(title) + if len(parts) >= 2 && period.IsPeriodLabel(parts[0]) { for _, p := range parts[1:] { if subject == "" && classCode.MatchString(p) { subject = p @@ -159,11 +166,4 @@ func splitEvent(label, title string) (subject, teacher, body string) { } // splitDash splits on " - " and trims each part. -func splitDash(s string) []string { - raw := strings.Split(s, " - ") - out := make([]string, 0, len(raw)) - for _, p := range raw { - out = append(out, strings.TrimSpace(p)) - } - return out -} +func splitDash(s string) []string { return period.SplitDash(s) } diff --git a/internal/parse/daywidget.go b/internal/parse/daywidget.go new file mode 100644 index 0000000..5a9dbd5 --- /dev/null +++ b/internal/parse/daywidget.go @@ -0,0 +1,114 @@ +package parse + +import ( + "fmt" + "strings" + "time" +) + +// This file parses the per-child day widget that Compass renders on a child's +// own pages ("/Records/User.aspx?userId=..." and the Learning Tasks page). The +// widget is labelled "Today" and its events are that child's own periods: +// +// 8:55: 1 - 7SCF - H14LAB - KNI +// +// The week calendar is school-wide (it carries the eldest child's lesson grid), +// so it must never be the source of a child's timetable (AGENTS.md §4 FR-16). +// This is the per-child source. + +// dayWidgetBaseHour is unused: the widget's grid start moves with the day's +// first event (a page whose first event is 7:45 starts at 07:00, one whose +// first event is 8:55 starts at 06:00), so the printed clock is the reliable +// source and the grid offset is not. + +// parseDayWidget returns the periods in one child's day widget. found reports +// whether the page carried a day widget at all. +func parseDayWidget(child string, page Page, loc *time.Location, ref time.Time) (items []Item, found bool, err error) { + root := parseHTML(page.HTML) + events := findAll(root, func(n *node) bool { + return n.Tag == "div" && n.hasClassPart("day-bd-evt-") && n.hasClassPart("calendardaywidget-") + }) + if len(events) == 0 { + return nil, false, nil + } + + day := ref.In(loc) + if day.IsZero() { + day = time.Now().In(loc) + } + date := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc) + + for _, ev := range events { + labelSpan := find(ev, func(n *node) bool { return n.hasClass("ext-evt-bd") }) + if labelSpan == nil { + continue + } + label := collapse(labelSpan.Text()) + if label == "" { + continue + } + cm := clockPrefix.FindStringSubmatch(label) + if cm == nil { + return nil, true, fmt.Errorf("parse: %s: day-widget event %q has no start time", page.Slug, label) + } + at, err := dayWidgetTime(cm, date) + if err != nil { + return nil, true, fmt.Errorf("parse: %s: day-widget event %q: %w", page.Slug, label, err) + } + title := strings.TrimSpace(cm[3]) + if title == "" { + title = label + } + + subject, teacher, body := splitEvent(label, title) + url := page.URL + if url == "" { + url = compassHost + "/Organise/Calendar/" + } + + items = append(items, Item{ + Child: child, + Source: SourceCompass, + Type: TypeEvent, + Subject: subject, + Teacher: teacher, + Title: title, + Body: body, + DueAt: &at, + URL: url, + SourcePage: page.Slug, + ExternalRef: dedupeKey("day-period", at.Format(time.RFC3339), title), + Importance: importanceNormal, + }) + } + return items, true, nil +} + +// dayWidgetTime reads an event's start from the printed clock. Compass prints a +// 12-hour clock with no AM/PM marker ("1:26" is the afternoon period), so the +// school-day convention applies: 1-6 are PM, 7-11 are AM, 12 is noon. +func dayWidgetTime(clock []string, date time.Time) (time.Time, error) { + hour, minute := atoiSafe(clock[1]), atoiSafe(clock[2]) + if hour < 0 || hour > 23 || minute < 0 || minute > 59 { + return time.Time{}, fmt.Errorf("bad time %02d:%02d", hour, minute) + } + if hour >= 1 && hour <= 6 { // 1:26 means 1:26 PM in the Compass day grid + hour += 12 + } + return date.Add(time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute), nil +} + +// atoiSafe parses a small decimal number, returning -1 when it is not one. +func atoiSafe(s string) int { + n := 0 + if s == "" { + return -1 + } + for _, r := range s { + if r < '0' || r > '9' { + return -1 + } + n = n*10 + int(r-'0') + } + return n +} diff --git a/internal/parse/importance.go b/internal/parse/importance.go index d80ff1e..c59d94e 100644 --- a/internal/parse/importance.go +++ b/internal/parse/importance.go @@ -7,6 +7,8 @@ import ( "sort" "strings" "time" + + "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period" ) // Importance labels stored in items.importance (FR-4). @@ -69,11 +71,11 @@ func deterministicImportance(it Item, week time.Time) string { // "End of Year Senior Music Concert - HALL (...) - KSH, CFR". The first part of // a lesson is a period label ("5", "C" or "RLE GROUP D"). func eventIsSchoolEvent(title string) bool { - parts := splitDash(title) + parts := period.SplitDash(title) if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { return false } - return !periodLabel.MatchString(parts[0]) + return !period.IsPeriodLabel(parts[0]) } // ClassifyModel asks a model to classify importance for every item (FR-4). It diff --git a/internal/parse/parse.go b/internal/parse/parse.go index a9b7003..55b23f6 100644 --- a/internal/parse/parse.go +++ b/internal/parse/parse.go @@ -45,13 +45,16 @@ type Item struct { // Page is one stored raw page: its slug, the URL it was fetched from, and the // bytes. HTML pages carry HTML; the JSON service page carries JSON and the -// child's Compass userId (from the fetcher's meta file). +// child's Compass userId (from the fetcher's meta file). FetchedAt is when the +// fetcher stored the page, so a "Today" widget can be dated correctly even when +// an old run is re-parsed. type Page struct { - Slug string - URL string - HTML []byte - JSON []byte - UserID string + Slug string + URL string + HTML []byte + JSON []byte + UserID string + FetchedAt time.Time } // Parse turns the stored pages for one child into structured items. It fails, @@ -96,30 +99,39 @@ func Parse(child string, pages []Page, loc *time.Location) ([]Item, error) { items = append(items, got.items...) recognised++ case PageLearningTasks: - if hasAuthoritativeTasks { - recognised++ - continue + if !hasAuthoritativeTasks { + got, err := parseLearningTasks(child, p, loc) + if err != nil { + return nil, err + } + items = append(items, got...) } - got, err := parseLearningTasks(child, p, loc) + // The child's own day widget is the per-child timetable (FR-16). + day, _, err := parseDayWidget(child, p, loc, p.FetchedAt) if err != nil { return nil, err } - items = append(items, got...) + items = append(items, day...) recognised++ case PageChildProfile: // The React profile page has no server-rendered grid; when it does // carry the legacy grid, use it. The JSON list supersedes it. - if hasAuthoritativeTasks { - recognised++ - continue + if !hasAuthoritativeTasks { + got, found, err := extractLearningTasks(child, p, loc) + if err != nil { + return nil, err + } + if found { + items = append(items, got...) + } } - got, found, err := extractLearningTasks(child, p, loc) + // The profile page carries the child's own "Today" widget: the + // authoritative per-child periods (FR-16). + day, _, err := parseDayWidget(child, p, loc, p.FetchedAt) if err != nil { return nil, err } - if found { - items = append(items, got...) - } + items = append(items, day...) recognised++ case PageHome: got, err := parseNews(child, p, loc) diff --git a/internal/parse/parse_test.go b/internal/parse/parse_test.go index a2dec56..e5f1c9f 100644 --- a/internal/parse/parse_test.go +++ b/internal/parse/parse_test.go @@ -175,23 +175,20 @@ func TestParseCalendar(t *testing.T) { if err != nil { t.Fatalf("parseCalendar: %v", err) } - if len(items) != 2 { - t.Fatalf("got %d items, want 2", len(items)) + // The week calendar is school-wide, so its lesson grid is dropped: a + // child's own periods come from that child's day widget (FR-16). Only the + // real school event survives. + if len(items) != 1 { + t.Fatalf("got %d items, want 1 (the routine lesson is not a per-child period)", len(items)) } if items[0].Type != TypeEvent { t.Errorf("type = %q", items[0].Type) } if got := items[0].DueAt.Format("2006-01-02 15:04"); got != "2026-10-15 18:00" { - t.Errorf("event 1 time = %s", got) + t.Errorf("event time = %s", got) } if items[0].Title != "End of Year Concert - HALL - KSH" { - t.Errorf("event 1 title = %q", items[0].Title) - } - if items[1].Subject != "11HLEGB5" || items[1].Teacher != "LHT" { - t.Errorf("event 2 subject/teacher = %q/%q", items[1].Subject, items[1].Teacher) - } - if got := items[1].DueAt.Format("15:04"); got != "14:06" { - t.Errorf("event 2 time = %q", got) + t.Errorf("event title = %q", items[0].Title) } } @@ -430,3 +427,87 @@ func TestParseReconcilesOverdueWithHome(t *testing.T) { t.Fatal("Parse accepted a home page with no record of the child (FR-12)") } } + +// dayWidgetFixture is the per-child "Today" widget Compass renders on a child's +// own profile page. The child is Finn (Year 7). +const dayWidgetFixture = ` +
+
Today
+
+ 8:55: 1 - 7SCF - H14LAB - KNI +
+
+ 9:54: 2 - 7DESF - B1 - PSI +
+
+ 1:26: 4 - 7PEF - GYM4 - LTA +
+
+` + +// TestParseDayWidgetIsPerChild proves FR-16: a child's periods come from that +// child's own day widget, and the school-wide week calendar's lesson grid is +// never stored against the child. +func TestParseDayWidgetIsPerChild(t *testing.T) { + loc := melbourne(t) + fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc) + pages := []Page{ + {Slug: PageChildProfile, URL: "https://nhs-vic.compass.education/Records/User.aspx?userId=4242", HTML: []byte(dayWidgetFixture), FetchedAt: fetched}, + {Slug: PageCalendar, URL: "https://nhs-vic.compass.education/Organise/Calendar/", HTML: []byte(calendarFixture), FetchedAt: fetched}, + } + items, err := Parse("Finn", pages, loc) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + leaked := 0 + periods := 0 + for _, it := range items { + if it.Subject == "11HLEGB5" { + leaked++ + } + if it.SourcePage == PageChildProfile && it.Type == TypeEvent { + periods++ + if it.DueAt == nil || it.DueAt.In(loc).Format("2006-01-02") != "2026-10-16" { + t.Errorf("period %q dated %v, want 2026-10-16", it.Title, it.DueAt) + } + if it.SourcePage != PageChildProfile { + t.Errorf("period %q source page = %q, want %q", it.Title, it.SourcePage, PageChildProfile) + } + } + } + if leaked != 0 { + t.Errorf("Finn's items carry %d Year 11 lesson(s) from the school-wide calendar", leaked) + } + if periods != 3 { + t.Errorf("Finn has %d periods, want 3", periods) + } +} + +// TestDayWidgetParsesRoomAndTeacher proves the label is split into its parts so +// the brief can decode them. +func TestDayWidgetParsesRoomAndTeacher(t *testing.T) { + loc := melbourne(t) + fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc) + items, found, err := parseDayWidget("Finn", Page{Slug: PageChildProfile, HTML: []byte(dayWidgetFixture), FetchedAt: fetched}, loc, fetched) + if err != nil || !found { + t.Fatalf("parseDayWidget: found=%v err=%v", found, err) + } + if len(items) != 3 { + t.Fatalf("got %d periods, want 3", len(items)) + } + if items[0].Subject != "7SCF" { + t.Errorf("subject = %q", items[0].Subject) + } + if items[0].Title != "1 - 7SCF - H14LAB - KNI" { + t.Errorf("title = %q", items[0].Title) + } + if got := items[0].DueAt.In(loc).Format("15:04"); got != "08:55" { + t.Errorf("start = %q, want 08:55", got) + } + // The printed clock is 12-hour: the 1:26 period is 1:26 PM (the grid + // offset is the authority). + if got := items[2].DueAt.In(loc).Format("15:04"); got != "13:26" { + t.Errorf("afternoon start = %q, want 13:26", got) + } +} diff --git a/internal/period/period.go b/internal/period/period.go new file mode 100644 index 0000000..a51ee84 --- /dev/null +++ b/internal/period/period.go @@ -0,0 +1,54 @@ +// Package period understands the Compass lesson label: +// +// [period] - [subject code] - [room code] - [teacher initials] +// +// for example "1 - 7SCF - H14LAB - KNI". It is a leaf package so the parser, +// the brief and the calendar all agree on what a routine class period is +// (AGENTS.md §4 FR-16/FR-17, §7). +package period + +import ( + "regexp" + "strings" +) + +// labelRe matches the period part of a lesson label: "5", "C" or +// "RLE GROUP D". A school event's first part ("End of Year Senior Music +// Concert") does not match, which is how a lesson is told from an event. +var labelRe = regexp.MustCompile(`^(\d{1,2}|[A-Z]|RLE GROUP [A-Z])$`) + +// IsPeriodLabel reports whether s is a period slot rather than an event name. +func IsPeriodLabel(s string) bool { + return labelRe.MatchString(strings.TrimSpace(s)) +} + +// Split breaks a lesson title into its components. ok is false when the first +// part is not a period label, so the caller renders the title unchanged. +// +// A lesson may have fewer than four parts when Compass omits the room or the +// teacher ("1 - 11HLEGB5 -"), so missing components come back as "". +func Split(title string) (period, subject, room, teacher string, ok bool) { + parts := SplitDash(title) + if len(parts) < 2 || !IsPeriodLabel(parts[0]) { + return "", "", "", "", false + } + period = parts[0] + subject = parts[1] + if len(parts) >= 3 { + room = parts[2] + } + if len(parts) >= 4 { + teacher = parts[3] + } + return period, subject, room, teacher, true +} + +// SplitDash splits on " - " and trims each part. +func SplitDash(s string) []string { + raw := strings.Split(s, " - ") + out := make([]string, 0, len(raw)) + for _, p := range raw { + out = append(out, strings.TrimSpace(p)) + } + return out +} diff --git a/internal/store/calendar.go b/internal/store/calendar.go index eb14679..cd5fc9e 100644 --- a/internal/store/calendar.go +++ b/internal/store/calendar.go @@ -47,6 +47,52 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64, AND i.due_at IS NOT NULL AND i.due_at >= ?` args := []any{from.UTC().Format(time.RFC3339)} + q, args = narrowCalendarQuery(q, args, childID, itemID) + q += ` ORDER BY i.due_at ASC, i.id ASC` + q, args = capCalendarQuery(q, args, limit) + return s.queryCalendarItems(ctx, q, args) +} + +// CalendarPeriodCandidates returns the per-child periods for one day that are +// not in the calendar yet. They come from the child's own day widget +// (source_page child-profile / learning-tasks), never from the school-wide week +// calendar (FR-16). A period is a routine lesson, so it carries importance +// "normal"; school events are written by CalendarCandidates. +func (s *Store) CalendarPeriodCandidates(ctx context.Context, childID, itemID *int64, dayStart, dayEnd time.Time, limit int) ([]CalendarItem, error) { + q := calendarSelect + ` + WHERE i.type = 'event' + AND i.source = 'compass' + AND i.importance = 'normal' + AND COALESCE(i.ha_event_uid, '') = '' + AND i.source_page IN ('child-profile', 'learning-tasks') + AND i.due_at IS NOT NULL + AND i.due_at >= ? AND i.due_at < ?` + args := []any{dayStart.UTC().Format(time.RFC3339), dayEnd.UTC().Format(time.RFC3339)} + q, args = narrowCalendarQuery(q, args, childID, itemID) + q += ` ORDER BY i.due_at ASC, i.id ASC` + q, args = capCalendarQuery(q, args, limit) + return s.queryCalendarItems(ctx, q, args) +} + +// CalendarTaskCandidates returns the outstanding work that carries a due date +// and is not in the calendar yet: the work due in the future and the work +// overdue (FR-8). Submitted work is filtered by the caller, which holds the +// body-status rule. +func (s *Store) CalendarTaskCandidates(ctx context.Context, childID, itemID *int64, limit int) ([]CalendarItem, error) { + q := calendarSelect + ` + WHERE i.type = 'task' + AND i.source = 'compass' + AND COALESCE(i.ha_event_uid, '') = '' + AND i.due_at IS NOT NULL` + args := []any{} + q, args = narrowCalendarQuery(q, args, childID, itemID) + q += ` ORDER BY i.due_at ASC, i.id ASC` + q, args = capCalendarQuery(q, args, limit) + return s.queryCalendarItems(ctx, q, args) +} + +// narrowCalendarQuery appends the optional child and item filters. +func narrowCalendarQuery(q string, args []any, childID, itemID *int64) (string, []any) { if childID != nil { q += ` AND i.child_id = ?` args = append(args, *childID) @@ -55,12 +101,19 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64, q += ` AND i.id = ?` args = append(args, *itemID) } - q += ` ORDER BY i.due_at ASC, i.id ASC` + return q, args +} + +func capCalendarQuery(q string, args []any, limit int) (string, []any) { if limit > 0 { q += ` LIMIT ?` args = append(args, limit) } + return q, args +} +// queryCalendarItems runs a candidate query and reads every row. +func (s *Store) queryCalendarItems(ctx context.Context, q string, args []any) ([]CalendarItem, error) { rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("calendar candidates: %w", err) @@ -118,6 +171,45 @@ func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem, return &it, nil } +// DeleteChildRoutineCalendarEvents removes a child's routine class periods that +// came from the school-wide week calendar (source_page "calendar", importance +// "normal"). The week calendar's lesson grid belongs to the eldest child, so +// storing it against a younger child is a leak (FR-16); the child's own day +// widget is the per-child source. Real school events (importance "high") were +// written with the same page and are kept. +func (s *Store) DeleteChildRoutineCalendarEvents(ctx context.Context, childID int64) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM items + WHERE child_id = ? AND type = 'event' AND source_page = 'calendar' AND importance = 'normal'`, + childID) + if err != nil { + return 0, fmt.Errorf("delete routine calendar events: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, err + } + return n, nil +} + +// DeleteChildDayWidgetEvents removes a child's previously parsed day-widget +// periods so a re-parse replaces the snapshot instead of accumulating stale +// days or wrong times. It only touches events from the child's own pages. +func (s *Store) DeleteChildDayWidgetEvents(ctx context.Context, childID int64) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM items + WHERE child_id = ? AND type = 'event' AND source_page IN ('child-profile', 'learning-tasks')`, + childID) + if err != nil { + return 0, fmt.Errorf("delete day-widget events: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, err + } + return n, nil +} + // MarkItemCalendarSynced records the Home Assistant event id and the push time // on one item. After this the item is no longer a calendar candidate. func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error { diff --git a/internal/store/calendar_test.go b/internal/store/calendar_test.go index 73e3fdf..ca9b62f 100644 --- a/internal/store/calendar_test.go +++ b/internal/store/calendar_test.go @@ -113,3 +113,36 @@ func TestCalendarPushSummaryIsAppendOnly(t *testing.T) { t.Fatalf("last = %+v", summary.Last) } } + +// TestCodesSeedAndEdit proves the decode map is seeded, editable and that a +// correction survives a re-seed (FR-17). +func TestCodesSeedAndEdit(t *testing.T) { + ctx := context.Background() + st := newTestStore(t) + + codes, err := st.Codes(ctx) + if err != nil { + t.Fatalf("codes: %v", err) + } + if codes.Subject["7SCF"] != "Year 7 Science" { + t.Errorf("7SCF = %q, want the seeded Year 7 Science", codes.Subject["7SCF"]) + } + if codes.Subject["NOPE"] != "" { + t.Errorf("unknown code should be missing, got %q", codes.Subject["NOPE"]) + } + + if _, err := st.SetCode(ctx, "teacher", "KNI", "Mr Smith"); err != nil { + t.Fatalf("set: %v", err) + } + again, err := st.Codes(ctx) + if err != nil { + t.Fatalf("codes again: %v", err) + } + if again.Teacher["KNI"] != "Mr Smith" { + t.Errorf("KNI = %q, want the correction to survive the re-seed", again.Teacher["KNI"]) + } + + if _, err := st.SetCode(ctx, "bogus", "X", "Y"); err == nil { + t.Error("an unknown kind should be rejected") + } +} diff --git a/internal/store/codes.go b/internal/store/codes.go new file mode 100644 index 0000000..123775c --- /dev/null +++ b/internal/store/codes.go @@ -0,0 +1,263 @@ +package store + +import ( + "context" + "fmt" + "strings" +) + +// This file owns the editable decode map (AGENTS.md §4 FR-17, §7): the raw +// Compass subject, room, teacher and period codes and the plain-English names +// they render as. The map is data because the codes change between years and +// new subjects appear each term; the seed below only fills gaps, so a +// correction Sam makes in the dashboard is never overwritten. + +// Code kinds stored in the codes table. +const ( + CodeSubject = "subject" + CodeRoom = "room" + CodeTeacher = "teacher" + CodePeriod = "period" +) + +// CodeMap is the whole decode table, grouped by kind. A missing code is not an +// error: the renderer falls back to the raw code (never blank). +type CodeMap struct { + Subject map[string]string + Room map[string]string + Teacher map[string]string + Period map[string]string +} + +// Lookup returns the mapped name for one kind and code, or "" when the code is +// not mapped. +func (m CodeMap) Lookup(kind, code string) string { + code = strings.TrimSpace(code) + switch kind { + case CodeSubject: + return m.Subject[code] + case CodeRoom: + return m.Room[code] + case CodeTeacher: + return m.Teacher[code] + case CodePeriod: + return m.Period[code] + default: + return "" + } +} + +// seedCodes are the mappings the project ships with. Subjects use the school's +// own names; rooms are "Room "; teacher entries are placeholders because +// Compass only supplies initials, so Sam replaces them with surnames. +var seedCodes = []struct { + Kind string + Code string + Name string +}{ + // Subjects seen in the Phase 2/4 captures (AGENTS.md §7 seed table plus the + // real captures). + {CodeSubject, "7SCF", "Year 7 Science"}, + {CodeSubject, "7DESF", "Year 7 Design"}, + {CodeSubject, "7TUTF", "Year 7 Tutorial"}, + {CodeSubject, "7ENF", "Year 7 English"}, + {CodeSubject, "7PEF", "Year 7 Physical Education"}, + {CodeSubject, "7DRAF", "Year 7 Drama"}, + {CodeSubject, "7HU", "Year 7 Humanities"}, + {CodeSubject, "7EN", "Year 7 English"}, + {CodeSubject, "7PE", "Year 7 Physical Education"}, + {CodeSubject, "7CHI", "Year 7 Chinese"}, + {CodeSubject, "11MGMAC2", "Year 11 General Maths"}, + {CodeSubject, "11XMUSA6", "Year 11 Music"}, + {CodeSubject, "11SBIOA1", "Year 11 Biology"}, + {CodeSubject, "11HLEGB5", "Year 11 Legal Studies"}, + {CodeSubject, "11AACPA4", "Year 11 Art Creative Practice"}, + {CodeSubject, "11EENGB3", "Year 11 English"}, + {CodeSubject, "11TUTB", "Year 11 Tutorial"}, + {CodeSubject, "11MGMA", "Year 11 General Maths"}, + {CodeSubject, "11XMUS", "Year 11 Music"}, + {CodeSubject, "11SBIO", "Year 11 Biology"}, + {CodeSubject, "11HLEG", "Year 11 Legal Studies"}, + {CodeSubject, "11EENG", "Year 11 English"}, + {CodeSubject, "11AACP", "Year 11 Art Creative Practice"}, + + // Rooms seen in the captures (AGENTS.md §7: B11 / M6 / A1 read as + // "Room B11" / "Room M6" / "Room A1"). + {CodeRoom, "H14LAB", "Room H14LAB"}, + {CodeRoom, "B11", "Room B11"}, + {CodeRoom, "M6", "Room M6"}, + {CodeRoom, "A1", "Room A1"}, + {CodeRoom, "G7", "Room G7"}, + {CodeRoom, "B1", "Room B1"}, + {CodeRoom, "C1", "Room C1"}, + {CodeRoom, "H3", "Room H3"}, + {CodeRoom, "M4", "Room M4"}, + {CodeRoom, "D8", "Room D8"}, + {CodeRoom, "IM1", "Room IM1"}, + {CodeRoom, "IM3", "Room IM3"}, + {CodeRoom, "IM5", "Room IM5"}, + {CodeRoom, "B13", "Room B13"}, + {CodeRoom, "GYM4", "Gym 4"}, + {CodeRoom, "THEATRE", "Theatre"}, + {CodeRoom, "HALL", "Hall"}, + {CodeRoom, "ENS", "Ensemble Room"}, + + // Teacher initials. Compass gives only initials, so these are placeholders + // in the AGENTS.md "Mr GT" form; Sam replaces them with surnames. + {CodeTeacher, "KNI", "Mr KNI"}, + {CodeTeacher, "PSI", "Mr PSI"}, + {CodeTeacher, "KDI", "Mr KDI"}, + {CodeTeacher, "AFR", "Mr AFR"}, + {CodeTeacher, "LHT", "Mr LHT"}, + {CodeTeacher, "RLE", "Mr RLE"}, + {CodeTeacher, "JBR", "Mr JBR"}, + {CodeTeacher, "GT", "Mr GT"}, + {CodeTeacher, "ANE", "Mr ANE"}, + {CodeTeacher, "BDR", "Mr BDR"}, + {CodeTeacher, "CHA", "Mr CHA"}, + {CodeTeacher, "HZH", "Mr HZH"}, + {CodeTeacher, "SLW", "Mr SLW"}, + {CodeTeacher, "KWO", "Mr KWO"}, + {CodeTeacher, "HHL", "Mr HHL"}, + {CodeTeacher, "OFG", "Mr OFG"}, + {CodeTeacher, "DCA", "Mr DCA"}, + {CodeTeacher, "LTA", "Mr LTA"}, + {CodeTeacher, "KSH", "Mr KSH"}, + {CodeTeacher, "CFR", "Mr CFR"}, + {CodeTeacher, "CMA", "Mr CMA"}, + {CodeTeacher, "KSM", "Mr KSM"}, + + // Period labels that are words rather than numbers (AGENTS.md §7: C and + // "RLE GROUP D" are tutorials, Connect or assembly). A number is rendered + // as "Period N" without a table entry; anything else falls back to raw. + {CodePeriod, "C", "Connect"}, + {CodePeriod, "RLE GROUP D", "RLE Group D"}, +} + +// SeedCodes inserts the shipped mappings that are not present yet. It is +// idempotent and never overwrites a value, so Sam's corrections survive every +// start and every scan. +func (s *Store) SeedCodes(ctx context.Context) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("seed codes: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + for _, c := range seedCodes { + if _, err := tx.ExecContext(ctx, + `INSERT INTO codes (kind, code, name) VALUES (?, ?, ?) + ON CONFLICT(kind, code) DO NOTHING`, + c.Kind, c.Code, c.Name); err != nil { + return fmt.Errorf("seed code %s %q: %w", c.Kind, c.Code, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("seed codes: commit: %w", err) + } + return nil +} + +// Codes returns the whole decode map. It seeds the shipped defaults first, so a +// fresh database renders the known captures and a corrected row is kept. +func (s *Store) Codes(ctx context.Context) (CodeMap, error) { + if err := s.SeedCodes(ctx); err != nil { + return CodeMap{}, err + } + rows, err := s.db.QueryContext(ctx, `SELECT kind, code, name FROM codes`) + if err != nil { + return CodeMap{}, fmt.Errorf("codes: %w", err) + } + defer rows.Close() + + out := CodeMap{ + Subject: map[string]string{}, + Room: map[string]string{}, + Teacher: map[string]string{}, + Period: map[string]string{}, + } + for rows.Next() { + var kind, code, name string + if err := rows.Scan(&kind, &code, &name); err != nil { + return CodeMap{}, fmt.Errorf("scan code: %w", err) + } + switch kind { + case CodeSubject: + out.Subject[code] = name + case CodeRoom: + out.Room[code] = name + case CodeTeacher: + out.Teacher[code] = name + case CodePeriod: + out.Period[code] = name + } + } + return out, rows.Err() +} + +// Code is one row of the decode map, for the `school codes` CLI. +type Code struct { + Kind string `json:"kind"` + Code string `json:"code"` + Name string `json:"name"` +} + +// ListCodes returns every mapping, ordered by kind then code. +func (s *Store) ListCodes(ctx context.Context) ([]Code, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT kind, code, name FROM codes ORDER BY kind, code`) + if err != nil { + return nil, fmt.Errorf("list codes: %w", err) + } + defer rows.Close() + + var out []Code + for rows.Next() { + var c Code + if err := rows.Scan(&c.Kind, &c.Code, &c.Name); err != nil { + return nil, fmt.Errorf("scan code: %w", err) + } + out = append(out, c) + } + return out, rows.Err() +} + +// SetCode creates or corrects one mapping and returns the row. It is how Sam +// edits the decode map (FR-17). +func (s *Store) SetCode(ctx context.Context, kind, code, name string) (Code, error) { + kind = strings.ToLower(strings.TrimSpace(kind)) + code = strings.TrimSpace(code) + name = strings.TrimSpace(name) + switch kind { + case CodeSubject, CodeRoom, CodeTeacher, CodePeriod: + default: + return Code{}, fmt.Errorf("unknown code kind %q (want subject, room, teacher or period)", kind) + } + if code == "" { + return Code{}, fmt.Errorf("code is required") + } + if name == "" { + return Code{}, fmt.Errorf("name is required (an empty mapping renders blank)") + } + if _, err := s.db.ExecContext(ctx, + `INSERT INTO codes (kind, code, name, updated_at) VALUES (?, ?, ?, datetime('now')) + ON CONFLICT(kind, code) DO UPDATE SET name = excluded.name, updated_at = datetime('now')`, + kind, code, name); err != nil { + return Code{}, fmt.Errorf("set code %s %q: %w", kind, code, err) + } + return Code{Kind: kind, Code: code, Name: name}, nil +} + +// DeleteCode removes one mapping, so the code falls back to raw. It reports +// whether a row existed. +func (s *Store) DeleteCode(ctx context.Context, kind, code string) (bool, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM codes WHERE kind = ? AND code = ?`, + strings.ToLower(strings.TrimSpace(kind)), strings.TrimSpace(code)) + if err != nil { + return false, fmt.Errorf("delete code: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, err + } + return n > 0, nil +} diff --git a/internal/store/migrations/005_codes.sql b/internal/store/migrations/005_codes.sql new file mode 100644 index 0000000..1ca1fab --- /dev/null +++ b/internal/store/migrations/005_codes.sql @@ -0,0 +1,19 @@ +-- Migration 005 — the editable decode map (AGENTS.md §4 FR-17, §7). +-- +-- Compass writes a lesson as "[period] - [subject code] - [room code] - +-- [teacher initials]", for example "1 - 7SCF - H14LAB - KNI". The brief must +-- read that as plain English ("Period 1 — Year 7 Science — Room H14LAB — Mr +-- KNI"), so the mapping is data, not code. School codes change between years, +-- so the table is editable and the seed only fills the gaps. +-- +-- A code that is not in the table renders as the raw Compass code, never blank. +CREATE TABLE IF NOT EXISTS codes ( + id INTEGER PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN ('subject', 'room', 'teacher', 'period')), + code TEXT NOT NULL, + name TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (kind, code) +); + +CREATE INDEX IF NOT EXISTS idx_codes_kind ON codes(kind, code); diff --git a/internal/store/store.go b/internal/store/store.go index df7a4a8..954b6ca 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -375,6 +375,40 @@ func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID in return res.RowsAffected() } +// DeleteItemsFromPages is DeleteItems narrowed to the given source pages. It is +// used to drop a stale snapshot (for example a child's previously parsed day +// widget) without touching the same child's other rows. +func (s *Store) DeleteItemsFromPages(ctx context.Context, childID int64, source, itemType string, pages ...string) (int64, error) { + if len(pages) == 0 { + return 0, nil + } + placeholders := strings.Repeat("?, ", len(pages)) + placeholders = strings.TrimSuffix(placeholders, ", ") + q := `DELETE FROM items WHERE child_id = ?` + args := []any{childID} + if source != "" { + q += ` AND source = ?` + args = append(args, source) + } + if itemType != "" { + q += ` AND type = ?` + args = append(args, itemType) + } + q += ` AND source_page IN (` + placeholders + `)` + for _, p := range pages { + args = append(args, p) + } + res, err := s.db.ExecContext(ctx, q, args...) + if err != nil { + return 0, fmt.Errorf("delete items for child %d from pages: %w", childID, err) + } + n, err := res.RowsAffected() + if err != nil { + return 0, err + } + return n, nil +} + // TypeCount is the number of items of one type for one child. type TypeCount struct { Child string diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 2be5669..87bd932 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -312,7 +312,7 @@ func TestSummaryBuildAndDelete(t *testing.T) { } page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID)) - if !strings.Contains(page, "Harry — 0 overdue") { + if !strings.Contains(page, "=== HARRY ===") { t.Errorf("brief not shown on dashboard: %s", snippet(page)) } resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete", diff --git a/scripts/calendar-push.sh b/scripts/calendar-push.sh index 868d1f8..bff514a 100755 --- a/scripts/calendar-push.sh +++ b/scripts/calendar-push.sh @@ -13,7 +13,7 @@ # scripts/calendar-push.sh events [--days 14] # # Environment overrides: -# IMAGE image tag (default 0.5.0-phase7) +# IMAGE image tag (default 0.6.2-phase6b7b) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # ENV_FILE HA config source (default environment.d/school-service.env) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney) @@ -25,7 +25,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}" +IMAGE="${IMAGE:-compass-teams-nhs/school:0.6.2-phase6b7b}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" diff --git a/scripts/morning-brief.sh b/scripts/morning-brief.sh index 9f12e61..d489c3d 100755 --- a/scripts/morning-brief.sh +++ b/scripts/morning-brief.sh @@ -3,9 +3,11 @@ # # This is the bounded command the 6:00am n8n schedule runs: # 1. scan Compass for both children (read-only; skipped with --no-scan), -# 2. build one brief per boy and one combined brief to Jo and Sam, -# 3. deliver them through the house Apprise API on `.35:8210`, -# 4. record every delivery in `notifications` and every exit code loudly. +# 2. write the day's periods, due work, overdue work and school events to +# Sam's Home Assistant School calendar (idempotent; skipped with --no-calendar), +# 3. build one brief per boy and one combined brief to Jo and Sam, +# 4. deliver them through the house Apprise API on `.35:8210`, +# 5. record every delivery in `notifications` and every exit code loudly. # # A failed scan is never reported as "nothing due" (FR-12): the brief opens with # the failure, and this script exits non-zero so the n8n execution is visibly @@ -16,13 +18,14 @@ # is only exported into the container environment. # # Usage: -# scripts/morning-brief.sh # scan, then send -# scripts/morning-brief.sh --no-scan # send from the latest scan +# scripts/morning-brief.sh # scan, calendar, then send +# scripts/morning-brief.sh --no-scan # calendar and send from the latest scan +# scripts/morning-brief.sh --no-scan --no-calendar # send only # scripts/morning-brief.sh --dry-run # build and print, send nothing # scripts/morning-brief.sh --channel email # limit the channels # # Environment overrides: -# IMAGE image tag (default 0.6.1-phase8) +# IMAGE image tag (default 0.6.2-phase6b7b) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # ENV_FILE service env file (default environment.d/school-service.env) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney) @@ -31,18 +34,20 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="${IMAGE:-compass-teams-nhs/school:0.6.1-phase8}" +IMAGE="${IMAGE:-compass-teams-nhs/school:0.6.2-phase6b7b}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" DOCKER_TIMEOUT="${DOCKER_TIMEOUT:-600}" NO_SCAN=0 +NO_CALENDAR=0 NOTIFY_ARGS=() while [[ $# -gt 0 ]]; do case "$1" in --no-scan) NO_SCAN=1; shift ;; - --dry-run) NOTIFY_ARGS+=(--dry-run); shift ;; + --no-calendar) NO_CALENDAR=1; shift ;; + --dry-run) NOTIFY_ARGS+=(--dry-run); NO_CALENDAR=1; shift ;; --channel) NOTIFY_ARGS+=(--channel "$2"); shift 2 ;; --only) NOTIFY_ARGS+=(--only "$2"); shift 2 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; @@ -55,7 +60,8 @@ if [[ -f "$ENV_FILE" ]]; then while IFS= read -r line; do case "$line" in SCHOOL_APPRISE_URL=*|SCHOOL_APPRISE_KEY=*|SCHOOL_APPRISE_TOKEN=*|SCHOOL_APPRISE_TIMEOUT=*|\ - SCHOOL_APPRISE_CHANNELS=*|SCHOOL_APPRISE_TAGS=*|SCHOOL_APPRISE_PARENTS=*|SCHOOL_NOTIFY_TOKEN=*) + SCHOOL_APPRISE_CHANNELS=*|SCHOOL_APPRISE_TAGS=*|SCHOOL_APPRISE_PARENTS=*|SCHOOL_NOTIFY_TOKEN=*|\ + SCHOOL_HA_URL=*|SCHOOL_HA_TOKEN=*|SCHOOL_HA_CALENDAR_ENTITY=*|SCHOOL_HA_INSECURE=*|SCHOOL_HA_TIMEOUT=*) key="${line%%=*}" val="${line#*=}" if [[ -z "${!key:-}" ]]; then @@ -83,6 +89,11 @@ run_school() { -e SCHOOL_APPRISE_CHANNELS="${SCHOOL_APPRISE_CHANNELS:-}" \ -e SCHOOL_APPRISE_TAGS="${SCHOOL_APPRISE_TAGS:-}" \ -e SCHOOL_APPRISE_PARENTS="${SCHOOL_APPRISE_PARENTS:-}" \ + -e SCHOOL_HA_URL="${SCHOOL_HA_URL:-}" \ + -e SCHOOL_HA_TOKEN="${SCHOOL_HA_TOKEN:-}" \ + -e SCHOOL_HA_CALENDAR_ENTITY="${SCHOOL_HA_CALENDAR_ENTITY:-}" \ + -e SCHOOL_HA_INSECURE="${SCHOOL_HA_INSECURE:-}" \ + -e SCHOOL_HA_TIMEOUT="${SCHOOL_HA_TIMEOUT:-}" \ "$IMAGE" "$@" } @@ -97,15 +108,31 @@ if [[ "$NO_SCAN" -eq 0 ]]; then fi fi -# 2. Build and send the briefs. +# 2. Write the calendar for every child. The write is idempotent: the same +# item is checked on every pass and never re-inserted (FR-8). A failure is +# loud and makes the run exit non-zero. +calendar_rc=0 +if [[ "$NO_CALENDAR" -eq 0 ]]; then + echo "== write the School calendar (periods + due + overdue + events) ==" + if [[ -n "${SCHOOL_HA_URL:-}" && -n "${SCHOOL_HA_TOKEN:-}" ]]; then + if ! run_school calendar push; then + calendar_rc=$? + echo "calendar push failed (exit $calendar_rc)" >&2 + fi + else + echo "SCHOOL_HA_URL/SCHOOL_HA_TOKEN are not set: the calendar was not written" >&2 + fi +fi + +# 3. Build and send the briefs. echo "== send the morning briefs ==" notify_rc=0 if ! run_school notify run "${NOTIFY_ARGS[@]+"${NOTIFY_ARGS[@]}"}"; then notify_rc=$? fi -if [[ "$scan_rc" -ne 0 || "$notify_rc" -ne 0 ]]; then - echo "morning brief FAILED: scan_rc=$scan_rc notify_rc=$notify_rc" >&2 +if [[ "$scan_rc" -ne 0 || "$calendar_rc" -ne 0 || "$notify_rc" -ne 0 ]]; then + echo "morning brief FAILED: scan_rc=$scan_rc calendar_rc=$calendar_rc notify_rc=$notify_rc" >&2 exit 1 fi echo "morning brief complete: both boys + the combined brief to Jo and Sam" diff --git a/scripts/parse-compass.sh b/scripts/parse-compass.sh index 61fde2f..2882f73 100755 --- a/scripts/parse-compass.sh +++ b/scripts/parse-compass.sh @@ -9,7 +9,7 @@ # scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto] # # Environment overrides: -# IMAGE image tag (default 0.5.0-phase7) +# IMAGE image tag (default 0.6.2-phase6b7b) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # RAW_ROOT host path to var/raw (default /var/raw) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) @@ -22,7 +22,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}" +IMAGE="${IMAGE:-compass-teams-nhs/school:0.6.2-phase6b7b}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" diff --git a/scripts/scan-compass.sh b/scripts/scan-compass.sh index d95b18b..604d982 100755 --- a/scripts/scan-compass.sh +++ b/scripts/scan-compass.sh @@ -21,7 +21,7 @@ # scripts/scan-compass.sh --child Harry --child Finn --classify none # # Environment overrides: -# IMAGE image tag (default compass-teams-nhs/school:0.5.0-phase7) +# IMAGE image tag (default compass-teams-nhs/school:0.6.2-phase6b7b) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # RAW_ROOT host path to var/raw (default /var/raw) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) @@ -33,7 +33,7 @@ set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE="${IMAGE:-compass-teams-nhs/school:0.5.0-phase7}" +IMAGE="${IMAGE:-compass-teams-nhs/school:0.6.2-phase6b7b}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"