Phase 6: write important school events to the Home Assistant calendar

Add the Home Assistant REST calendar client and the idempotent sync that
writes important school events to Sam's Home Assistant calendar (FR-8). The
school stays read-only; the only write is to the calendar (rule 7).

- internal/calendar: HA REST client (states, create_event, get_events,
  delete_event) and the sync that selects important future events, adopts an
  existing event by its description marker, records the HA event id on the
  item, and records every attempt.
- internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and
  the append-only calendar_pushes table), candidates, mark-synced and the
  push log.
- internal/parse: a school event is important; a routine class period is not.
- cmd/school: `school calendar entities|status|push|events`.
- web: an "Add to calendar" item action and a fail-loud calendar banner, so a
  failed write is never swallowed (FR-12).
- scripts/calendar-push.sh, Makefile target, env example, docs and tests.

The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no
non-Google writable calendar today, so the entity is asked on AUD-110.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 18:01:30 +11:00
parent 37d82b1a0a
commit f6be17fe4f
26 changed files with 2729 additions and 43 deletions

View File

@@ -4,8 +4,30 @@ A school service for Harry and Finn: **Compass** for the daily schedule and
notices, **Teams** for homework and assignments. It stores what is due per child,
puts overdue work first, and tells the family before work is late.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 5** (the
> per-child dashboard). See §11 of the spec for the phase order.
> Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 6** (the
> calendar write). See §11 of the spec for the phase order.
## Status — Phase 6
Phase 6 writes the **important** school events to **Sam's Home Assistant
calendar** through the Home Assistant REST API (FR-8). It is idempotent (a
re-run never duplicates an event) and it fails loudly (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).
**Open decision:** the brief says *not Google*, and Home Assistant has no
non-Google writable calendar yet. The target entity is configurable
(`SCHOOL_HA_CALENDAR_ENTITY`) and the question is on
[AUD-110](/AUD/issues/AUD-110).
```bash
scripts/calendar-push.sh entities # list the writable calendar entities
scripts/calendar-push.sh status # target, reachability, pending, last push
scripts/calendar-push.sh push --dry-run
scripts/calendar-push.sh push # write the pending important events
scripts/calendar-push.sh events # read the calendar back
make calendar CALENDAR_ARGS='push --dry-run'
```
## Status — Phase 5
@@ -55,9 +77,9 @@ Built and deployed on `.13`:
- Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`.
Not built yet (later phases): the calendar write, notifications and the Hub API.
The service itself does **not** scan the school; n8n triggers the scan later.
Teams (FR-15) is a separate source.
Not built yet (later phases): notifications and the Hub API. The calendar write
is Phase 6. The service itself does **not** scan the school; n8n triggers the
scan later. Teams (FR-15) is a separate source.
### Fail loudly (FR-12)
@@ -76,10 +98,11 @@ internal/config/ environment configuration
internal/store/ SQLite: schema, migrations, queries
internal/parse/ HTML -> structured items (tasks, messages, events)
internal/llm/ OmniRoute client (importance classification only)
internal/calendar/ Home Assistant calendar client + the idempotent sync
internal/auth/ session login (signed cookies, CSRF)
internal/web/ HTTP handlers, templates, static assets
deploy/ Caddy snippet, deployment notes, verify script
scripts/ Compass fetchers and the parse wrapper
scripts/ Compass fetchers, the parse wrapper and the calendar push
environment.d/ env example (the real file is gitignored)
```
@@ -94,8 +117,8 @@ docker compose up -d --build
Generate secrets with the built image:
```bash
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.3.0-phase5 hash-password 'the-password'
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 gen-secret
docker run --rm --entrypoint /app/school compass-teams-nhs/school:0.4.0-phase6 hash-password 'the-password'
```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash