Files
compass_teams_nhs/internal/calendar/sync.go
Sam Rolfe f6be17fe4f 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>
2026-10-15 18:01:30 +11:00

276 lines
8.4 KiB
Go

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
}