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:
40
AGENTS.md
40
AGENTS.md
@@ -84,13 +84,16 @@ Items marked **not verified** need a decision or a test.
|
|||||||
| FR-6 | Detect overdue work and work due today and this week. |
|
| FR-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
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -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
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -26,8 +26,11 @@ Phase 9 publishes the project documentation across five places.
|
|||||||
|
|
||||||
The daily trigger is **06:00** in `Australia/Melbourne` (n8n). The briefs go out
|
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
|
||||||
|
|||||||
@@ -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
160
cmd/school/codes.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
243
docs/phase-6b7b-notes.md
Normal 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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
84
internal/decode/decode.go
Normal 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
|
||||||
|
}
|
||||||
52
internal/decode/decode_test.go
Normal file
52
internal/decode/decode_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
114
internal/parse/daywidget.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
54
internal/period/period.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
263
internal/store/codes.go
Normal 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
|
||||||
|
}
|
||||||
19
internal/store/migrations/005_codes.sql
Normal file
19
internal/store/migrations/005_codes.sql
Normal 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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
Reference in New Issue
Block a user