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 <noreply@paperclip.ing>
This commit is contained in:
2026-10-16 10:25:55 +11:00
parent 4950755b3d
commit 047af0f31d
40 changed files with 2049 additions and 160 deletions

View File

@@ -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-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-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-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-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-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-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-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-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-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 ## 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 Decoding `11MGMAC2`: `11` = Year level · `M` = Faculty (Maths) · `GM` = Subject
(General Maths) · `AC2` = timetable block. (General Maths) · `AC2` = timetable block.
**Decoding is OPTIONAL and NOT yet instructed.** The raw codes are what Compass **Decoding is REQUIRED (FR-17).** Sam wants periods in plain English, not codes.
supplies, and rendering them as-is (`11XMUSA6 · A1 · JBR`) is acceptable.
If Sam later asks for readable names, store them as an **editable `codes` map** — Store the mappings as an **editable `codes` map** — never hardcode, because teacher
never hardcode, because initials and room codes change between years. An unknown code initials and room codes change between years and new subject codes appear each term.
must render as the **raw code**, never blank.
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 ## 8. Integrations
@@ -302,8 +318,16 @@ Resolved:
key, never the date or title. key, never the date or title.
15. **Brief order (FR-9):** today's periods → today's major events → work overdue → 15. **Brief order (FR-9):** today's periods → today's major events → work overdue →
future due. Lists only. future due. Lists only.
16. **Codes are NOT decoded (yet).** Raw Compass codes (`11XMUSA6 · A1 · JBR`) are 16. **Codes ARE decoded (FR-17).** Periods must read as plain English:
acceptable. Decoding was raised as a question, not an instruction — see §7. "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 13. **Not spoken unless asked.** The HA calendar stays silent unless Sam adds an
automation. Google Calendar is separate and already speaks. 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 14. **Email is the report channel.** Each boy gets his own by email; Jo and Sam get

View File

@@ -1,5 +1,5 @@
IMAGE ?= compass-teams-nhs/school:0.6.1-phase8 IMAGE ?= compass-teams-nhs/school:0.6.2-phase6b7b
BUILD_VERSION ?= 0.6.1-phase8 BUILD_VERSION ?= 0.6.2-phase6b7b
# Parse options (see scripts/parse-compass.sh). # Parse options (see scripts/parse-compass.sh).
CHILD ?= Harry CHILD ?= Harry

View File

@@ -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 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 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 one combined brief to Jo and Sam. Each boy's block comes **separately, Finn
Assistant Local Calendar `calendar.school`. The Hub reads items through the 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. read-only `POST /ask` contract.
The accepted scraper is **patchright** (headless Playwright), not the 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 `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). [`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 ```bash
make notify # send the briefs from the latest scan make notify # send the briefs from the latest scan
make preview # print the briefs, send nothing 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. # Delivery log: per recipient per channel, plus the scan state.
docker exec compass-teams-nhs /app/school notify status 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 The destinations live in the Apprise configuration, not here: this service only
names a tag such as `harry_email` (AGENTS.md §10 rule 3). 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 ## Status — Phase 6
Phase 6 writes the **important** school events to **Sam's Home Assistant Phase 6 writes, for **each child separately**, that day's **periods**, the
calendar** through the Home Assistant REST API (FR-8). It is idempotent (a **work due** (any future date), the **work overdue**, and the **major school
re-run never duplicates an event) and it fails loudly (a failed write is shown events** to **Sam's Home Assistant calendar** through the Home Assistant REST
on the dashboard and recorded in `calendar_pushes`). See 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). [`docs/phase-6-calendar-notes.md`](docs/phase-6-calendar-notes.md).
The target is a **Home Assistant Local Calendar**, `calendar.school` (added 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 entities # list the writable calendar entities
scripts/calendar-push.sh status # target, reachability, pending, last push scripts/calendar-push.sh status # target, reachability, pending, last push
scripts/calendar-push.sh push --dry-run scripts/calendar-push.sh push --dry-run
scripts/calendar-push.sh push # write the pending important events scripts/calendar-push.sh push # write the pending periods/work/events
scripts/calendar-push.sh events # read the calendar back 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' make calendar CALENDAR_ARGS='push --dry-run'
``` ```
@@ -229,8 +263,8 @@ docker compose up -d --build
Generate secrets with the built image: Generate secrets with the built image:
```bash ```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.2-phase6b7b 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 hash-password 'the-password'
``` ```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash

View File

@@ -125,14 +125,11 @@ func calendarStatus(args []string) {
os.Exit(1) os.Exit(1)
} }
now := time.Now().In(cfg.Location) 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 { if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err) fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1) 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 { if asJSON {
out := struct { out := struct {
@@ -174,7 +171,7 @@ func calendarStatus(args []string) {
} else { } else {
fmt.Printf("reachable yes\n") 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) fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed)
if summary.Last != nil { if summary.Last != nil {
state := "ok" state := "ok"
@@ -272,7 +269,11 @@ func printCalendarResult(res calendar.Result, loc *time.Location) {
if uid == "" { if uid == "" {
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 { for _, msg := range res.Errors {
fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg) fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg)
@@ -285,9 +286,11 @@ func calendarEvents(args []string) {
fs := flag.NewFlagSet("calendar events", flag.ContinueOnError) fs := flag.NewFlagSet("calendar events", flag.ContinueOnError)
var ( var (
days int days int
past int
asJSON bool asJSON bool
) )
fs.IntVar(&days, "days", 14, "how many days ahead to read") 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.BoolVar(&asJSON, "json", false, "print the events as JSON")
_ = fs.Parse(args) _ = fs.Parse(args)
@@ -302,8 +305,9 @@ func calendarEvents(args []string) {
defer st.Close() defer st.Close()
now := time.Now().In(cfg.Location) now := time.Now().In(cfg.Location)
start := now.AddDate(0, 0, -past)
end := now.AddDate(0, 0, days) 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 { if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err) fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1) os.Exit(1)
@@ -321,7 +325,7 @@ func calendarEvents(args []string) {
if len(events) == 0 { if len(events) == 0 {
fmt.Printf("no events on %s between %s and %s\n", fmt.Printf("no events on %s between %s and %s\n",
cfg.Calendar.EntityID, cfg.Calendar.EntityID,
now.Format("2006-01-02 15:04"), start.Format("2006-01-02 15:04"),
end.Format("2006-01-02 15:04")) end.Format("2006-01-02 15:04"))
return return
} }

160
cmd/school/codes.go Normal file
View File

@@ -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)
}

View File

@@ -5,6 +5,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"strings"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "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"). // stored against the wrong child ("nothing may leak between children").
func itemsCommand(args []string) { func itemsCommand(args []string) {
if len(args) == 0 || args[0] != "purge" { 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) os.Exit(2)
} }
fs := flag.NewFlagSet("items purge", flag.ContinueOnError) fs := flag.NewFlagSet("items purge", flag.ContinueOnError)
var ( var (
child string child string
source string source string
typ string typ string
sourcePages multiFlag
) )
fs.StringVar(&child, "child", "", "child name (Harry or Finn)") fs.StringVar(&child, "child", "", "child name (Harry or Finn)")
fs.StringVar(&source, "source", "", "limit to one source") fs.StringVar(&source, "source", "", "limit to one source")
fs.StringVar(&typ, "type", "", "limit to one type") fs.StringVar(&typ, "type", "", "limit to one type")
fs.Var(&sourcePages, "source-page", "limit to one stored page slug (repeatable)")
_ = fs.Parse(args[1:]) _ = fs.Parse(args[1:])
if child == "" { if child == "" {
fmt.Fprintln(os.Stderr, "school items purge: --child is required") fmt.Fprintln(os.Stderr, "school items purge: --child is required")
os.Exit(2) 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) fmt.Fprintln(os.Stderr, "school items:", err)
os.Exit(1) 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() ctx := context.Background()
cfg, err := config.LoadDB() cfg, err := config.LoadDB()
@@ -61,10 +74,15 @@ func runItemsPurge(child, source, typ string) error {
if c == nil { if c == nil {
return fmt.Errorf("unknown child %q", child) 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 { if err != nil {
return err 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 return nil
} }

View File

@@ -62,6 +62,9 @@ func main() {
case "calendar": case "calendar":
calendarCommand(os.Args[2:]) calendarCommand(os.Args[2:])
return return
case "codes":
codesCommand(os.Args[2:])
return
case "version": case "version":
fmt.Println(version) fmt.Println(version)
return return
@@ -97,6 +100,9 @@ func run() error {
if err := st.SeedChildren(context.Background(), seedChildren...); err != nil { if err := st.SeedChildren(context.Background(), seedChildren...); err != nil {
return err return err
} }
if err := st.SeedCodes(context.Background()); err != nil {
return err
}
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure, am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, cfg.CookieSecure,
cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword) cfg.AdminUsername, cfg.AdminPasswordHash, cfg.AdminPassword)

View File

@@ -118,6 +118,7 @@ func buildOnly(ctx context.Context, svc *brief.Service, st *store.Store, only st
if len(children) == 0 { if len(children) == 0 {
return nil, fmt.Errorf("no children on file") return nil, fmt.Errorf("no children on file")
} }
children = brief.OrderChildren(children)
var out []brief.Send var out []brief.Send
if only != "combined" { if only != "combined" {

View File

@@ -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) inserted, updated, err := st.UpsertItems(ctx, child.ID, stored)
if err != nil { if err != nil {
_, _ = st.RecordScan(ctx, &child.ID, len(pages), len(items), false, err.Error()) _, _ = 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 { type rawPage struct {
htmlBytes, jsonBytes []byte htmlBytes, jsonBytes []byte
url, user string url, user string
fetchedAt time.Time
} }
bySlug := map[string]*rawPage{} bySlug := map[string]*rawPage{}
var order []string 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) return "", nil, fmt.Errorf("read %s: %w", name, err)
} }
var meta struct { var meta struct {
URL string `json:"url"` URL string `json:"url"`
UserID string `json:"userId"` UserID string `json:"userId"`
FetchedAt string `json:"fetchedAt"`
} }
_ = json.Unmarshal(raw, &meta) _ = json.Unmarshal(raw, &meta)
p := get(slug) p := get(slug)
p.url = strings.TrimSpace(meta.URL) p.url = strings.TrimSpace(meta.URL)
p.user = strings.TrimSpace(meta.UserID) 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"): case strings.HasSuffix(lower, ".json"):
if strings.EqualFold(name, "manifest.json") { if strings.EqualFold(name, "manifest.json") {
continue continue
@@ -387,11 +405,12 @@ func loadRunPages(rawRoot, child, run string) (string, []parse.Page, error) {
continue continue
} }
pages = append(pages, parse.Page{ pages = append(pages, parse.Page{
Slug: slug, Slug: slug,
URL: rp.url, URL: rp.url,
HTML: rp.htmlBytes, HTML: rp.htmlBytes,
JSON: rp.jsonBytes, JSON: rp.jsonBytes,
UserID: rp.user, UserID: rp.user,
FetchedAt: rp.fetchedAt,
}) })
} }
if len(pages) == 0 { if len(pages) == 0 {

View File

@@ -76,7 +76,7 @@ the API directly on the internal address.
`SCHOOL_APPRISE_KEY`, and generate the n8n token: `SCHOOL_APPRISE_KEY`, and generate the n8n token:
```bash ```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=<the value> # -> SCHOOL_NOTIFY_TOKEN=<the value>
``` ```

View File

@@ -10,8 +10,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.6.1-phase8" VERSION: "0.6.2-phase6b7b"
image: compass-teams-nhs/school:0.6.1-phase8 image: compass-teams-nhs/school:0.6.2-phase6b7b
container_name: compass-teams-nhs container_name: compass-teams-nhs
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -34,7 +34,7 @@ make restart # restart the service only
``` ```
The container is `compass-teams-nhs`. The image tag is 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 ## Operate

View File

@@ -1,5 +1,11 @@
# Phase 6 — the calendar write (Sam's Home Assistant calendar) # 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 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 the service writes important school events to it through the Home Assistant REST
API. API.

243
docs/phase-6b7b-notes.md Normal file
View File

@@ -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:<item id>]` 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.

View File

@@ -1,5 +1,10 @@
# Phase 7 — Notify and send (Apprise: Email + WhatsApp) # 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 Status: **done** (2026-10-15). A brief was delivered to real recipients on both
channels. See *Evidence* at the end. channels. See *Evidence* at the end.

View File

@@ -15,12 +15,14 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise" "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/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "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" "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) return "", false, fmt.Errorf("items for %s: %w", child.Name, err)
} }
buckets := dashboard.Classify(items, s.Now(), s.Loc) 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) sc, err := s.Store.ScanSummaryForChild(ctx, child.ID)
if err != nil { if err != nil {
@@ -132,18 +138,32 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string,
return notice + "\n" + text, false, nil 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 // CombinedText returns the single brief for Jo and Sam — both boys in one
// message — and the children whose scan did not complete. // message — and the children whose scan did not complete.
func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) { 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 failed []string
var notices []string var notices []string
dec, err := s.decoder(ctx)
if err != nil {
return "", nil, err
}
for _, c := range children { for _, c := range children {
items, err := s.Store.ItemsForChild(ctx, c.ID) items, err := s.Store.ItemsForChild(ctx, c.ID)
if err != nil { if err != nil {
return "", nil, fmt.Errorf("items for %s: %w", c.Name, err) return "", nil, fmt.Errorf("items for %s: %w", c.Name, err)
} }
parts = append(parts, dashboard.Combined{ parts = append(parts, dashboard.CombinedBrief{
Child: c.Name, Child: c.Name,
Buckets: dashboard.Classify(items, s.Now(), s.Loc), 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) failed = append(failed, c.Name)
} }
} }
text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits) text := dashboard.RenderCombinedBrief(parts, dec, s.Loc)
if len(notices) > 0 { if len(notices) > 0 {
text = strings.Join(notices, "\n") + "\n\n" + text 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 { if len(children) == 0 {
return nil, fmt.Errorf("no children on file: nothing to brief") 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 var out []planned
for _, c := range children { for _, c := range children {
@@ -379,6 +401,15 @@ func (s *Service) plan(ctx context.Context) ([]planned, error) {
return out, nil 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 { func combinedNames(children []store.Child) string {
names := make([]string, 0, len(children)) names := make([]string, 0, len(children))
for _, c := range children { for _, c := range children {

View File

@@ -148,16 +148,20 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) {
t.Fatalf("morning: %v", err) t.Fatalf("morning: %v", err)
} }
if len(sends) != 3 { 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 { for _, s := range sends {
if !s.OK() { if !s.OK() {
t.Errorf("send %s not OK: %+v", s.Scope, s) 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. // 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) t.Errorf("Harry deliveries = %d, want 2", got)
} }
combined := sends[2] combined := sends[2]
@@ -193,10 +197,16 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) {
} }
} }
if !strings.Contains(combined.Text, "OVERDUE") || if !strings.Contains(combined.Text, "OVERDUE") ||
!strings.Contains(combined.Text, "Harry: English: Essay") || !strings.Contains(combined.Text, "=== FINN ===") ||
!strings.Contains(combined.Text, "Finn: Science: Lab report") { !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) 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) rows, err := st.RecentNotifications(context.Background(), 20)
if err != nil { if err != nil {
@@ -278,19 +288,19 @@ func TestFailedScanIsNeverNothingDue(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("morning: %v", err) t.Fatalf("morning: %v", err)
} }
if len(sends[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" { if len(sends[1].ScanFailed) != 1 || sends[1].ScanFailed[0] != "Harry" {
t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed) t.Errorf("Harry scan failure not reported: %+v", sends[1].ScanFailed)
} }
if !strings.Contains(sends[0].Text, "SCAN FAILED") { if !strings.Contains(sends[1].Text, "SCAN FAILED") {
t.Errorf("brief does not say the scan failed: %q", sends[0].Text) t.Errorf("brief does not say the scan failed: %q", sends[1].Text)
} }
if !strings.Contains(sends[0].Text, "UNKNOWN") { if !strings.Contains(sends[1].Text, "UNKNOWN") {
t.Errorf("brief does not say due work is unknown: %q", sends[0].Text) t.Errorf("brief does not say due work is unknown: %q", sends[1].Text)
} }
if !strings.Contains(sends[2].Text, "SCAN FAILED") { if !strings.Contains(sends[2].Text, "SCAN FAILED") {
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text) 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") t.Error("a scan failure must make the run not OK")
} }
} }

View File

@@ -156,8 +156,8 @@ type Event struct {
Summary string `json:"summary"` Summary string `json:"summary"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
Start *time.Time `json:"-"` Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"-"` End *time.Time `json:"end,omitempty"`
StartRaw string `json:"-"` StartRaw string `json:"-"`
EndRaw string `json:"-"` EndRaw string `json:"-"`
} }

View File

@@ -9,6 +9,8 @@ import (
"strings" "strings"
"time" "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" "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. // ItemMarker is the full description tag written to the calendar.
func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) } 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. // Options narrows one sync run.
type Options struct { type Options struct {
// ChildID limits the run to one child (nil means all children). // 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). // Created is one event that the run wrote (or would write on a dry run).
type Created struct { type Created struct {
ItemID int64 `json:"item_id"` ItemID int64 `json:"item_id"`
Kind Kind `json:"kind"`
Child string `json:"child"` Child string `json:"child"`
Title string `json:"title"` Title string `json:"title"`
Start string `json:"start"` Start string `json:"start"`
@@ -76,13 +90,27 @@ type Result struct {
DryRun bool `json:"dry_run,omitempty"` 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 // 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 // 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 // item's marker is adopted instead of written again. Identity is the stable
// in the append-only calendar_pushes table, so a failure is visible and loud // per-item key, never the date or title. Every attempt is recorded in the
// rather than swallowed (FR-12). // 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) { func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) {
if st == nil { if st == nil {
return Result{}, errors.New("calendar: store is required") 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} 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 { if err != nil {
return res, err return res, err
} }
// The Compass week calendar is school-wide, so the same event is stored groups := groupCandidates(candidates)
// against each child. Write it once, and keep every child's item in step.
groups := GroupDuplicates(candidates)
res.Candidates = len(groups) res.Candidates = len(groups)
if len(groups) == 0 { if len(groups) == 0 {
return res, nil return res, nil
} }
// One window covers every candidate, so the marker scan is a single call. codes, err := st.Codes(ctx)
windowEnd := now.Add(24 * time.Hour) if err != nil {
for _, g := range groups { return res, err
if due := g[0].DueAt; due != nil && due.After(windowEnd) {
windowEnd = due.Add(24 * time.Hour)
}
} }
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 { if err != nil {
// A failed read is not a licence to write blind: it would risk // A failed read is not a licence to write blind: it would risk
// duplicates, so record one loud failure and stop. // 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.Failed = len(groups)
res.Errors = append(res.Errors, msg) res.Errors = append(res.Errors, msg)
for _, g := range groups { 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) log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
return res, nil 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) 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{ 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", Start: start, UID: uid, Summary: "already in the calendar",
}) })
continue continue
} }
created := Created{ 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, Start: start, DryRun: opts.DryRun,
} }
if 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{ uid, err := c.CreateEvent(ctx, Event{
Summary: eventSummary(g), Summary: eventSummary(g, codes),
Description: eventDescription(g), Description: eventDescription(g),
Start: first.DueAt, Start: first.DueAt,
End: ptrTime(first.DueAt.Add(duration)), End: ptrTime(first.DueAt.Add(duration)),
}) })
if err != nil { if err != nil {
res.Failed++ 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) res.Errors = append(res.Errors, msg)
log.Error("calendar write failed", "item", first.ID, "child", children, "title", first.Title, "entity", c.Entity(), "err", err) 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 continue
} }
if uid == "" { if uid == "" {
@@ -220,6 +246,120 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
return res, nil 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 // recordPush appends one push-log row per item in a group, so the log always
// names every child whose event was written (FR-12). // 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) { 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 // 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. // 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 { for _, it := range g {
ev, ok := known[Marker(it.ID)] ev, ok := known[Marker(it.ID)]
if !ok { 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. // 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)) seen := make(map[string]bool, len(g))
var out []string var out []string
for _, it := range g { for _, it := range g {
@@ -281,11 +421,27 @@ func groupChildren(g []store.CalendarItem) []string {
return out 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) // 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. // carries just its title, because it is one event for the whole family.
func eventSummary(g []store.CalendarItem) string { func eventSummary(g []candidate, codes store.CodeMap) string {
title := strings.TrimSpace(g[0].Title) title := displayTitle(g, codes)
if children := groupChildren(g); len(children) == 1 { if children := groupChildren(g); len(children) == 1 {
return children[0] + " — " + title return children[0] + " — " + title
} }
@@ -294,7 +450,7 @@ func eventSummary(g []store.CalendarItem) string {
// eventDescription carries the school detail, the Compass link and the // eventDescription carries the school detail, the Compass link and the
// idempotency markers. The markers are what make a re-run safe. // 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] first := g[0]
var b strings.Builder var b strings.Builder
body := strings.TrimSpace(first.Body) body := strings.TrimSpace(first.Body)
@@ -309,6 +465,7 @@ func eventDescription(g []store.CalendarItem) string {
if first.Subject != "" { if first.Subject != "" {
b.WriteString("Subject: " + first.Subject + "\n") b.WriteString("Subject: " + first.Subject + "\n")
} }
b.WriteString("Kind: " + string(first.Kind) + "\n")
if children := groupChildren(g); len(children) > 1 { if children := groupChildren(g); len(children) > 1 {
b.WriteString("Children: " + strings.Join(children, ", ") + "\n") b.WriteString("Children: " + strings.Join(children, ", ") + "\n")
} }

View File

@@ -330,3 +330,85 @@ func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created()) 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())
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "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. // so the summary can count them and nothing is silently dropped.
Later []store.Item Later []store.Item
Done []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. // 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 { switch {
case !due.Before(startToday) && due.Before(endToday): case !due.Before(startToday) && due.Before(endToday):
b.DueToday = append(b.DueToday, it) 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): case !due.Before(endToday) && due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it) b.DueWeek = append(b.DueWeek, it)
case !due.Before(endWeek): 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) b.Overdue = append(b.Overdue, it)
case due.Before(endToday): case due.Before(endToday):
b.DueToday = append(b.DueToday, it) b.DueToday = append(b.DueToday, it)
b.FutureWork = append(b.FutureWork, it)
case due.Before(endWeek): case due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it) b.DueWeek = append(b.DueWeek, it)
b.FutureWork = append(b.FutureWork, it)
default: default:
b.Later = append(b.Later, it) 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(s, func(i, j int) bool { return less(s[i], s[j]) })
} }
sort.SliceStable(b.Messages, func(i, j int) bool { sort.SliceStable(b.Messages, func(i, j int) bool {
@@ -171,8 +187,20 @@ var submittedStatuses = map[string]bool{
"completed": true, "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] } 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 { func startOfDay(t time.Time, loc *time.Location) time.Time {
t = t.In(loc) t = t.In(loc)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, 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 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")
}
}

View File

@@ -260,3 +260,87 @@ func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) {
t.Errorf("the full render must keep every item:\n%s", full) 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)
}
}

84
internal/decode/decode.go Normal file
View File

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

View File

@@ -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)
}
}

View File

@@ -6,6 +6,8 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
) )
// calendarPxPerHour is the Compass week-grid scale: the day gutter is 1200px // 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`) calendarTop = regexp.MustCompile(`top:\s*([0-9.]+)px`)
// clockPrefix matches the leading "8:55: " of an event label. // clockPrefix matches the leading "8:55: " of an event label.
clockPrefix = regexp.MustCompile(`^(\d{1,2}):(\d{2}):\s*(.*)$`) 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 // parseCalendar parses the week calendar page. The week calendar is
// the page has no calendar grid (FR-12). // 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) { func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error) {
items, found, err := extractCalendar(child, page, loc) items, found, err := extractCalendar(child, page, loc)
if err != nil { if err != nil {
@@ -36,7 +37,13 @@ func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error)
if !found { if !found {
return nil, fmt.Errorf("parse: %s: no calendar grid found in %s (layout changed or fetch failed)", child, page.Slug) 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 // 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 // label is "5 - 11HLEGB5 - D8 - LHT"; a school event is
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR, ...". // "End of Year Senior Music Concert - HALL (...) - KSH, CFR, ...".
func splitEvent(label, title string) (subject, teacher, body string) { func splitEvent(label, title string) (subject, teacher, body string) {
parts := splitDash(title) parts := period.SplitDash(title)
if len(parts) >= 2 && periodLabel.MatchString(parts[0]) { if len(parts) >= 2 && period.IsPeriodLabel(parts[0]) {
for _, p := range parts[1:] { for _, p := range parts[1:] {
if subject == "" && classCode.MatchString(p) { if subject == "" && classCode.MatchString(p) {
subject = p subject = p
@@ -159,11 +166,4 @@ func splitEvent(label, title string) (subject, teacher, body string) {
} }
// splitDash splits on " - " and trims each part. // splitDash splits on " - " and trims each part.
func splitDash(s string) []string { func splitDash(s string) []string { return period.SplitDash(s) }
raw := strings.Split(s, " - ")
out := make([]string, 0, len(raw))
for _, p := range raw {
out = append(out, strings.TrimSpace(p))
}
return out
}

114
internal/parse/daywidget.go Normal file
View File

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

View File

@@ -7,6 +7,8 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
) )
// Importance labels stored in items.importance (FR-4). // 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 // "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"). // a lesson is a period label ("5", "C" or "RLE GROUP D").
func eventIsSchoolEvent(title string) bool { func eventIsSchoolEvent(title string) bool {
parts := splitDash(title) parts := period.SplitDash(title)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return false 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 // ClassifyModel asks a model to classify importance for every item (FR-4). It

View File

@@ -45,13 +45,16 @@ type Item struct {
// Page is one stored raw page: its slug, the URL it was fetched from, and the // 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 // 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 { type Page struct {
Slug string Slug string
URL string URL string
HTML []byte HTML []byte
JSON []byte JSON []byte
UserID string UserID string
FetchedAt time.Time
} }
// Parse turns the stored pages for one child into structured items. It fails, // 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...) items = append(items, got.items...)
recognised++ recognised++
case PageLearningTasks: case PageLearningTasks:
if hasAuthoritativeTasks { if !hasAuthoritativeTasks {
recognised++ got, err := parseLearningTasks(child, p, loc)
continue 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 { if err != nil {
return nil, err return nil, err
} }
items = append(items, got...) items = append(items, day...)
recognised++ recognised++
case PageChildProfile: case PageChildProfile:
// The React profile page has no server-rendered grid; when it does // The React profile page has no server-rendered grid; when it does
// carry the legacy grid, use it. The JSON list supersedes it. // carry the legacy grid, use it. The JSON list supersedes it.
if hasAuthoritativeTasks { if !hasAuthoritativeTasks {
recognised++ got, found, err := extractLearningTasks(child, p, loc)
continue 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 { if err != nil {
return nil, err return nil, err
} }
if found { items = append(items, day...)
items = append(items, got...)
}
recognised++ recognised++
case PageHome: case PageHome:
got, err := parseNews(child, p, loc) got, err := parseNews(child, p, loc)

View File

@@ -175,23 +175,20 @@ func TestParseCalendar(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("parseCalendar: %v", err) t.Fatalf("parseCalendar: %v", err)
} }
if len(items) != 2 { // The week calendar is school-wide, so its lesson grid is dropped: a
t.Fatalf("got %d items, want 2", len(items)) // 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 { if items[0].Type != TypeEvent {
t.Errorf("type = %q", items[0].Type) t.Errorf("type = %q", items[0].Type)
} }
if got := items[0].DueAt.Format("2006-01-02 15:04"); got != "2026-10-15 18:00" { 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" { if items[0].Title != "End of Year Concert - HALL - KSH" {
t.Errorf("event 1 title = %q", items[0].Title) t.Errorf("event 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)
} }
} }
@@ -430,3 +427,87 @@ func TestParseReconcilesOverdueWithHome(t *testing.T) {
t.Fatal("Parse accepted a home page with no record of the child (FR-12)") 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 = `<html><body>
<div class="x-panel compass-day-calendar x-cal-panel" id="calendardaywidget-1055">
<div class="x-toolbar">Today</div>
<div class="calendardaywidget-1055-day-bd-evt-a x-cal-default calendar-event-93806 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:95.8px;height:48.1px;">
<span class="ext-evt-bd" lang="en-GB">8:55: 1 - 7SCF - H14LAB - KNI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-b x-cal-default calendar-event-93737 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:50%;width:50%;top:145px;height:44px;">
<span class="ext-evt-bd" lang="en-GB">9:54: 2 - 7DESF - B1 - PSI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-c x-cal-default calendar-event-93755 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:321.6666666666667px;height:48px;">
<span class="ext-evt-bd" lang="en-GB">1:26: 4 - 7PEF - GYM4 - LTA</span>
</div>
</div>
</body></html>`
// 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)
}
}

54
internal/period/period.go Normal file
View File

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

View File

@@ -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 IS NOT NULL
AND i.due_at >= ?` AND i.due_at >= ?`
args := []any{from.UTC().Format(time.RFC3339)} 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 { if childID != nil {
q += ` AND i.child_id = ?` q += ` AND i.child_id = ?`
args = append(args, *childID) args = append(args, *childID)
@@ -55,12 +101,19 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
q += ` AND i.id = ?` q += ` AND i.id = ?`
args = append(args, *itemID) 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 { if limit > 0 {
q += ` LIMIT ?` q += ` LIMIT ?`
args = append(args, 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...) rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil { if err != nil {
return nil, fmt.Errorf("calendar candidates: %w", err) 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 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 // MarkItemCalendarSynced records the Home Assistant event id and the push time
// on one item. After this the item is no longer a calendar candidate. // 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 { func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error {

View File

@@ -113,3 +113,36 @@ func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
t.Fatalf("last = %+v", summary.Last) 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")
}
}

263
internal/store/codes.go Normal file
View File

@@ -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 <code>"; 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
}

View File

@@ -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);

View File

@@ -375,6 +375,40 @@ func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID in
return res.RowsAffected() 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. // TypeCount is the number of items of one type for one child.
type TypeCount struct { type TypeCount struct {
Child string Child string

View File

@@ -312,7 +312,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
} }
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID)) 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)) t.Errorf("brief not shown on dashboard: %s", snippet(page))
} }
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete", resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",

View File

@@ -13,7 +13,7 @@
# scripts/calendar-push.sh events [--days 14] # scripts/calendar-push.sh events [--days 14]
# #
# Environment overrides: # 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) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# ENV_FILE HA config source (default environment.d/school-service.env) # ENV_FILE HA config source (default environment.d/school-service.env)
# SCHOOL_TIMEZONE family timezone (default Australia/Sydney) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney)
@@ -25,7 +25,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}"

View File

@@ -3,9 +3,11 @@
# #
# This is the bounded command the 6:00am n8n schedule runs: # This is the bounded command the 6:00am n8n schedule runs:
# 1. scan Compass for both children (read-only; skipped with --no-scan), # 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, # 2. write the day's periods, due work, overdue work and school events to
# 3. deliver them through the house Apprise API on `.35:8210`, # Sam's Home Assistant School calendar (idempotent; skipped with --no-calendar),
# 4. record every delivery in `notifications` and every exit code loudly. # 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 # 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 # 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. # is only exported into the container environment.
# #
# Usage: # Usage:
# scripts/morning-brief.sh # scan, then send # scripts/morning-brief.sh # scan, calendar, then send
# scripts/morning-brief.sh --no-scan # send from the latest scan # 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 --dry-run # build and print, send nothing
# scripts/morning-brief.sh --channel email # limit the channels # scripts/morning-brief.sh --channel email # limit the channels
# #
# Environment overrides: # 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) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# ENV_FILE service env file (default environment.d/school-service.env) # ENV_FILE service env file (default environment.d/school-service.env)
# SCHOOL_TIMEZONE family timezone (default Australia/Sydney) # SCHOOL_TIMEZONE family timezone (default Australia/Sydney)
@@ -31,18 +34,20 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}" ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}"
DOCKER_TIMEOUT="${DOCKER_TIMEOUT:-600}" DOCKER_TIMEOUT="${DOCKER_TIMEOUT:-600}"
NO_SCAN=0 NO_SCAN=0
NO_CALENDAR=0
NOTIFY_ARGS=() NOTIFY_ARGS=()
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--no-scan) NO_SCAN=1; shift ;; --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 ;; --channel) NOTIFY_ARGS+=(--channel "$2"); shift 2 ;;
--only) NOTIFY_ARGS+=(--only "$2"); shift 2 ;; --only) NOTIFY_ARGS+=(--only "$2"); shift 2 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;; *) echo "unknown argument: $1" >&2; exit 2 ;;
@@ -55,7 +60,8 @@ if [[ -f "$ENV_FILE" ]]; then
while IFS= read -r line; do while IFS= read -r line; do
case "$line" in case "$line" in
SCHOOL_APPRISE_URL=*|SCHOOL_APPRISE_KEY=*|SCHOOL_APPRISE_TOKEN=*|SCHOOL_APPRISE_TIMEOUT=*|\ 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%%=*}" key="${line%%=*}"
val="${line#*=}" val="${line#*=}"
if [[ -z "${!key:-}" ]]; then if [[ -z "${!key:-}" ]]; then
@@ -83,6 +89,11 @@ run_school() {
-e SCHOOL_APPRISE_CHANNELS="${SCHOOL_APPRISE_CHANNELS:-}" \ -e SCHOOL_APPRISE_CHANNELS="${SCHOOL_APPRISE_CHANNELS:-}" \
-e SCHOOL_APPRISE_TAGS="${SCHOOL_APPRISE_TAGS:-}" \ -e SCHOOL_APPRISE_TAGS="${SCHOOL_APPRISE_TAGS:-}" \
-e SCHOOL_APPRISE_PARENTS="${SCHOOL_APPRISE_PARENTS:-}" \ -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" "$@" "$IMAGE" "$@"
} }
@@ -97,15 +108,31 @@ if [[ "$NO_SCAN" -eq 0 ]]; then
fi fi
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 ==" echo "== send the morning briefs =="
notify_rc=0 notify_rc=0
if ! run_school notify run "${NOTIFY_ARGS[@]+"${NOTIFY_ARGS[@]}"}"; then if ! run_school notify run "${NOTIFY_ARGS[@]+"${NOTIFY_ARGS[@]}"}"; then
notify_rc=$? notify_rc=$?
fi fi
if [[ "$scan_rc" -ne 0 || "$notify_rc" -ne 0 ]]; then if [[ "$scan_rc" -ne 0 || "$calendar_rc" -ne 0 || "$notify_rc" -ne 0 ]]; then
echo "morning brief FAILED: scan_rc=$scan_rc notify_rc=$notify_rc" >&2 echo "morning brief FAILED: scan_rc=$scan_rc calendar_rc=$calendar_rc notify_rc=$notify_rc" >&2
exit 1 exit 1
fi fi
echo "morning brief complete: both boys + the combined brief to Jo and Sam" echo "morning brief complete: both boys + the combined brief to Jo and Sam"

View File

@@ -9,7 +9,7 @@
# scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto] # scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto]
# #
# Environment overrides: # 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) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -22,7 +22,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"

View File

@@ -21,7 +21,7 @@
# scripts/scan-compass.sh --child Harry --child Finn --classify none # scripts/scan-compass.sh --child Harry --child Finn --classify none
# #
# Environment overrides: # 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) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -33,7 +33,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" 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}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"