- 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>
7.9 KiB
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.gomarks an eventhighwhen it is a school event, andnormalwhen it is a routine class period. A Compass week grid writes a class as5 - 11HLEGB5 - D8 - LHTand an event asEnd 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:
- The item stores the Home Assistant event id (
items.ha_event_uid). An item that already has one is not a candidate again. - 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 = 0plus the error). - The dashboard shows a Calendar banner:
CALENDAR NOT CONFIGURED,CALENDAR WRITE FAILEDwith the Home Assistant reason, or the healthy state. - The item screen shows the failure in place of a confirmation.
scripts/calendar-push.sh pushexits 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_eventis called with the service response to learn the event id; the Local Calendar answersService 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.