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

170 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 6 — the calendar write (Sam's Home Assistant calendar)
> **Updated by Phase 6b (AUD-119):** the calendar now holds each child's
> **periods**, **work due**, **work overdue** and the **major school events**,
> per child, on every pass. See
> [`phase-6b7b-notes.md`](phase-6b7b-notes.md). The Phase 6 mechanism below
> (Local Calendar, marker idempotency, fail loud) is unchanged.
Status: **done** (AUD-110). Sam chose a **Home Assistant Local Calendar**, and
the service writes important school events to it through the Home Assistant REST
API.
| | |
|---|---|
| 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:
```bash
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
```bash
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
```bash
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.