Files
compass_teams_nhs/docs/phase-6-calendar-notes.md
Sam Rolfe 047af0f31d 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>
2026-10-16 10:25:55 +11:00

7.9 KiB
Raw Permalink Blame History

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. The Phase 6 mechanism below (Local Calendar, marker idempotency, fail loud) is unchanged.

Status: done (AUD-110). Sam chose a Home Assistant Local Calendar, and the service writes important school events to it through the Home Assistant REST API.

Target entity calendar.school (Local Calendar "School", added 2026-10-15)
Configuration environment.d/school-service.env (gitignored): SCHOOL_HA_URL, SCHOOL_HA_TOKEN, SCHOOL_HA_CALENDAR_ENTITY, SCHOOL_HA_TIMEOUT
First live events Senior Sub-School Community Service for Uniform Infringement and End of Year Senior Music Concert — HALL (2026-10-15)

What it does

school calendar push selects the important school events that are not in the calendar yet and writes them with the Home Assistant service calendar.create_event. The school is never touched (AGENTS.md §10 rule 1); the only write is to the calendar (rule 7 — no approval needed).

  • Important is the Phase 3 classifier: internal/parse/importance.go marks an event high when it is a school event, and normal when it is a routine class period. A Compass week grid writes a class as 5 - 11HLEGB5 - D8 - LHT and an event as End of Year Senior Music Concert - HALL (Senior Music Concert) - KSH, CFR. The first part of a lesson is a period label (5, C, RLE GROUP D).
  • Only today and future events are written: a scan that runs late in the day still records today's school events, but yesterday is history.
  • The Compass week calendar is school-wide, so the same event is stored against each child. A school-wide event is written once, with no child prefix in its title, and every child's item records the same event id. A per-child event keeps the child's name in its title (Harry — …).
  • Which events qualify is visible before anything is written:
scripts/calendar-push.sh push --dry-run

Idempotency (a re-run never duplicates)

Two independent guards:

  1. The item stores the Home Assistant event id (items.ha_event_uid). An item that already has one is not a candidate again.
  2. The event description carries a marker, source: [compass-nhs:<item id>]. A run lists the calendar first and adopts any event that already carries the marker for that item, even when Home Assistant reports no event id.

items.ha_pushed_at records when the item was written.

Fail loudly (FR-12)

A failed calendar write is never swallowed.

  • Every attempt is appended to calendar_pushes (ok = 0 plus the error).
  • The dashboard shows a Calendar banner: CALENDAR NOT CONFIGURED, CALENDAR WRITE FAILED with the Home Assistant reason, or the healthy state.
  • The item screen shows the failure in place of a confirmation.
  • scripts/calendar-push.sh push exits non-zero when any write failed.

A failed write leaves the item as a candidate, so the next run retries it.

Configuration (names only — never the values)

Variable Meaning
SCHOOL_HA_URL Home Assistant base URL, for example http://192.168.20.30:8123
SCHOOL_HA_TOKEN long-lived access token (secret)
SCHOOL_HA_CALENDAR_ENTITY the calendar entity to write to, for example calendar.school
SCHOOL_HA_INSECURE true to skip TLS verification for the internal .lab certificate
SCHOOL_HA_TIMEOUT per-request timeout (default 20s)

The values live in environment.d/school-service.env, which is gitignored (AGENTS.md §10 rule 3). The token is never logged, never printed by the CLI and never stored in SQLite.

Commands

scripts/calendar-push.sh entities     # list calendar.* entities and whether they are writable
scripts/calendar-push.sh status       # target, reachability, pending events, last push
scripts/calendar-push.sh push         # write the pending important events
scripts/calendar-push.sh events       # read the calendar back (evidence)
scripts/calendar-push.sh push --dry-run --json

make calendar CALENDAR_ARGS='push --dry-run' runs the same thing.

The item screen has an Add to calendar button for an important future event, and shows in the calendar with the Home Assistant event id once it is written.

Decision (accepted 2026-10-15)

The Home Assistant instance (192.168.20.30, HA 2026.9.4) had three calendar entities, and all the writable ones were Google-backed (calendar.samuelrolfe_googlemail_com, calendar.birthdays); the brief says Sam's Home Assistant calendar (not Google), and no non-Google calendar existed. Sam chose to add a Local Calendar, so:

  • The Local Calendar integration was added through the Home Assistant config flow, creating the entity calendar.school (title "School"). It is Home-Assistant-local, never touches Google, and the change is purely additive.
  • SCHOOL_HA_CALENDAR_ENTITY=calendar.school.

The calendar is a family calendar, so an event carries the child's name only when it belongs to one child.

Home Assistant quirks found on the first live run

Both are handled, and both were caught by the fail-loudly design rather than silently writing nothing:

  • The Local Calendar rejects return_response. calendar.create_event is called with the service response to learn the event id; the Local Calendar answers Service does not support responses. The client retries the call without it. Home Assistant then replies with the changed states, a JSON array, which the client ignores.
  • The Local Calendar reports no event id. With no id to store, the item keeps the description marker ([compass-nhs:<item id>]) as its reference, so the item leaves the candidate set and is never written twice. The live retry proved it: after a failed first attempt the two events already in the calendar were adopted by their markers, and nothing was duplicated.

Where the code lives

internal/calendar/ha.go      Home Assistant REST client (states, create, get, delete)
internal/calendar/sync.go    selection, idempotency, the push log
internal/store/calendar.go   candidates, mark-synced, the append-only push log
internal/store/migrations/003_calendar.sql
cmd/school/calendar.go       `school calendar entities|status|push|events`
internal/web/server.go       the item action + the fail-loud calendar banner
scripts/calendar-push.sh     one-off container runner (no service restart)

Found while building (worth a decision later)

The Compass week-calendar page looks school-wide, not child-filtered: Finn's page carries Year 11 Assembly and Tour to China photo exactly as Harry's does, while their lesson grids differ. Phase 6 handles that honestly — a shared event is written once and both children's items record the same event id — but the per-child attribution of a school-wide event is a Phase 3/4 data question, not a calendar one. It matters for the Phase 7 per-boy brief.

The week grid also stops at Thursday in the stored pages (Monday–Thursday have lessons, Friday–Sunday do not), so a Friday scan may be the first to carry future school events.

Verification

make test    # go test ./...

The tests cover the Home Assistant client against a fake Home Assistant (create, read-back, error mapping, the no-response fallback, the start/end shapes), the sync run (write once, adopt by marker, dry run, loud failure, refuse-to-write-blind, no-event-id idempotency), the store (candidates, the push log), the parser (a model cannot label an event) and the web surface (the action, the CSRF guard, the failure banner).

Live proof on .13 (2026-10-15): two Compass school events were written to calendar.school, read back through the Home Assistant API, and the dashboard and item screen show the calendar state.