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:
130
docs/phase-6-calendar-notes.md
Normal file
130
docs/phase-6-calendar-notes.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Phase 6 — the calendar write (Sam's Home Assistant calendar)
|
||||
|
||||
Status: **code complete and tested; the calendar target needs Sam's decision**
|
||||
(AUD-110). The service now writes important school events through the Home
|
||||
Assistant REST API. Nothing is written until a calendar entity is chosen.
|
||||
|
||||
## 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 **future** events are written: a past lesson is history, not a plan.
|
||||
- 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.
|
||||
|
||||
## The open decision: which calendar entity?
|
||||
|
||||
The Home Assistant instance (`192.168.20.30`, HA 2026.9.4) has three calendar
|
||||
entities, and only two are writable:
|
||||
|
||||
| Entity | Writable | Backing |
|
||||
|---|---|---|
|
||||
| `calendar.samuelrolfe_googlemail_com` | yes | Google, synced into Home Assistant |
|
||||
| `calendar.birthdays` | yes | Google |
|
||||
| `calendar.holidays_in_australia` | no | Google |
|
||||
|
||||
The brief says **Sam's Home Assistant calendar (not Google)**. Today Home
|
||||
Assistant has **no non-Google writable calendar**: the `local_calendar`
|
||||
integration is not installed, and no CalDAV/remote calendar is configured.
|
||||
|
||||
So the target must be chosen before the event is written:
|
||||
|
||||
1. **Add a Local Calendar to Home Assistant** (recommended). One Home Assistant
|
||||
config-flow entry creates `calendar.school`. It is Home-Assistant-local, never
|
||||
touches Google, and is entirely additive. The service then uses
|
||||
`SCHOOL_HA_CALENDAR_ENTITY=calendar.school`.
|
||||
2. **Use `calendar.samuelrolfe_googlemail_com`.** No Home Assistant change, but
|
||||
every school event also appears in Sam's personal Google Calendar.
|
||||
|
||||
The question is posted on AUD-110. The code needs only the configuration line
|
||||
changed once the answer is in.
|
||||
|
||||
## 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)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make test # go test ./...
|
||||
```
|
||||
|
||||
The tests cover the Home Assistant client against a fake Home Assistant (create,
|
||||
read-back, error mapping), the sync run (write once, adopt by marker, dry run,
|
||||
loud failure, refuse-to-write-blind), the store (candidates, the push log) and
|
||||
the web surface (the action, the CSRF guard, the failure banner).
|
||||
|
||||
Not tested here: the live Home Assistant write. That needs the entity decision.
|
||||
Reference in New Issue
Block a user