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

@@ -1,5 +1,5 @@
IMAGE ?= compass-teams-nhs/school:0.3.0-phase5 IMAGE ?= compass-teams-nhs/school:0.4.0-phase6
BUILD_VERSION ?= 0.3.0-phase5 BUILD_VERSION ?= 0.4.0-phase6
# Parse options (see scripts/parse-compass.sh). # Parse options (see scripts/parse-compass.sh).
CHILD ?= Harry CHILD ?= Harry
@@ -16,7 +16,7 @@ GO_RUN = timeout 600 docker run --rm --network host --user "$$(id -u):$$(id -g)"
-e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \ -e HOME=/src/.gocache -e GOMODCACHE=/src/.gocache/mod -e GOCACHE=/src/.gocache/build \
-v "$$PWD":/src -w /src $(GO_IMAGE) -v "$$PWD":/src -w /src $(GO_IMAGE)
.PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan .PHONY: help test vet build up down restart logs ps deps fmt hash-password gen-secret verify-phase1 parse scan calendar
help: help:
@echo "make test run go test ./... in golang:1.25-alpine" @echo "make test run go test ./... in golang:1.25-alpine"
@@ -31,6 +31,7 @@ help:
@echo "make gen-secret print a fresh session secret" @echo "make gen-secret print a fresh session secret"
@echo "make parse parse stored Compass pages into SQLite (Phase 3)" @echo "make parse parse stored Compass pages into SQLite (Phase 3)"
@echo "make scan fetch + parse one or both children (Phase 4; SCAN_CHILD=Harry|Finn|all)" @echo "make scan fetch + parse one or both children (Phase 4; SCAN_CHILD=Harry|Finn|all)"
@echo "make calendar push important events to the HA calendar (Phase 6; CALENDAR_ARGS=...)"
@echo "make verify-phase1 prove /health, the login and the Caddy route" @echo "make verify-phase1 prove /health, the login and the Caddy route"
test: test:
@@ -78,6 +79,10 @@ parse:
scan: scan:
CLASSIFY=$(CLASSIFY) IMAGE=$(IMAGE) scripts/scan-compass.sh $(if $(filter-out all,$(SCAN_CHILD)),--child $(SCAN_CHILD)) CLASSIFY=$(CLASSIFY) IMAGE=$(IMAGE) scripts/scan-compass.sh $(if $(filter-out all,$(SCAN_CHILD)),--child $(SCAN_CHILD))
# Phase 6: Home Assistant calendar. CALENDAR_ARGS defaults to 'status'.
calendar:
IMAGE=$(IMAGE) scripts/calendar-push.sh $(if $(CALENDAR_ARGS),$(CALENDAR_ARGS),status)
# Phase 1 definition of done: /health 200, login page + session login, and the # Phase 1 definition of done: /health 200, login page + session login, and the
# Caddy route on .35. # Caddy route on .35.
verify-phase1: verify-phase1:

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, 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. 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 > Build spec: [`AGENTS.md`](AGENTS.md). This repository is at **Phase 6** (the
> per-child dashboard). See §11 of the spec for the phase order. > 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 ## Status — Phase 5
@@ -55,9 +77,9 @@ Built and deployed on `.13`:
- Its own container and port **8792**, behind Caddy at - Its own container and port **8792**, behind Caddy at
`school.lab.audasmedia.com.au`. `school.lab.audasmedia.com.au`.
Not built yet (later phases): the calendar write, notifications and the Hub API. Not built yet (later phases): notifications and the Hub API. The calendar write
The service itself does **not** scan the school; n8n triggers the scan later. is Phase 6. The service itself does **not** scan the school; n8n triggers the
Teams (FR-15) is a separate source. scan later. Teams (FR-15) is a separate source.
### Fail loudly (FR-12) ### Fail loudly (FR-12)
@@ -76,10 +98,11 @@ internal/config/ environment configuration
internal/store/ SQLite: schema, migrations, queries internal/store/ SQLite: schema, migrations, queries
internal/parse/ HTML -> structured items (tasks, messages, events) internal/parse/ HTML -> structured items (tasks, messages, events)
internal/llm/ OmniRoute client (importance classification only) internal/llm/ OmniRoute client (importance classification only)
internal/calendar/ Home Assistant calendar client + the idempotent sync
internal/auth/ session login (signed cookies, CSRF) internal/auth/ session login (signed cookies, CSRF)
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
deploy/ Caddy snippet, deployment notes, verify script 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) environment.d/ env example (the real file is gitignored)
``` ```
@@ -94,8 +117,8 @@ docker compose up -d --build
Generate secrets with the built image: Generate secrets with the built image:
```bash ```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.4.0-phase6 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 hash-password 'the-password'
``` ```
Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash Note: Docker Compose interpolates `$VAR` inside `env_file`. A bcrypt hash

364
cmd/school/calendar.go Normal file
View File

@@ -0,0 +1,364 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// calendarCommand is the `school calendar` entry point (AGENTS.md §11 Phase 6).
// It writes important school events to Sam's Home Assistant calendar and reads
// the calendar back. The school stays read-only.
func calendarCommand(args []string) {
if len(args) == 0 {
usage("school calendar entities | status | push [--child NAME] [--item N] [--limit N] [--dry-run] [--json] | events [--days N]")
}
switch args[0] {
case "entities":
calendarEntities(args[1:])
case "status":
calendarStatus(args[1:])
case "push":
calendarPush(args[1:])
case "events":
calendarEvents(args[1:])
default:
usage("school calendar entities | status | push | events")
}
}
// openCalendar builds the store and the Home Assistant client. The token is
// never printed: the caller only ever sees the host and the entity id.
func openCalendar(ctx context.Context) (*store.Store, *config.DBConfig, *calendar.Client, error) {
cfg, err := config.LoadDB()
if err != nil {
return nil, nil, nil, err
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return nil, nil, nil, err
}
if err := st.Migrate(ctx); err != nil {
_ = st.Close()
return nil, nil, nil, err
}
client, err := calendar.New(cfg.Calendar, nil)
if err != nil {
_ = st.Close()
return nil, nil, nil, err
}
return st, cfg, client, nil
}
// calendarEntities lists the calendar entities Home Assistant reports, so the
// operator can choose SCHOOL_HA_CALENDAR_ENTITY from real values.
func calendarEntities(args []string) {
fs := flag.NewFlagSet("calendar entities", flag.ContinueOnError)
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
if err := client.Healthy(ctx); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
entities, err := client.Calendars(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL))
fmt.Printf("configured %s\n", cfg.Calendar.EntityID)
for _, e := range entities {
writable := "read-only"
if e.CanWrite() {
writable = "writable"
}
marker := " "
if e.EntityID == cfg.Calendar.EntityID {
marker = "*"
}
fmt.Printf("%s %-45s %-10s %s\n", marker, e.EntityID, writable, e.Name)
}
}
// calendarStatus shows the configured target (never the token) and the last
// recorded push, so a failure is visible outside the dashboard too (FR-12).
func calendarStatus(args []string) {
fs := flag.NewFlagSet("calendar status", flag.ContinueOnError)
var asJSON bool
fs.BoolVar(&asJSON, "json", false, "print the status as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
// A missing target is itself the status: report it, do not crash.
fmt.Fprintf(os.Stderr, "school calendar: %v\n", err)
os.Exit(2)
}
defer st.Close()
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
now := time.Now().In(cfg.Location)
candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if asJSON {
out := struct {
BaseURL string `json:"base_url"`
Entity string `json:"entity"`
TokenSet bool `json:"token_set"`
Pending int `json:"pending_events"`
ReloadedAt string `json:"checked_at"`
HomeAssist string `json:"ha_status"`
LastPush *store.CalendarPush `json:"last_push,omitempty"`
PushSummary store.CalendarPushStatus `json:"pushes"`
}{
BaseURL: haHost(cfg.Calendar.BaseURL),
Entity: cfg.Calendar.EntityID,
TokenSet: cfg.Calendar.Token != "",
Pending: len(candidates),
ReloadedAt: now.Format(time.RFC3339),
PushSummary: summary,
}
if err := client.Healthy(ctx); err != nil {
out.HomeAssist = "unreachable: " + err.Error()
} else {
out.HomeAssist = "ok"
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
return
}
fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL))
fmt.Printf("entity %s\n", cfg.Calendar.EntityID)
fmt.Printf("token set\n")
if err := client.Healthy(ctx); err != nil {
fmt.Printf("reachable no (%v)\n", err)
} else {
fmt.Printf("reachable yes\n")
}
fmt.Printf("pending events %d (important, not in the calendar yet)\n", len(candidates))
fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed)
if summary.Last != nil {
state := "ok"
if !summary.Last.OK {
state = "FAILED: " + summary.Last.Error
}
fmt.Printf("last push item %d %s %s\n", summary.Last.ItemID, summary.Last.CreatedAt.In(cfg.Location).Format("2006-01-02 15:04"), state)
} else {
fmt.Printf("last push never run\n")
}
}
// calendarPush writes the pending important events and records every attempt.
func calendarPush(args []string) {
fs := flag.NewFlagSet("calendar push", flag.ContinueOnError)
var (
child string
itemID int64
limit int
dryRun bool
asJSON bool
)
fs.StringVar(&child, "child", "", "limit to one child (Harry or Finn)")
fs.Int64Var(&itemID, "item", 0, "push exactly one stored item id")
fs.IntVar(&limit, "limit", 0, "cap how many events are written")
fs.BoolVar(&dryRun, "dry-run", false, "select and report without writing")
fs.BoolVar(&asJSON, "json", false, "print the result as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
opts := calendar.Options{Limit: limit, DryRun: dryRun, Now: time.Now().In(cfg.Location)}
if child != "" {
c, err := st.ChildByName(ctx, child)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if c == nil {
fmt.Fprintf(os.Stderr, "school calendar: unknown child %q\n", child)
os.Exit(2)
}
opts.ChildID = &c.ID
}
if itemID > 0 {
opts.ItemID = &itemID
}
res, err := calendar.Sync(ctx, st, client, opts, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
} else {
printCalendarResult(res, cfg.Location)
}
if res.Failed > 0 {
os.Exit(1)
}
}
func printCalendarResult(res calendar.Result, loc *time.Location) {
verb := "created"
if res.DryRun {
verb = "would create"
}
fmt.Printf("entity %s\n", res.Entity)
fmt.Printf("candidates %d\n", res.Candidates)
fmt.Printf("%-11s %d\n", verb, res.Created)
fmt.Printf("skipped %d (already in the calendar)\n", res.Skipped)
fmt.Printf("failed %d\n", res.Failed)
for _, ev := range res.Events {
start := ev.Start
if t, err := time.Parse(time.RFC3339, ev.Start); err == nil {
start = t.In(loc).Format("Mon 02 Jan 15:04")
}
uid := ev.UID
if uid == "" {
uid = "-"
}
fmt.Printf(" item %-5d %-6s %-10s %-45s uid=%s\n", ev.ItemID, ev.Child, start, truncate(ev.Title, 45), uid)
}
for _, msg := range res.Errors {
fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg)
}
}
// calendarEvents reads the calendar back, which is the evidence that an event
// really is in Sam's Home Assistant calendar.
func calendarEvents(args []string) {
fs := flag.NewFlagSet("calendar events", flag.ContinueOnError)
var (
days int
asJSON bool
)
fs.IntVar(&days, "days", 14, "how many days ahead to read")
fs.BoolVar(&asJSON, "json", false, "print the events as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
now := time.Now().In(cfg.Location)
end := now.AddDate(0, 0, days)
events, err := client.Events(ctx, now.Add(-24*time.Hour), end)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
sort.Slice(events, func(i, j int) bool {
return eventStart(events[i]).Before(eventStart(events[j]))
})
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(events)
return
}
if len(events) == 0 {
fmt.Printf("no events on %s between %s and %s\n",
cfg.Calendar.EntityID,
now.Format("2006-01-02 15:04"),
end.Format("2006-01-02 15:04"))
return
}
for _, ev := range events {
start := eventStart(ev)
fmt.Printf("%s %-45s %s\n",
start.In(cfg.Location).Format("Mon 02 Jan 15:04"),
truncate(ev.Summary, 45),
shortMarker(ev.Description))
}
}
func eventStart(ev calendar.Event) time.Time {
if ev.Start != nil {
return *ev.Start
}
return time.Time{}
}
// shortMarker shows only the compass-nhs marker of a description, so a read-back
// stays a one-line list.
func shortMarker(desc string) string {
const open = "[compass-nhs:"
i := strings.Index(desc, open)
if i < 0 {
return ""
}
j := strings.Index(desc[i:], "]")
if j < 0 {
return ""
}
return desc[i : i+j+1]
}
// haHost prints the Home Assistant host only. The token and any query string
// stay out of the output (AGENTS.md §10 rule 3).
func haHost(base string) string {
u, err := url.Parse(base)
if err != nil || u.Host == "" {
return base
}
return u.Scheme + "://" + u.Host
}

View File

@@ -56,6 +56,9 @@ func main() {
case "brief": case "brief":
briefCommand(os.Args[2:]) briefCommand(os.Args[2:])
return return
case "calendar":
calendarCommand(os.Args[2:])
return
case "version": case "version":
fmt.Println(version) fmt.Println(version)
return return

View File

@@ -10,8 +10,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.3.0-phase5" VERSION: "0.4.0-phase6"
image: compass-teams-nhs/school:0.3.0-phase5 image: compass-teams-nhs/school:0.4.0-phase6
container_name: compass-teams-nhs container_name: compass-teams-nhs
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View 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.

View File

@@ -28,3 +28,20 @@ SCHOOL_TIMEZONE=Australia/Sydney
SCHOOL_LOG_LEVEL=info SCHOOL_LOG_LEVEL=info
SCHOOL_APP_ENV=production SCHOOL_APP_ENV=production
# ---- Home Assistant calendar (AGENTS.md §4 FR-8, Phase 6) -------------------
# Important school events are written to Sam's Home Assistant calendar through
# the Home Assistant REST API. Nothing is written until all three of URL, token
# and entity are set; the dashboard shows CALENDAR NOT CONFIGURED until then.
#
# The token is a long-lived access token created on Sam's Home Assistant
# profile page. It is a SECRET: keep it in this file only.
SCHOOL_HA_URL=http://192.168.20.30:8123
SCHOOL_HA_TOKEN=
# The calendar entity to write to. Discover the real ids with:
# scripts/calendar-push.sh entities
SCHOOL_HA_CALENDAR_ENTITY=calendar.school
# The internal .lab Caddy certificate is not in the container trust store.
# Set true only when SCHOOL_HA_URL is the https://homeassistant.home.lab URL.
SCHOOL_HA_INSECURE=false
SCHOOL_HA_TIMEOUT=20s

393
internal/calendar/ha.go Normal file
View File

@@ -0,0 +1,393 @@
// Package calendar writes important school events to Sam's Home Assistant
// calendar (AGENTS.md §4 FR-8, §8, §11 Phase 6). It talks to the Home Assistant
// REST API only; the school itself stays read-only. The token lives in the
// environment and is never logged.
package calendar
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
)
// ErrorID returns the Home Assistant error id from a failed call, plus the
// HTTP status. It exists so a caller can tell an expired token from a missing
// entity without parsing a message.
type Error struct {
// Status is the HTTP status code, or 0 for a transport error.
Status int
// ID is the Home Assistant error id such as "unauthorized" or "not_found".
ID string
// Message is the human-readable detail.
Message string
}
func (e *Error) Error() string {
switch {
case e.ID != "" && e.Message != "":
return fmt.Sprintf("home assistant %s: %s", e.ID, e.Message)
case e.ID != "":
return "home assistant: " + e.ID
case e.Message != "":
return "home assistant: " + e.Message
default:
return "home assistant call failed"
}
}
// Client is a Home Assistant REST client for one calendar entity.
type Client struct {
baseURL string
token string
entity string
http *http.Client
log *slog.Logger
}
// New builds a client from configuration. It fails loudly when the target is
// incomplete, so the caller never silently writes nowhere.
func New(cfg config.CalendarConfig, log *slog.Logger) (*Client, error) {
if log == nil {
log = slog.Default()
}
if missing := cfg.Missing(); len(missing) > 0 {
return nil, fmt.Errorf("home assistant calendar is not configured: set %s", strings.Join(missing, ", "))
}
if _, err := url.Parse(cfg.BaseURL); err != nil {
return nil, fmt.Errorf("SCHOOL_HA_URL %q: %w", cfg.BaseURL, err)
}
timeout := cfg.Timeout
if timeout <= 0 {
timeout = 20 * time.Second
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if cfg.Insecure {
// Internal CA only (the .lab Caddy cert). Never enabled by default.
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"),
token: cfg.Token,
entity: cfg.EntityID,
http: &http.Client{Timeout: timeout, Transport: transport},
log: log,
}, nil
}
// Entity is the configured calendar entity id.
func (c *Client) Entity() string { return c.entity }
// CalendarEntity is one calendar.* entity reported by Home Assistant.
type CalendarEntity struct {
EntityID string
Name string
State string
// Features is the raw supported_features bit field.
Features int
}
// CanWrite reports whether the entity advertises the create-event feature
// (Home Assistant CalendarEntityFeature.CREATE_EVENT = 1).
func (e CalendarEntity) CanWrite() bool { return e.Features&1 != 0 }
// Healthy verifies the base URL and the token with GET /api/.
func (c *Client) Healthy(ctx context.Context) error {
var out struct {
Message string `json:"message"`
}
if err := c.do(ctx, http.MethodGet, "/api/", nil, &out); err != nil {
return fmt.Errorf("home assistant ping: %w", err)
}
if !strings.Contains(strings.ToLower(out.Message), "running") {
return fmt.Errorf("home assistant ping: unexpected reply %q", out.Message)
}
return nil
}
// Calendars lists every calendar entity, so the operator can pick one. It is
// the discovery step behind `school calendar entities`.
func (c *Client) Calendars(ctx context.Context) ([]CalendarEntity, error) {
var states []struct {
EntityID string `json:"entity_id"`
State string `json:"state"`
Attributes struct {
FriendlyName string `json:"friendly_name"`
SupportedFeatures int `json:"supported_features"`
} `json:"attributes"`
}
if err := c.do(ctx, http.MethodGet, "/api/states", nil, &states); err != nil {
return nil, fmt.Errorf("home assistant states: %w", err)
}
var out []CalendarEntity
for _, s := range states {
if !strings.HasPrefix(s.EntityID, "calendar.") {
continue
}
name := s.Attributes.FriendlyName
if name == "" {
name = s.EntityID
}
out = append(out, CalendarEntity{
EntityID: s.EntityID,
Name: name,
State: s.State,
Features: s.Attributes.SupportedFeatures,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].EntityID < out[j].EntityID })
return out, nil
}
// Event is one calendar event. UID is Home Assistant's id, when it reports one.
type Event struct {
UID string `json:"uid,omitempty"`
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Location string `json:"location,omitempty"`
Start *time.Time `json:"-"`
End *time.Time `json:"-"`
StartRaw string `json:"-"`
EndRaw string `json:"-"`
}
// CreateEvent writes one event with calendar.create_event and returns the Home
// Assistant event id when the service reports one. It asks for the service
// response, so the id can be stored on the item and a re-run cannot duplicate
// the event.
func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
if strings.TrimSpace(ev.Summary) == "" {
return "", errors.New("calendar: event summary is empty")
}
if ev.Start == nil || ev.End == nil {
return "", errors.New("calendar: event start and end are required")
}
body := map[string]any{
"entity_id": c.entity,
"summary": ev.Summary,
"start_date_time": ev.Start.Format(time.RFC3339),
"end_date_time": ev.End.Format(time.RFC3339),
}
if ev.Description != "" {
body["description"] = ev.Description
}
if ev.Location != "" {
body["location"] = ev.Location
}
resp, err := c.callService(ctx, "calendar", "create_event", body)
if err != nil {
return "", err
}
return serviceUID(resp, c.entity), nil
}
// DeleteEvent removes an event by its Home Assistant uid. It exists so a test
// event can be cleaned up, and so a wrong write can be undone.
func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
if strings.TrimSpace(uid) == "" {
return errors.New("calendar: delete needs a uid")
}
_, err := c.callService(ctx, "calendar", "delete_event", map[string]any{
"entity_id": c.entity,
"uid": uid,
})
return err
}
// Events lists the events on the calendar between start and end (exclusive).
// The sync uses it to see whether a marker is already present, so a re-run is
// idempotent even when Home Assistant reports no uid.
func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, error) {
resp, err := c.callService(ctx, "calendar", "get_events", map[string]any{
"entity_id": c.entity,
"start_date_time": start.Format(time.RFC3339),
"end_date_time": end.Format(time.RFC3339),
})
if err != nil {
return nil, err
}
return serviceEvents(resp, c.entity), nil
}
// ---------- transport ----------
// callService posts to /api/services/<domain>/<service> with return_response,
// so a service that reports data (the created event id) answers with it.
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any) (map[string]any, error) {
path := fmt.Sprintf("/api/services/%s/%s?return_response", domain, service)
var out map[string]any
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil {
return nil, err
}
return out, nil
}
// do performs one authenticated request. A non-2xx answer becomes an *Error
// carrying the Home Assistant error id, so the caller can act (and report) with
// the real reason instead of a bare status code.
func (c *Client) do(ctx context.Context, method, path string, body any, out any) error {
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, reader)
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return &Error{Message: fmt.Sprintf("request to %s failed: %v", path, err)}
}
defer func() { _, _ = io.Copy(io.Discard, resp.Body); _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return &Error{Status: resp.StatusCode, Message: "reading the reply failed: " + err.Error()}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return haError(resp.StatusCode, raw)
}
if out == nil || len(bytes.TrimSpace(raw)) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return &Error{Status: resp.StatusCode, Message: fmt.Sprintf("unexpected reply body: %v", err)}
}
return nil
}
// haError turns a Home Assistant error body into an *Error.
func haError(status int, raw []byte) *Error {
var shape struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Message string `json:"message"`
}
_ = json.Unmarshal(raw, &shape)
msg := shape.Error.Message
if msg == "" {
msg = shape.Message
}
if msg == "" {
msg = strings.TrimSpace(string(raw))
}
return &Error{Status: status, ID: shape.Error.Code, Message: msg}
}
// serviceUID digs the created event id out of a service response. Home
// Assistant answers with {"service_response": {"calendar.x": {"uid": "..."}}};
// some integrations nest the event under "result" or "event", so all three are
// tried. An empty result is not an error: the description marker keeps the
// write idempotent.
func serviceUID(resp map[string]any, entity string) string {
for _, payload := range servicePayloads(resp, entity) {
if uid, ok := payload["uid"].(string); ok && uid != "" {
return uid
}
for _, key := range []string{"result", "event"} {
if nested, ok := payload[key].(map[string]any); ok {
if uid, ok := nested["uid"].(string); ok && uid != "" {
return uid
}
}
}
}
return ""
}
// serviceEvents reads the event list out of a calendar.get_events response.
func serviceEvents(resp map[string]any, entity string) []Event {
var out []Event
for _, payload := range servicePayloads(resp, entity) {
raw, ok := payload["events"].([]any)
if !ok {
continue
}
for _, item := range raw {
m, ok := item.(map[string]any)
if !ok {
continue
}
ev := Event{
UID: str(m["uid"]),
Summary: str(m["summary"]),
Description: str(m["description"]),
Location: str(m["location"]),
}
// Home Assistant reports a date or a date-time; try date-time first.
ev.Start, ev.StartRaw = parseHATime(m, "start")
ev.End, ev.EndRaw = parseHATime(m, "end")
out = append(out, ev)
}
}
return out
}
// servicePayloads returns the per-entity payload in a service response,
// tolerating both the wrapped ({"service_response": {...}}) and the bare
// shape. The entity key wins; other keys are only a fallback when Home
// Assistant renamed it.
func servicePayloads(resp map[string]any, entity string) []map[string]any {
if resp == nil {
return nil
}
root := resp
if sr, ok := resp["service_response"].(map[string]any); ok {
root = sr
}
if payload, ok := root[entity].(map[string]any); ok {
return []map[string]any{payload}
}
var out []map[string]any
for _, v := range root {
if payload, ok := v.(map[string]any); ok {
out = append(out, payload)
}
}
return out
}
func parseHATime(m map[string]any, prefix string) (*time.Time, string) {
if raw := str(m[prefix+"_date_time"]); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
return &t, raw
}
}
if raw := str(m[prefix+"_date"]); raw != "" {
if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil {
return &t, raw
}
return nil, raw
}
return nil, ""
}
func str(v any) string {
s, _ := v.(string)
return s
}

View File

@@ -0,0 +1,311 @@
package calendar
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
)
// fakeHA is a minimal Home Assistant REST API for the calendar tests. It
// records what the client sent, so a test can prove the exact service calls.
type fakeHA struct {
*httptest.Server
mu sync.Mutex
entity string
events []map[string]any
createCalls int
getCalls int
failCreate bool
returnUID bool
lastCreate map[string]any
lastGet map[string]any
}
func newFakeHA(t *testing.T, entity string) *fakeHA {
t.Helper()
f := &fakeHA{entity: entity, returnUID: true}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, map[string]any{"message": "API running."})
})
mux.HandleFunc("GET /api/states", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, []map[string]any{
{"entity_id": f.entity, "state": "off", "attributes": map[string]any{
"friendly_name": "School", "supported_features": 3,
}},
{"entity_id": "calendar.holidays", "state": "off", "attributes": map[string]any{
"friendly_name": "Holidays",
}},
{"entity_id": "sensor.temperature", "state": "21", "attributes": map[string]any{}},
})
})
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.createCalls++
f.lastCreate = body
if f.failCreate {
w.WriteHeader(http.StatusInternalServerError)
writeJSON(w, map[string]any{"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"}})
return
}
uid := ""
if f.returnUID {
uid = "uid-" + str(body["summary"])
}
f.events = append(f.events, map[string]any{
"uid": uid,
"summary": body["summary"],
"description": body["description"],
"start_date_time": body["start_date_time"],
"end_date_time": body["end_date_time"],
})
writeJSON(w, map[string]any{
"changed_entities": map[string]any{},
"service_response": map[string]any{f.entity: map[string]any{"uid": uid}},
})
})
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
f.mu.Lock()
defer f.mu.Unlock()
f.getCalls++
f.lastGet = body
evs := f.events
if evs == nil {
evs = []map[string]any{}
}
writeJSON(w, map[string]any{
"service_response": map[string]any{f.entity: map[string]any{"events": evs}},
})
})
mux.HandleFunc("POST /api/services/calendar/delete_event", func(w http.ResponseWriter, r *http.Request) {
if !f.authOK(w, r) {
return
}
writeJSON(w, map[string]any{"service_response": map[string]any{}})
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakeHA) authOK(w http.ResponseWriter, r *http.Request) bool {
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer test-token") {
w.WriteHeader(http.StatusUnauthorized)
writeJSON(w, map[string]any{"message": "Unauthorized"})
return false
}
return true
}
func (f *fakeHA) created() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.createCalls
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func testClient(t *testing.T, baseURL, entity string) *Client {
t.Helper()
c, err := New(config.CalendarConfig{
BaseURL: baseURL,
Token: "test-token",
EntityID: entity,
Timeout: 5 * time.Second,
}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
return c
}
func TestClientRejectsIncompleteConfig(t *testing.T) {
if _, err := New(config.CalendarConfig{}, nil); err == nil {
t.Fatal("New accepted an empty config")
} else if !strings.Contains(err.Error(), "SCHOOL_HA_URL") {
t.Errorf("error does not name the missing variable: %v", err)
}
}
func TestHealthyAndCalendars(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
if err := c.Healthy(context.Background()); err != nil {
t.Fatalf("Healthy: %v", err)
}
entities, err := c.Calendars(context.Background())
if err != nil {
t.Fatalf("Calendars: %v", err)
}
if len(entities) != 2 {
t.Fatalf("want 2 calendar entities, got %d (%v)", len(entities), entities)
}
if entities[0].EntityID != "calendar.holidays" || entities[1].EntityID != "calendar.school" {
t.Errorf("entities not sorted: %v", entities)
}
if !entities[1].CanWrite() {
t.Error("calendar.school should advertise CREATE_EVENT")
}
if entities[0].CanWrite() {
t.Error("calendar.holidays should read-only")
}
}
func TestHealthRejectsBadToken(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c, err := New(config.CalendarConfig{
BaseURL: ha.URL, Token: "wrong", EntityID: "calendar.school", Timeout: 5 * time.Second,
}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if err := c.Healthy(context.Background()); err == nil {
t.Fatal("Healthy accepted a bad token")
}
}
func TestCreateEventReturnsUID(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{
Summary: "Harry — End of Year Senior Music Concert",
Start: &start,
End: ptrTime(start.Add(time.Hour)),
Description: "HALL\n\nsource: " + Marker(29),
})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid == "" {
t.Fatal("CreateEvent returned no uid")
}
created := ha.lastCreate
if got := str(created["entity_id"]); got != "calendar.school" {
t.Errorf("entity_id = %q", got)
}
if got := str(created["start_date_time"]); got != "2026-10-15T18:00:00Z" {
t.Errorf("start_date_time = %q", got)
}
if got := str(created["end_date_time"]); got != "2026-10-15T19:00:00Z" {
t.Errorf("end_date_time = %q", got)
}
if !strings.Contains(str(created["description"]), Marker(29)) {
t.Errorf("description lost the marker: %q", created["description"])
}
}
func TestCreateEventWithoutUIDIsNotAnError(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.returnUID = false
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid != "" {
t.Errorf("want no uid, got %q", uid)
}
}
func TestCreateEventErrorCarriesHomeAssistantCode(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.failCreate = true
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
_, err := c.CreateEvent(context.Background(), Event{Summary: "x", Start: &start, End: ptrTime(start)})
if err == nil {
t.Fatal("CreateEvent ignored a 500")
}
haErr, ok := err.(*Error)
if !ok {
t.Fatalf("want *Error, got %T", err)
}
if haErr.Status != http.StatusInternalServerError || haErr.ID != "unknown_error" {
t.Errorf("error = %+v", haErr)
}
if !strings.Contains(err.Error(), "calendar is busy") {
t.Errorf("error loses the message: %v", err)
}
}
func TestEventsReadBack(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
if _, err := c.CreateEvent(context.Background(), Event{
Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
Description: "source: " + Marker(29),
}); err != nil {
t.Fatalf("CreateEvent: %v", err)
}
events, err := c.Events(context.Background(), start.Add(-time.Hour), start.Add(24*time.Hour))
if err != nil {
t.Fatalf("Events: %v", err)
}
if len(events) != 1 {
t.Fatalf("want 1 event, got %d", len(events))
}
if events[0].Summary != "Concert" || !strings.Contains(events[0].Description, Marker(29)) {
t.Errorf("event = %+v", events[0])
}
if events[0].Start == nil || !events[0].Start.Equal(start) {
t.Errorf("start = %v, want %v", events[0].Start, start)
}
body := ha.lastGet
if got := str(body["start_date_time"]); got == "" {
t.Error("get_events did not send a start window")
}
}
func TestFindMarker(t *testing.T) {
cases := map[string]string{
"source: [compass-nhs:29]": "[compass-nhs:29]",
"line\nsource: [compass-nhs:7]\nmore": "[compass-nhs:7]",
"no marker here": "",
"[compass-nhs:abc]": "",
"[compass-nhs:]": "",
}
for in, want := range cases {
if got := findMarker(in); got != want {
t.Errorf("findMarker(%q) = %q, want %q", in, got, want)
}
}
}

275
internal/calendar/sync.go Normal file
View File

@@ -0,0 +1,275 @@
package calendar
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// DefaultDuration is how long a school event is blocked out when Compass gives
// only a start time. The week grid stores a start, not an end.
const DefaultDuration = time.Hour
// Marker builds the idempotency marker stored in an event's description. It
// carries the SQLite item id, so a re-run can recognise its own event even when
// Home Assistant reports no event id.
func Marker(itemID int64) string {
return fmt.Sprintf("[compass-nhs:%d]", itemID)
}
// ItemMarker is the full description tag written to the calendar.
func ItemMarker(itemID int64) string { return "source: " + Marker(itemID) }
// Options narrows one sync run.
type Options struct {
// ChildID limits the run to one child (nil means all children).
ChildID *int64
// ItemID pushes exactly one item (used by the item screen).
ItemID *int64
// Limit caps how many events are written in one run (0 means no cap).
Limit int
// DryRun selects the candidates and reports them without writing.
DryRun bool
// Now is the clock, so tests are deterministic.
Now time.Time
// Duration overrides DefaultDuration.
Duration time.Duration
}
// Created is one event that the run wrote (or would write on a dry run).
type Created struct {
ItemID int64 `json:"item_id"`
Child string `json:"child"`
Title string `json:"title"`
Start string `json:"start"`
UID string `json:"uid,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
Summary string `json:"summary"`
}
// Result is the outcome of one sync run. Failed is non-zero when a write
// failed; the caller then reports loudly (FR-12).
type Result struct {
Entity string `json:"entity"`
Candidates int `json:"candidates"`
Created int `json:"created"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Events []Created `json:"events,omitempty"`
Errors []string `json:"errors,omitempty"`
DryRun bool `json:"dry_run,omitempty"`
}
// Sync pushes every important school event that is not in the calendar yet.
//
// It is idempotent in two ways: an item that already carries a Home Assistant
// event id is not a candidate, and an event whose description carries this
// item's marker is adopted instead of written again. Every attempt is recorded
// in the append-only calendar_pushes table, so a failure is visible and loud
// rather than swallowed (FR-12).
func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *slog.Logger) (Result, error) {
if st == nil {
return Result{}, errors.New("calendar: store is required")
}
if c == nil {
return Result{}, errors.New("calendar: client is required")
}
if log == nil {
log = slog.Default()
}
now := opts.Now
if now.IsZero() {
now = time.Now()
}
duration := opts.Duration
if duration <= 0 {
duration = DefaultDuration
}
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, now, opts.Limit)
if err != nil {
return res, err
}
res.Candidates = len(candidates)
if len(candidates) == 0 {
return res, nil
}
// One window covers every candidate, so the marker scan is a single call.
windowEnd := now.Add(24 * time.Hour)
for _, it := range candidates {
if it.DueAt != nil && it.DueAt.After(windowEnd) {
windowEnd = it.DueAt.Add(24 * time.Hour)
}
}
existing, err := c.Events(ctx, now.Add(-24*time.Hour), windowEnd)
if err != nil {
// A failed read is not a licence to write blind: it would risk
// duplicates, so record one loud failure and stop.
msg := fmt.Sprintf("calendar: reading existing events failed: %v", err)
res.Failed = len(candidates)
res.Errors = append(res.Errors, msg)
for _, it := range candidates {
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, msg); rerr != nil {
log.Error("record calendar push", "item", it.ID, "err", rerr)
}
}
log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
return res, nil
}
known := indexByMarker(existing)
for _, it := range candidates {
if it.DueAt == nil {
continue
}
marker := Marker(it.ID)
if ev, ok := known[marker]; ok {
// Adopt an event this run (or an earlier one) already wrote.
res.Skipped++
uid := ev.UID
if uid == "" {
// No id from Home Assistant: keep the item out of the next
// candidate set by adopting the marker as its reference.
uid = marker
}
if !opts.DryRun {
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err)
}
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
log.Error("record calendar push", "item", it.ID, "err", err)
}
}
res.Events = append(res.Events, Created{
ItemID: it.ID, Child: it.Child, Title: it.Title,
Start: it.DueAt.In(time.Local).Format(time.RFC3339), UID: uid,
Summary: "already in the calendar",
})
continue
}
created := Created{
ItemID: it.ID, Child: it.Child, Title: it.Title,
Start: it.DueAt.In(time.Local).Format(time.RFC3339),
DryRun: opts.DryRun,
}
if opts.DryRun {
res.Created++
res.Events = append(res.Events, created)
continue
}
uid, err := c.CreateEvent(ctx, Event{
Summary: eventSummary(it),
Description: eventDescription(it),
Start: it.DueAt,
End: ptrTime(it.DueAt.Add(duration)),
})
if err != nil {
res.Failed++
msg := fmt.Sprintf("item %d (%s %q): %v", it.ID, it.Child, it.Title, err)
res.Errors = append(res.Errors, msg)
log.Error("calendar write failed", "item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity(), "err", err)
if _, rerr := st.RecordCalendarPush(ctx, it.ID, c.Entity(), "", false, err.Error()); rerr != nil {
log.Error("record calendar push", "item", it.ID, "err", rerr)
}
continue
}
if uid == "" {
// Home Assistant did not report an event id. The marker in the
// description still makes the next run idempotent, but say so.
log.Warn("home assistant returned no event id; relying on the description marker",
"item", it.ID, "child", it.Child, "title", it.Title, "entity", c.Entity())
}
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err)
}
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), uid, true, ""); err != nil {
log.Error("record calendar push", "item", it.ID, "err", err)
}
res.Created++
created.UID = uid
res.Events = append(res.Events, created)
}
sort.Slice(res.Events, func(i, j int) bool { return res.Events[i].Start < res.Events[j].Start })
return res, nil
}
// eventSummary is the calendar entry title. The child's name is prefixed, so a
// shared family calendar says who the event belongs to.
func eventSummary(it store.CalendarItem) string {
title := strings.TrimSpace(it.Title)
if it.Child != "" {
return it.Child + " — " + title
}
return title
}
// eventDescription carries the school detail, the Compass link and the
// idempotency marker. The marker is what makes a re-run safe.
func eventDescription(it store.CalendarItem) string {
var b strings.Builder
body := strings.TrimSpace(it.Body)
if body != "" && body != strings.TrimSpace(it.Title) {
b.WriteString(body)
b.WriteString("\n")
}
if it.URL != "" {
b.WriteString(it.URL)
b.WriteString("\n")
}
if it.Subject != "" {
b.WriteString("Subject: " + it.Subject + "\n")
}
b.WriteString("\n" + ItemMarker(it.ID))
return b.String()
}
// indexByMarker maps a marker to the event that carries it.
func indexByMarker(events []Event) map[string]Event {
out := make(map[string]Event, len(events))
for _, ev := range events {
if m := findMarker(ev.Description); m != "" {
out[m] = ev
}
}
return out
}
// ptrTime returns a pointer to t, so an event end can hold a computed time.
func ptrTime(t time.Time) *time.Time { return &t }
// findMarker returns the first compass-nhs marker in a text, or "".
func findMarker(text string) string {
const open, closeTok = "[compass-nhs:", "]"
i := strings.Index(text, open)
if i < 0 {
return ""
}
start := i + len(open)
j := strings.Index(text[start:], closeTok)
if j < 0 {
return ""
}
value := text[start : start+j]
if value == "" {
return ""
}
for _, r := range value {
if r < '0' || r > '9' {
return ""
}
}
return open + value + closeTok
}

View File

@@ -0,0 +1,238 @@
package calendar
import (
"context"
"io"
"log/slog"
"path/filepath"
"strings"
"testing"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
func testStore(t *testing.T) (*store.Store, int64) {
t.Helper()
st, err := store.Open(filepath.Join(t.TempDir(), "school.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := st.SeedChildren(context.Background(), "Harry"); err != nil {
t.Fatalf("seed: %v", err)
}
child, err := st.ChildByName(context.Background(), "Harry")
if err != nil || child == nil {
t.Fatalf("child: %v %v", child, err)
}
return st, child.ID
}
// seedEvent stores one calendar event for a child.
func seedEvent(t *testing.T, st *store.Store, childID int64, title string, due time.Time, importance string, ref string) store.Item {
t.Helper()
item := store.Item{
Source: "compass",
Type: "event",
Title: title,
Body: title + "\nTeacher(s): KSH",
DueAt: &due,
URL: "https://example.invalid/Organise/Calendar/",
SourcePage: "calendar",
Importance: importance,
ExternalRef: ref,
}
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, err := st.ItemsForChild(context.Background(), childID)
if err != nil {
t.Fatalf("items: %v", err)
}
for _, it := range items {
if it.ExternalRef == ref {
return it
}
}
t.Fatalf("seeded item %q not found", ref)
return store.Item{}
}
func silentLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) }
func TestSyncIsIdempotent(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "End of Year Senior Music Concert - HALL - KSH", due, "high", "concert")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Candidates != 1 || first.Created != 1 || first.Failed != 0 {
t.Fatalf("first result = %+v", first)
}
if ha.created() != 1 {
t.Fatalf("first Sync made %d create calls", ha.created())
}
// The item now carries the Home Assistant event id, so it is no longer a
// candidate: a re-run writes nothing.
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}
func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL - CMA", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
// The event exists in Home Assistant, but the item has no id (a crashed
// run, or a Home Assistant that reports no uid). The marker adopts it.
ha.events = []map[string]any{{
"uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID),
"start_date_time": due.Format(time.RFC3339),
}}
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 0 || res.Skipped != 1 {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("Sync wrote a duplicate (%d create calls)", ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "uid-existing" {
t.Errorf("adopted uid = %q", ci.HAEventUID)
}
}
func TestSyncRecordsALoudFailure(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Tour to China photo - THEATRE", due, "high", "photo")
ha := newFakeHA(t, "calendar.school")
ha.failCreate = true
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || len(res.Errors) != 1 {
t.Fatalf("result = %+v", res)
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Failed != 1 || summary.Last == nil || summary.Last.OK {
t.Fatalf("push summary = %+v", summary)
}
if !strings.Contains(summary.Last.Error, "calendar is busy") {
t.Errorf("recorded error loses the reason: %q", summary.Last.Error)
}
// A failed write leaves the item as a candidate, so a later run retries it.
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil {
t.Fatalf("item: %v", err)
}
if ci.HAEventUID != "" {
t.Errorf("failed item should not carry an event id, got %q", ci.HAEventUID)
}
}
func TestSyncDryRunWritesNothing(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Senior Concert Band rehearsal - ENS", due, "high", "band")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now(), DryRun: true}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 1 || !res.DryRun {
t.Fatalf("result = %+v", res)
}
if ha.created() != 0 {
t.Fatalf("dry run wrote %d events", ha.created())
}
}
func TestSyncIgnoresRoutineLessonsAndPastEvents(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
now := time.Now()
seedEvent(t, st, childID, "5 - 11HLEGB5 - D8 - LHT", now.Add(3*time.Hour), "normal", "lesson")
seedEvent(t, st, childID, "Year 11 Assembly - HALL", now.Add(-2*time.Hour), "high", "past")
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
candidates, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
// The store selects on importance; the lesson and the past event are
// excluded because the parser never marks a lesson high and the past event
// is behind `now`.
if len(candidates) != 0 {
t.Fatalf("candidates = %+v", candidates)
}
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Created != 0 || ha.created() != 0 {
t.Fatalf("result = %+v", res)
}
}
func TestSyncReportsAReadFailureInsteadOfWritingBlind(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.Close() // the calendar is unreachable
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("Sync: %v", err)
}
if res.Failed != 1 || res.Created != 0 {
t.Fatalf("result = %+v", res)
}
if !strings.Contains(res.Errors[0], "reading existing events failed") {
t.Errorf("error = %q", res.Errors[0])
}
}

View File

@@ -43,6 +43,52 @@ type Config struct {
// Location is the family timezone. Due dates and briefs are wall-clock, so // Location is the family timezone. Due dates and briefs are wall-clock, so
// this must be the family's zone, not the container default of UTC. // this must be the family's zone, not the container default of UTC.
Location *time.Location Location *time.Location
// Calendar is the Home Assistant calendar target for important school
// events (AGENTS.md §4 FR-8).
Calendar CalendarConfig
}
// CalendarConfig points at Sam's Home Assistant calendar. Every value comes
// from the environment (environment.d); the token never reaches git, the
// database or the logs (AGENTS.md §10 rule 3, AUD-110).
//
// Environment variables:
//
// SCHOOL_HA_URL base URL, for example http://192.168.20.30:8123
// SCHOOL_HA_TOKEN long-lived access token (secret)
// SCHOOL_HA_CALENDAR_ENTITY calendar entity id, for example calendar.school
// SCHOOL_HA_INSECURE true to skip TLS verification (internal CA)
// SCHOOL_HA_TIMEOUT per-request timeout, default 20s
//
// Calendar writing stays off until all three of URL, token and entity are set,
// so the service still starts before the calendar is configured.
type CalendarConfig struct {
BaseURL string
Token string
EntityID string
Insecure bool
Timeout time.Duration
}
// Enabled reports whether the calendar target is fully configured.
func (c CalendarConfig) Enabled() bool {
return c.BaseURL != "" && c.Token != "" && c.EntityID != ""
}
// Missing lists the environment variables that are still unset, in the order
// the operator should set them. An empty result means the target is usable.
func (c CalendarConfig) Missing() []string {
var out []string
if c.BaseURL == "" {
out = append(out, "SCHOOL_HA_URL")
}
if c.Token == "" {
out = append(out, "SCHOOL_HA_TOKEN")
}
if c.EntityID == "" {
out = append(out, "SCHOOL_HA_CALENDAR_ENTITY")
}
return out
} }
// DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset. // DefaultTimezone is the family timezone used when SCHOOL_TIMEZONE is unset.
@@ -90,6 +136,7 @@ func Load() (*Config, error) {
return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err) return nil, fmt.Errorf("SCHOOL_TIMEZONE: %w", err)
} }
cfg.Location = loc cfg.Location = loc
cfg.Calendar = loadCalendar()
return cfg, nil return cfg, nil
} }
@@ -100,6 +147,7 @@ func Load() (*Config, error) {
type DBConfig struct { type DBConfig struct {
DBPath string DBPath string
Location *time.Location Location *time.Location
Calendar CalendarConfig
} }
// LoadDB reads the database path and the family timezone only. // LoadDB reads the database path and the family timezone only.
@@ -111,9 +159,29 @@ func LoadDB() (*DBConfig, error) {
return &DBConfig{ return &DBConfig{
DBPath: env("SCHOOL_DB_PATH", "/data/school.db"), DBPath: env("SCHOOL_DB_PATH", "/data/school.db"),
Location: loc, Location: loc,
Calendar: loadCalendar(),
}, nil }, nil
} }
// loadCalendar reads the Home Assistant calendar target. It never fails: an
// empty value simply leaves the target disabled, so the service boots before
// the calendar is configured and the calendar commands fail loudly instead.
func loadCalendar() CalendarConfig {
timeout := 20 * time.Second
if v := strings.TrimSpace(os.Getenv("SCHOOL_HA_TIMEOUT")); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
timeout = d
}
}
return CalendarConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SCHOOL_HA_URL")), "/"),
Token: strings.TrimSpace(os.Getenv("SCHOOL_HA_TOKEN")),
EntityID: strings.TrimSpace(os.Getenv("SCHOOL_HA_CALENDAR_ENTITY")),
Insecure: envBool("SCHOOL_HA_INSECURE", false),
Timeout: timeout,
}
}
func env(key, fallback string) string { func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" { if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v return v

View File

@@ -46,7 +46,7 @@ func ClassifyDeterministic(items []Item, now time.Time) {
func deterministicImportance(it Item, week time.Time) string { func deterministicImportance(it Item, week time.Time) string {
// Only work carries a deadline that makes it important. A calendar entry's // Only work carries a deadline that makes it important. A calendar entry's
// due_at is its start time, and a lesson that starts this week is not // due_at is its start time, and a lesson that starts this week is not
// overdue work, so events and messages stay normal under the offline rule. // overdue work, so meetings stay normal under the offline rule.
if it.Type == TypeTask { if it.Type == TypeTask {
if it.DueAt != nil && it.DueAt.Before(week) { if it.DueAt != nil && it.DueAt.Before(week) {
return importanceHigh return importanceHigh
@@ -55,9 +55,27 @@ func deterministicImportance(it Item, week time.Time) string {
return importanceHigh return importanceHigh
} }
} }
// A school event is important; a routine class period is not (Phase 6
// writes only the important ones to the calendar, AGENTS.md §4 FR-8).
if it.Type == TypeEvent && eventIsSchoolEvent(it.Title) {
return importanceHigh
}
return importanceNormal return importanceNormal
} }
// eventIsSchoolEvent reports whether a calendar entry is a school event rather
// than a routine class period. Compass writes a lesson as
// "5 - 11HLEGB5 - D8 - LHT" and a school event as
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR". The first part of
// a lesson is a period label ("5", "C" or "RLE GROUP D").
func eventIsSchoolEvent(title string) bool {
parts := splitDash(title)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return false
}
return !periodLabel.MatchString(parts[0])
}
// ClassifyModel asks a model to classify importance for every item (FR-4). It // ClassifyModel asks a model to classify importance for every item (FR-4). It
// fails, loudly, on a model error or an unparseable answer; it never silently // fails, loudly, on a model error or an unparseable answer; it never silently
// leaves a wrong label. Items are updated in place. now lets the model judge // leaves a wrong label. Items are updated in place. now lets the model judge

View File

@@ -304,6 +304,26 @@ func TestClassifyDeterministic(t *testing.T) {
func ptr(t time.Time) *time.Time { return &t } func ptr(t time.Time) *time.Time { return &t }
// TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school
// event is important, a routine class period is not.
func TestClassifyDeterministicEvents(t *testing.T) {
now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
at := now.Add(4 * time.Hour)
items := []Item{
{Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at},
{Type: TypeEvent, Title: "Year 11 Assembly - HALL - CMA", DueAt: &at},
{Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at},
{Type: TypeEvent, Title: "C - 11TUTB - M6 - ANE", DueAt: &at},
{Type: TypeEvent, Title: "RLE GROUP D - IM1 - RLE", DueAt: &at},
}
ClassifyDeterministic(items, now)
for i, want := range []string{importanceHigh, importanceHigh, importanceNormal, importanceNormal, importanceNormal} {
if items[i].Importance != want {
t.Errorf("items[%d] %q = %q, want %q", i, items[i].Title, items[i].Importance, want)
}
}
}
// tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with // tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with
// the per-child tasks. Task 1 is overdue and carries its due date at the task // the per-child tasks. Task 1 is overdue and carries its due date at the task
// level; task 2 is on time and carries it on the student; task 3 belongs to the // level; task 2 is on time and carries it on the student; task 3 belongs to the

221
internal/store/calendar.go Normal file
View File

@@ -0,0 +1,221 @@
package store
import (
"context"
"database/sql"
"fmt"
"time"
)
// This file holds the Phase 6 calendar helpers (AGENTS.md §4 FR-8, §11 Phase 6):
// the events that are worth writing to Sam's Home Assistant calendar, the mark
// that records the Home Assistant event id on the item, and the append-only
// push log that makes a failed write loud (FR-12).
// calendarSelect extends the shared item column list with the Home Assistant
// columns, so a candidate carries its own push state.
const calendarSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, ''),
COALESCE(i.ha_event_uid, ''), i.ha_pushed_at
FROM items i LEFT JOIN children ch ON ch.id = i.child_id`
// CalendarItem is one item plus its Home Assistant push state.
type CalendarItem struct {
Item
// HAEventUID is the Home Assistant event id once the item is in the
// calendar ("" until then).
HAEventUID string
// HAPushedAt is when the item was pushed, or nil.
HAPushedAt *time.Time
}
// CalendarCandidates returns the important school events that belong in the
// calendar: Compass events marked "high" by the Phase 3 classifier, starting at
// or after from, newest-first excluded. childID and itemID narrow the set, and
// limit caps it (0 means no cap).
//
// An item that already carries a Home Assistant event id is not a candidate:
// the write is idempotent, so a re-run must not duplicate it.
func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64, from time.Time, limit int) ([]CalendarItem, error) {
q := calendarSelect + `
WHERE i.type = 'event'
AND i.source = 'compass'
AND i.importance = 'high'
AND COALESCE(i.ha_event_uid, '') = ''
AND i.due_at IS NOT NULL
AND i.due_at >= ?`
args := []any{from.UTC().Format(time.RFC3339)}
if childID != nil {
q += ` AND i.child_id = ?`
args = append(args, *childID)
}
if itemID != nil {
q += ` AND i.id = ?`
args = append(args, *itemID)
}
q += ` ORDER BY i.due_at ASC, i.id ASC`
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("calendar candidates: %w", err)
}
defer rows.Close()
var out []CalendarItem
for rows.Next() {
var it CalendarItem
var due, pushed sql.NullString
if err := rows.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
&it.SourcePage, &it.Importance, &it.ExternalRef,
&it.HAEventUID, &pushed); err != nil {
return nil, fmt.Errorf("scan calendar item: %w", err)
}
if due.Valid {
t := parseTime(due.String)
it.DueAt = &t
}
if pushed.Valid {
t := parseTime(pushed.String)
it.HAPushedAt = &t
}
out = append(out, it)
}
return out, rows.Err()
}
// CalendarItemByID returns one item with its Home Assistant push state, or nil
// when it does not exist. The item screen uses it to show the calendar status.
func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem, error) {
row := s.db.QueryRowContext(ctx, calendarSelect+` WHERE i.id = ?`, id)
var it CalendarItem
var due, pushed sql.NullString
err := row.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
&it.SourcePage, &it.Importance, &it.ExternalRef,
&it.HAEventUID, &pushed)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("item %d: %w", id, err)
}
if due.Valid {
t := parseTime(due.String)
it.DueAt = &t
}
if pushed.Valid {
t := parseTime(pushed.String)
it.HAPushedAt = &t
}
return &it, nil
}
// MarkItemCalendarSynced records the Home Assistant event id and the push time
// on one item. After this the item is no longer a calendar candidate.
func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE items SET ha_event_uid = ?, ha_pushed_at = ?, updated_at = datetime('now') WHERE id = ?`,
uid, at.UTC().Format(time.RFC3339), itemID); err != nil {
return fmt.Errorf("mark item %d calendar-synced: %w", itemID, err)
}
return nil
}
// CalendarPush is one row of the append-only calendar push log.
type CalendarPush struct {
ID int64 `json:"id"`
ItemID int64 `json:"item_id"`
EntityID string `json:"entity_id"`
UID string `json:"uid,omitempty"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// CalendarPushStatus summarises the push log for the dashboard. A failed push is
// shown as an explicit failure and is never swallowed (FR-12).
type CalendarPushStatus struct {
// EverRun is false until the first push is recorded.
EverRun bool `json:"ever_run"`
// Total is the number of push rows.
Total int `json:"total"`
// Failed is the number of rows with ok = 0.
Failed int `json:"failed"`
// Last is the most recent push, when one exists.
Last *CalendarPush `json:"last,omitempty"`
}
// RecordCalendarPush appends one push attempt. ok is false with an error message
// for a failed write, which the dashboard then shows.
func (s *Store) RecordCalendarPush(ctx context.Context, itemID int64, entityID, uid string, ok bool, errMsg string) (int64, error) {
var errVal any
if errMsg != "" {
errVal = errMsg
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO calendar_pushes (item_id, entity_id, uid, ok, error) VALUES (?, ?, ?, ?, ?)`,
itemID, entityID, uid, ok, errVal)
if err != nil {
return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err)
}
id, err := res.LastInsertId()
if err != nil {
return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err)
}
return id, nil
}
// LastCalendarPush returns the most recent push row, or nil when none exists.
func (s *Store) LastCalendarPush(ctx context.Context) (*CalendarPush, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, item_id, entity_id, COALESCE(uid, ''), ok, COALESCE(error, ''), created_at
FROM calendar_pushes ORDER BY id DESC LIMIT 1`)
p, err := scanCalendarPush(row)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &p, nil
}
// CalendarPushSummary summarises the push log (FR-12).
func (s *Store) CalendarPushSummary(ctx context.Context) (CalendarPushStatus, error) {
var out CalendarPushStatus
row := s.db.QueryRowContext(ctx,
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM calendar_pushes`)
if err := row.Scan(&out.Total, &out.Failed); err != nil {
return out, fmt.Errorf("calendar push summary: %w", err)
}
out.EverRun = out.Total > 0
last, err := s.LastCalendarPush(ctx)
if err != nil {
return out, err
}
out.Last = last
return out, nil
}
func scanCalendarPush(r rowScanner) (CalendarPush, error) {
var p CalendarPush
var created string
var uid, errMsg sql.NullString
var ok int
if err := r.Scan(&p.ID, &p.ItemID, &p.EntityID, &uid, &ok, &errMsg, &created); err != nil {
return p, err
}
p.UID = uid.String
p.OK = ok != 0
p.Error = errMsg.String
p.CreatedAt = parseTime(created)
return p, nil
}

View File

@@ -0,0 +1,115 @@
package store
import (
"context"
"testing"
"time"
)
// TestCalendarCandidatesSelectsImportantFutureEventsOnly proves the Phase 6
// selection rule: only important, future Compass events that are not in the
// calendar yet are candidates.
func TestCalendarCandidatesSelectsImportantFutureEventsOnly(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
finn, _ := st.ChildByName(ctx, "Finn")
now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
future := now.Add(4 * time.Hour)
past := now.Add(-4 * time.Hour)
items := []Item{
{Source: "compass", Type: "event", Title: "Concert", DueAt: &future, Importance: "high", ExternalRef: "e1"},
{Source: "compass", Type: "event", Title: "Lesson", DueAt: &future, Importance: "normal", ExternalRef: "e2"},
{Source: "compass", Type: "event", Title: "Yesterday", DueAt: &past, Importance: "high", ExternalRef: "e3"},
{Source: "compass", Type: "task", Title: "Essay", DueAt: &future, Importance: "high", ExternalRef: "e4"},
{Source: "teams", Type: "event", Title: "Teams thing", DueAt: &future, Importance: "high", ExternalRef: "e5"},
}
if _, _, err := st.UpsertItems(ctx, harry.ID, items); err != nil {
t.Fatalf("upsert: %v", err)
}
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
{Source: "compass", Type: "event", Title: "Finn event", DueAt: &future, Importance: "high", ExternalRef: "f1"},
}); err != nil {
t.Fatalf("upsert finn: %v", err)
}
all, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
if len(all) != 2 {
t.Fatalf("want 2 candidates (one per child), got %d: %+v", len(all), all)
}
if all[0].Title != "Concert" || all[1].Title != "Finn event" {
t.Errorf("unexpected candidate order: %+v", all)
}
onlyHarry, err := st.CalendarCandidates(ctx, &harry.ID, nil, now, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
if len(onlyHarry) != 1 || onlyHarry[0].Child != "Harry" {
t.Fatalf("per-child filter failed: %+v", onlyHarry)
}
// Mark the concert as synced; it must leave the candidate set.
if err := st.MarkItemCalendarSynced(ctx, all[0].ID, "uid-1", now); err != nil {
t.Fatalf("mark: %v", err)
}
after, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
if err != nil {
t.Fatalf("candidates: %v", err)
}
if len(after) != 1 || after[0].Title != "Finn event" {
t.Fatalf("synced item is still a candidate: %+v", after)
}
ci, err := st.CalendarItemByID(ctx, all[0].ID)
if err != nil || ci == nil {
t.Fatalf("item: %v %v", ci, err)
}
if ci.HAEventUID != "uid-1" || ci.HAPushedAt == nil {
t.Errorf("mark did not stick: %+v", ci)
}
}
// TestCalendarPushSummaryIsAppendOnly proves a failed write stays on record.
func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(time.Hour).UTC()
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
{Source: "compass", Type: "event", Title: "Concert", DueAt: &due, Importance: "high", ExternalRef: "e1"},
}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
empty, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if empty.EverRun || empty.Total != 0 || empty.Last != nil {
t.Fatalf("fresh summary = %+v", empty)
}
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "", false, "boom"); err != nil {
t.Fatalf("record: %v", err)
}
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "uid-1", true, ""); err != nil {
t.Fatalf("record: %v", err)
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Total != 2 || summary.Failed != 1 || !summary.EverRun {
t.Fatalf("summary = %+v", summary)
}
if summary.Last == nil || !summary.Last.OK || summary.Last.UID != "uid-1" {
t.Fatalf("last = %+v", summary.Last)
}
}

View File

@@ -0,0 +1,24 @@
-- Migration 003 — Phase 6 calendar write (AGENTS.md §4 FR-8, §11 Phase 6).
--
-- Important school events are written to Sam's Home Assistant calendar. The
-- write must be idempotent: the Home Assistant event id is stored on the item,
-- so a re-run updates nothing and never duplicates an event. The push log is
-- append-only, so a failed calendar write is visible and loud instead of being
-- swallowed (FR-12).
ALTER TABLE items ADD COLUMN ha_event_uid TEXT NOT NULL DEFAULT '';
ALTER TABLE items ADD COLUMN ha_pushed_at TEXT;
-- One row per calendar write attempt. ok = 0 with an error is a loud failure.
CREATE TABLE IF NOT EXISTS calendar_pushes (
id INTEGER PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items(id),
entity_id TEXT NOT NULL,
uid TEXT,
ok INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_calendar_pushes_item ON calendar_pushes(item_id);
CREATE INDEX IF NOT EXISTS idx_calendar_pushes_created ON calendar_pushes(created_at);

View File

@@ -18,6 +18,7 @@ import (
"time" "time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/auth"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store" "gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
@@ -64,6 +65,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard)) mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard))
mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail)) mux.HandleFunc("GET /items/{id}", s.requireAuth(s.handleItemDetail))
mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete)) mux.HandleFunc("POST /items/{id}/delete", s.requireAuth(s.handleItemDelete))
mux.HandleFunc("POST /items/{id}/calendar", s.requireAuth(s.handleItemCalendarPush))
mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild)) mux.HandleFunc("POST /summaries/build", s.requireAuth(s.handleSummaryBuild))
mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete)) mux.HandleFunc("POST /summaries/{id}/delete", s.requireAuth(s.handleSummaryDelete))
mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial)) mux.HandleFunc("GET /partials/scan-status", s.requireAuth(s.handleScanStatusPartial))
@@ -282,6 +284,63 @@ func (s *Server) scanStatus(ctx context.Context) scanStatusView {
// "nothing due" instead of "unknown". // "nothing due" instead of "unknown".
func (v scanStatusView) OK() bool { return v.Level == "ok" } func (v scanStatusView) OK() bool { return v.Level == "ok" }
// ---------- fail-loudly calendar banner (FR-8, FR-12) ----------
// calendarBannerView is the fail-loudly view of the Home Assistant calendar
// target. A missing target or a failed write is shown explicitly, so the
// family never assumes an event was written when it was not.
type calendarBannerView struct {
// Level is "off", "warn", "error" or "ok".
Level string
Message string
Entity string
LastAt string
Written int
Failed int
}
// OK reports whether the calendar target is healthy.
func (v calendarBannerView) OK() bool { return v.Level == "ok" }
func (s *Server) calendarBanner(ctx context.Context) calendarBannerView {
cc := s.cfg.Calendar
if !cc.Enabled() {
return calendarBannerView{
Level: "warn",
Message: "CALENDAR NOT CONFIGURED — important school events are not being written. Set " + strings.Join(cc.Missing(), ", ") + ".",
}
}
v := calendarBannerView{Entity: cc.EntityID}
st, err := s.store.CalendarPushSummary(ctx)
if err != nil {
s.log.Error("calendar push summary", "err", err)
v.Level = "error"
v.Message = "Could not read the calendar log: " + err.Error()
return v
}
v.Written = st.Total - st.Failed
v.Failed = st.Failed
if st.Last != nil {
v.LastAt = st.Last.CreatedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
if st.Last != nil && !st.Last.OK {
v.Level = "error"
v.Message = "CALENDAR WRITE FAILED — the last write to " + cc.EntityID + " did not complete."
if st.Last.Error != "" {
v.Message += " " + st.Last.Error
}
return v
}
if !st.EverRun {
v.Level = "warn"
v.Message = "No calendar write has run yet for " + cc.EntityID + "."
return v
}
v.Level = "ok"
v.Message = "Last calendar write completed."
return v
}
// ---------- dashboard (FR-6, FR-7) ---------- // ---------- dashboard (FR-6, FR-7) ----------
type itemView struct { type itemView struct {
@@ -335,6 +394,7 @@ type dashboardData struct {
Version string Version string
AppEnv string AppEnv string
Scan scanStatusView Scan scanStatusView
Calendar calendarBannerView
Tabs []childTab Tabs []childTab
Child childPanel Child childPanel
HasChild bool HasChild bool
@@ -357,6 +417,7 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
Version: s.cfg.Version, Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv, AppEnv: s.cfg.AppEnv,
Scan: s.scanStatus(ctx), Scan: s.scanStatus(ctx),
Calendar: s.calendarBanner(ctx),
ServerUp: time.Since(serverStarted).Round(time.Second).String(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
} }
@@ -518,46 +579,155 @@ type itemData struct {
Item itemView Item itemView
Child store.Child Child store.Child
Scan scanStatusView Scan scanStatusView
Calendar calendarItemView
Flash string
FlashLevel string
ServerUp string ServerUp string
} }
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) { // calendarItemView is the calendar state of one item on the item screen.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) type calendarItemView struct {
defer cancel() Configured bool
Entity string
Eligible bool
InCalendar bool
UID string
PushedAt string
}
// itemCalendarView describes whether one item can go to the calendar and
// whether it is already there. An important, future Compass event is eligible.
func (s *Server) itemCalendarView(it store.CalendarItem, now time.Time) calendarItemView {
v := calendarItemView{
Configured: s.cfg.Calendar.Enabled(),
Entity: s.cfg.Calendar.EntityID,
InCalendar: it.HAEventUID != "",
UID: it.HAEventUID,
}
v.Eligible = it.Type == "event" && it.Source == "compass" && it.Importance == "high" &&
it.DueAt != nil && it.DueAt.After(now)
if it.HAPushedAt != nil {
v.PushedAt = it.HAPushedAt.In(s.cfg.Location).Format("Mon 02 Jan 2006 15:04")
}
return v
}
func (s *Server) handleItemDetail(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r, "id") id, ok := pathID(r, "id")
if !ok { if !ok {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
it, err := s.store.ItemByID(ctx, id) s.renderItemDetail(w, r, id, "", "")
}
// renderItemDetail renders the item screen, optionally with a calendar flash.
func (s *Server) renderItemDetail(w http.ResponseWriter, r *http.Request, id int64, flash, flashLevel string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
ci, err := s.store.CalendarItemByID(ctx, id)
if err != nil { if err != nil {
s.log.Error("item", "id", id, "err", err) s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError) http.Error(w, "could not read the item", http.StatusInternalServerError)
return return
} }
if it == nil { if ci == nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
child, err := s.store.ChildByID(ctx, it.ChildID) child, err := s.store.ChildByID(ctx, ci.ChildID)
if err != nil || child == nil { if err != nil || child == nil {
child = &store.Child{ID: it.ChildID, Name: it.Child} child = &store.Child{ID: ci.ChildID, Name: ci.Child}
} }
s.render(w, r, "item.html", itemData{ s.render(w, r, "item.html", itemData{
Title: it.Title, Title: ci.Title,
Username: s.cfg.AdminUsername, Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r), CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version, Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv, AppEnv: s.cfg.AppEnv,
Item: itemViews([]store.Item{*it}, s.cfg.Location)[0], Item: itemViews([]store.Item{ci.Item}, s.cfg.Location)[0],
Child: *child, Child: *child,
Scan: s.scanStatus(ctx), Scan: s.scanStatus(ctx),
Calendar: s.itemCalendarView(*ci, time.Now()),
Flash: flash,
FlashLevel: flashLevel,
ServerUp: time.Since(serverStarted).Round(time.Second).String(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}) })
} }
// handleItemCalendarPush writes one item to Sam's Home Assistant calendar. The
// school stays read-only: the only write is to the calendar (AGENTS.md §10
// rule 1). A failure is shown on the page and recorded in calendar_pushes.
func (s *Server) handleItemCalendarPush(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("calendar push rejected: bad csrf token", "path", r.URL.Path)
http.Error(w, "session expired", http.StatusForbidden)
return
}
id, ok := pathID(r, "id")
if !ok {
http.NotFound(w, r)
return
}
ci, err := s.store.CalendarItemByID(ctx, id)
if err != nil {
s.log.Error("item", "id", id, "err", err)
http.Error(w, "could not read the item", http.StatusInternalServerError)
return
}
if ci == nil {
http.NotFound(w, r)
return
}
if missing := s.cfg.Calendar.Missing(); len(missing) > 0 {
s.renderItemDetail(w, r, id,
"Calendar is not configured. Set "+strings.Join(missing, ", ")+".", "error")
return
}
client, err := calendar.New(s.cfg.Calendar, s.log)
if err != nil {
s.log.Error("calendar client", "err", err)
s.renderItemDetail(w, r, id, "Calendar is not usable: "+err.Error(), "error")
return
}
res, err := calendar.Sync(ctx, s.store, client,
calendar.Options{ItemID: &id, Now: time.Now().In(s.cfg.Location)}, s.log)
if err != nil {
s.log.Error("calendar push", "item", id, "err", err)
s.renderItemDetail(w, r, id, "Calendar write failed: "+err.Error(), "error")
return
}
switch {
case res.Failed > 0:
msg := "Calendar write failed."
if len(res.Errors) > 0 {
msg = "Calendar write failed: " + strings.Join(res.Errors, "; ")
}
s.log.Error("calendar push failed", "item", id, "child", ci.Child, "errors", res.Errors)
s.renderItemDetail(w, r, id, msg, "error")
case res.Created == 0 && res.Skipped > 0:
s.renderItemDetail(w, r, id, "Already in the calendar ("+s.cfg.Calendar.EntityID+").", "ok")
case res.Created == 0:
s.renderItemDetail(w, r, id,
"Nothing to write: only important, future school events go to the calendar.", "error")
default:
s.log.Info("calendar event written", "item", id, "child", ci.Child, "title", ci.Title, "entity", s.cfg.Calendar.EntityID)
s.renderItemDetail(w, r, id, "Added to "+s.cfg.Calendar.EntityID+" on Sam's Home Assistant calendar.", "ok")
}
}
func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) { func (s *Server) handleItemDelete(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel() defer cancel()

View File

@@ -2,6 +2,7 @@ package web
import ( import (
"context" "context"
"encoding/json"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -31,6 +32,11 @@ var csrfRe = regexp.MustCompile(`name="csrf" value="([^"]+)"`)
// newTestServer builds the web handler against a fresh SQLite database. // newTestServer builds the web handler against a fresh SQLite database.
func newTestServer(t *testing.T) (*httptest.Server, *store.Store) { func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
return newTestServerCalendar(t, config.CalendarConfig{})
}
// newTestServerCalendar is newTestServer with a Home Assistant calendar target.
func newTestServerCalendar(t *testing.T, cal config.CalendarConfig) (*httptest.Server, *store.Store) {
t.Helper() t.Helper()
loc, err := time.LoadLocation("Australia/Sydney") loc, err := time.LoadLocation("Australia/Sydney")
if err != nil { if err != nil {
@@ -58,6 +64,7 @@ func newTestServer(t *testing.T) (*httptest.Server, *store.Store) {
AppEnv: "test", AppEnv: "test",
Version: "test", Version: "test",
Location: loc, Location: loc,
Calendar: cal,
} }
am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword) am, err := auth.New(cfg.SessionSecret, cfg.SessionTTL, false, cfg.AdminUsername, "", cfg.AdminPassword)
if err != nil { if err != nil {
@@ -307,3 +314,187 @@ func snippet(s string) string {
} }
return s return s
} }
// fakeHACalendar is a minimal Home Assistant calendar for the item-screen test.
// It records created events so a read-back can prove the write.
func fakeHACalendar(t *testing.T, failCreate bool) *httptest.Server {
t.Helper()
var events []map[string]any
mux := http.NewServeMux()
mux.HandleFunc("POST /api/services/calendar/create_event", func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
_ = json.NewDecoder(r.Body).Decode(&body)
if failCreate {
w.WriteHeader(http.StatusInternalServerError)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": map[string]any{"code": "unknown_error", "message": "calendar is busy"},
})
return
}
events = append(events, map[string]any{
"uid": "uid-1", "summary": body["summary"], "description": body["description"],
"start_date_time": body["start_date_time"],
})
_ = json.NewEncoder(w).Encode(map[string]any{
"service_response": map[string]any{"calendar.school": map[string]any{"uid": "uid-1"}},
})
})
mux.HandleFunc("POST /api/services/calendar/get_events", func(w http.ResponseWriter, r *http.Request) {
if events == nil {
events = []map[string]any{}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"service_response": map[string]any{"calendar.school": map[string]any{"events": events}},
})
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// TestDashboardWarnsWhenTheCalendarIsNotConfigured proves the calendar half of
// FR-12: an unconfigured target is stated, never silently skipped.
func TestDashboardWarnsWhenTheCalendarIsNotConfigured(t *testing.T) {
ts, _ := newTestServer(t)
client := login(t, ts)
body := getBody(t, client, ts.URL+"/")
if !strings.Contains(body, "CALENDAR NOT CONFIGURED") {
t.Errorf("dashboard does not warn about the calendar: %s", snippet(body))
}
if !strings.Contains(body, "SCHOOL_HA_URL") {
t.Error("warning does not name the missing configuration")
}
}
// TestItemCalendarPush proves the item screen writes one event and records it.
func TestItemCalendarPush(t *testing.T) {
ha := fakeHACalendar(t, false)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "End of Year Senior Music Concert - HALL",
Body: "End of Year Senior Music Concert - HALL - KSH", DueAt: &due,
Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
if !strings.Contains(detail, "Add to calendar") {
t.Fatalf("item screen has no calendar action: %s", snippet(detail))
}
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
page, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("push status = %d, want 200", resp.StatusCode)
}
if !strings.Contains(string(page), "Added to calendar.school") {
t.Errorf("push page has no confirmation: %s", snippet(string(page)))
}
if !strings.Contains(string(page), "in the calendar") {
t.Errorf("item screen does not show the calendar state: %s", snippet(string(page)))
}
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
t.Fatalf("summary: %v", err)
}
if summary.Total != 1 || summary.Failed != 0 {
t.Fatalf("push log = %+v", summary)
}
ci, _ := st.CalendarItemByID(ctx, id)
if ci == nil || ci.HAEventUID != "uid-1" {
t.Fatalf("item does not carry the event id: %+v", ci)
}
}
// TestItemCalendarPushFailureIsLoud proves a failed write is shown and recorded.
func TestItemCalendarPushFailureIsLoud(t *testing.T) {
ha := fakeHACalendar(t, true)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Second)
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "Year 11 Assembly - HALL",
DueAt: &due, Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
id := items[0].ID
client := login(t, ts)
detail := getBody(t, client, ts.URL+"/items/"+itoa(id))
resp, err := client.PostForm(ts.URL+"/items/"+itoa(id)+"/calendar",
url.Values{"csrf": {csrfToken(t, detail)}})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
page, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(page), "Calendar write failed") {
t.Errorf("failure is not shown on the page: %s", snippet(string(page)))
}
if !strings.Contains(string(page), "calendar is busy") {
t.Errorf("failure loses the reason: %s", snippet(string(page)))
}
summary, _ := st.CalendarPushSummary(ctx)
if summary.Failed != 1 {
t.Fatalf("failed write was not recorded: %+v", summary)
}
// The dashboard repeats the failure, so it is visible without the item page.
body := getBody(t, client, ts.URL+"/")
if !strings.Contains(body, "CALENDAR WRITE FAILED") {
t.Errorf("dashboard does not show the failed write: %s", snippet(body))
}
}
// TestItemCalendarPushNeedsCSRF proves the write is not open to a forged post.
func TestItemCalendarPushNeedsCSRF(t *testing.T) {
ha := fakeHACalendar(t, false)
ts, st := newTestServerCalendar(t, config.CalendarConfig{
BaseURL: ha.URL, Token: "test-token", EntityID: "calendar.school", Timeout: 5 * time.Second,
})
ctx := context.Background()
harry, _ := st.ChildByName(ctx, "Harry")
due := time.Now().Add(4 * time.Hour).UTC()
if _, _, err := st.UpsertItems(ctx, harry.ID, []store.Item{{
Source: "compass", Type: "event", Title: "Concert", DueAt: &due,
Importance: "high", ExternalRef: "e1",
}}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, _ := st.ItemsForChild(ctx, harry.ID)
client := login(t, ts)
resp, err := client.PostForm(ts.URL+"/items/"+itoa(items[0].ID)+"/calendar", url.Values{})
if err != nil {
t.Fatalf("push: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("tokenless push = %d, want 403", resp.StatusCode)
}
summary, _ := st.CalendarPushSummary(ctx)
if summary.Total != 0 {
t.Errorf("a tokenless push recorded a write: %+v", summary)
}
}

View File

@@ -240,6 +240,12 @@ a:hover { text-decoration: underline; }
color: var(--sticker-red); color: var(--sticker-red);
background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface)); background: color-mix(in srgb, var(--sticker-red) 8%, var(--surface));
} }
.flash--ok {
border-color: var(--sticker-green);
border-left-width: 3px;
color: #0f7a28;
background: color-mix(in srgb, var(--sticker-green) 8%, var(--surface));
}
/* ---------- Scan status (FR-12: fail loudly) ---------- */ /* ---------- Scan status (FR-12: fail loudly) ---------- */

View File

@@ -39,6 +39,8 @@
{{template "scan-status.html" .Scan}} {{template "scan-status.html" .Scan}}
</div> </div>
{{template "calendar-status.html" .Calendar}}
{{if .Tabs}} {{if .Tabs}}
<nav class="child-tabs" aria-label="Children"> <nav class="child-tabs" aria-label="Children">
{{range .Tabs}} {{range .Tabs}}

View File

@@ -30,6 +30,7 @@
<p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p> <p class="breadcrumb"><a href="/?child={{.Child.ID}}">← {{.Child.Name}}</a></p>
<article class="detail"> <article class="detail">
{{if .Flash}}<p class="flash flash--{{if eq .FlashLevel "ok"}}ok{{else}}error{{end}}" role="alert">{{.Flash}}</p>{{end}}
<header class="detail__head"> <header class="detail__head">
<h1 class="heading-1">{{.Item.Title}}</h1> <h1 class="heading-1">{{.Item.Title}}</h1>
<p class="detail__meta"> <p class="detail__meta">
@@ -51,12 +52,23 @@
<dt>Child</dt><dd>{{.Child.Name}}</dd> <dt>Child</dt><dd>{{.Child.Name}}</dd>
<dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd> <dt>Source</dt><dd>{{.Item.Source}}{{if .Item.SourcePage}} · {{.Item.SourcePage}}{{end}}</dd>
{{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}} {{if .Item.Importance}}<dt>Importance</dt><dd>{{.Item.Importance}}</dd>{{end}}
<dt>Calendar</dt>
<dd>{{if .Calendar.InCalendar}}in the calendar{{if .Calendar.PushedAt}} · {{.Calendar.PushedAt}}{{end}}{{if .Calendar.UID}} · {{.Calendar.UID}}{{end}}
{{else if not .Calendar.Configured}}not configured
{{else if .Calendar.Eligible}}not written yet
{{else}}not a calendar item{{end}}</dd>
{{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}} {{if .Item.ExternalRef}}<dt>Reference</dt><dd>{{.Item.ExternalRef}}</dd>{{end}}
{{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}} {{if .Item.DueAt}}<dt>Due</dt><dd>{{.Item.DueLabel}}</dd>{{end}}
</dl> </dl>
<div class="detail__actions"> <div class="detail__actions">
{{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}} {{if .Item.URL}}<a class="button-primary" href="{{.Item.URL}}" rel="noreferrer noopener">Open in Compass</a>{{end}}
{{if and .Calendar.Configured .Calendar.Eligible (not .Calendar.InCalendar)}}
<form method="post" action="/items/{{.Item.ID}}/calendar">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="button-primary" type="submit">Add to calendar</button>
</form>
{{end}}
<form method="post" action="/items/{{.Item.ID}}/delete" <form method="post" action="/items/{{.Item.ID}}/delete"
hx-post="/items/{{.Item.ID}}/delete" hx-confirm="Delete this item? It returns on the next scan if the school still lists it."> hx-post="/items/{{.Item.ID}}/delete" hx-confirm="Delete this item? It returns on the next scan if the school still lists it.">
<input type="hidden" name="csrf" value="{{.CSRF}}"> <input type="hidden" name="csrf" value="{{.CSRF}}">

View File

@@ -0,0 +1,19 @@
{{define "calendar-status.html"}}
{{/* Fail-loudly calendar banner (FR-8, FR-12). A missing target or a failed
write is never hidden: the family must not assume an event was written. */}}
<section class="scan scan--{{.Level}}" aria-labelledby="calendar-title">
<div class="scan__head">
<h2 id="calendar-title" class="heading-2">Calendar</h2>
<span class="category-pill scan__pill">
{{if eq .Level "ok"}}healthy
{{else if eq .Level "error"}}failed
{{else if eq .Level "warn"}}attention
{{else}}off{{end}}
</span>
</div>
<p class="scan__message">{{.Message}}</p>
{{if .Entity}}<p class="scan__meta">{{.Entity}}{{if .LastAt}} · last write {{.LastAt}}{{end}}
{{if gt .Written 0}}· {{.Written}} event(s) written{{end}}</p>{{end}}
{{if gt .Failed 0}}<p class="scan__meta">{{.Failed}} failed calendar write(s) on record.</p>{{end}}
</section>
{{end}}

61
scripts/calendar-push.sh Executable file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Phase 6: write important school events to Sam's Home Assistant calendar and
# read the calendar back (AGENTS.md §4 FR-8, §11 Phase 6).
#
# The school stays read-only. The only write is to the calendar. The Home
# Assistant token comes from the environment or from the service env file and is
# never printed.
#
# Usage:
# scripts/calendar-push.sh entities
# scripts/calendar-push.sh status
# scripts/calendar-push.sh push [--child Harry] [--dry-run]
# scripts/calendar-push.sh events [--days 14]
#
# Environment overrides:
# IMAGE image tag (default 0.4.0-phase6)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# ENV_FILE HA config source (default environment.d/school-service.env)
# SCHOOL_TIMEZONE family timezone (default Australia/Sydney)
# SCHOOL_HA_URL Home Assistant base URL (no default; required)
# SCHOOL_HA_TOKEN long-lived access token (no default; required)
# SCHOOL_HA_CALENDAR_ENTITY calendar entity id (no default; required)
# TIMEOUT docker command timeout (default 300)
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
ENV_FILE="${ENV_FILE:-$REPO_ROOT/environment.d/school-service.env}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Sydney}"
TIMEOUT="${TIMEOUT:-300}"
# The real service env file is gitignored. Take only the calendar variables from
# it, so the service's bcrypt hash (which contains "$$") is never re-expanded.
if [[ -f "$ENV_FILE" ]]; then
while IFS= read -r line; do
case "$line" in
SCHOOL_HA_URL=*|SCHOOL_HA_TOKEN=*|SCHOOL_HA_CALENDAR_ENTITY=*|SCHOOL_HA_INSECURE=*|SCHOOL_HA_TIMEOUT=*)
key="${line%%=*}"
val="${line#*=}"
if [[ -z "${!key:-}" ]]; then
export "$key=$val"
fi
;;
esac
done < "$ENV_FILE"
fi
if [[ $# -eq 0 ]]; then
echo "usage: scripts/calendar-push.sh entities|status|push|events [options]" >&2
exit 2
fi
exec timeout "$TIMEOUT" docker run --rm --network host \
-v "$VOLUME:/data" \
-e SCHOOL_DB_PATH=/data/school.db \
-e SCHOOL_TIMEZONE="$SCHOOL_TIMEZONE" \
-e SCHOOL_HA_URL -e SCHOOL_HA_TOKEN -e SCHOOL_HA_CALENDAR_ENTITY \
-e SCHOOL_HA_INSECURE -e SCHOOL_HA_TIMEOUT \
"$IMAGE" calendar "$@"

View File

@@ -9,7 +9,7 @@
# scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto] # scripts/parse-compass.sh [--child Harry] [--run latest] [--classify auto]
# #
# Environment overrides: # Environment overrides:
# IMAGE image tag (default 0.2.0-phase3) # IMAGE image tag (default 0.4.0-phase6)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -22,7 +22,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.3.0-phase4}" IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"

View File

@@ -21,7 +21,7 @@
# scripts/scan-compass.sh --child Harry --child Finn --classify none # scripts/scan-compass.sh --child Harry --child Finn --classify none
# #
# Environment overrides: # Environment overrides:
# IMAGE image tag (default compass-teams-nhs/school:0.3.0-phase4) # IMAGE image tag (default compass-teams-nhs/school:0.4.0-phase6)
# VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data) # VOLUME SQLite volume (default compass-teams-nhs_compass-teams-nhs-data)
# RAW_ROOT host path to var/raw (default <repo>/var/raw) # RAW_ROOT host path to var/raw (default <repo>/var/raw)
# SCHOOL_TIMEZONE family timezone (default Australia/Melbourne) # SCHOOL_TIMEZONE family timezone (default Australia/Melbourne)
@@ -33,7 +33,7 @@ set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMAGE="${IMAGE:-compass-teams-nhs/school:0.3.0-phase4}" IMAGE="${IMAGE:-compass-teams-nhs/school:0.4.0-phase6}"
VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}" VOLUME="${VOLUME:-compass-teams-nhs_compass-teams-nhs-data}"
RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}" RAW_ROOT="${RAW_ROOT:-$REPO_ROOT/var/raw}"
SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}" SCHOOL_TIMEZONE="${SCHOOL_TIMEZONE:-Australia/Melbourne}"