The first live run against the new calendar.school entity failed loudly twice, and both causes are fixed here. - The Local Calendar rejects `return_response` on calendar.create_event. The client retries the call without it and ignores the changed-states array that Home Assistant returns instead. - Home Assistant then reports no event id. The item keeps the description marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set and is never written twice. The live retry proved it: both events already in the calendar were adopted by their markers, with no duplicate. - calendar.get_events reports `start` / `end` (a date-time or a bare date), not `start_date_time`; the reader now accepts all three shapes. A model must no longer classify events. The calendar write depends on the school-event rule, and the LLM labelled every event "normal"; events now keep the deterministic rule under both classifiers. Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan` class, so select each banner by its labelled section. Docs record the accepted decision: Sam chose a Home Assistant Local Calendar, so the target is calendar.school. Co-Authored-By: Paperclip <noreply@paperclip.ing>
164 lines
7.6 KiB
Markdown
164 lines
7.6 KiB
Markdown
# Phase 6 — the calendar write (Sam's Home Assistant calendar)
|
||
|
||
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.
|