Files
compass_teams_nhs/internal/calendar/sync.go
Sam Rolfe 047af0f31d Phase 6b/7b: per-child decoded periods, separate brief blocks, idempotent calendar (AUD-119)
- Parse each child's own "Today" day widget for the timetable (FR-16); drop the
  school-wide week calendar's lesson grid and clean the leaked routine rows.
- Add the editable codes map (FR-17): subjects, rooms, teachers and period
  labels, seeded, an unknown code falls back to the raw code; `school codes` CLI.
- Brief: Finn's block first, then Harry's, four sections always present (FR-9);
  periods decoded into plain English; the undated tail is one counted line (FR-18).
- Calendar: write periods + work due + work overdue + major events, per child,
  on every pass. Identity is the stable per-item key, so a re-run never re-inserts
  (FR-8). morning-brief.sh now runs scan -> calendar push -> notify.
- Day-widget labels are 12-hour: 1-6 read as PM, fixing afternoon periods.

Proven live on .13: image 0.6.2-phase6b7b; both children's briefs decoded and
separate; 35 events in calendar.school; runs 2 and 3 of the scan added nothing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-16 10:25:55 +11:00

530 lines
16 KiB
Go

package calendar
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/dashboard"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/decode"
"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) }
// Kind is the category of a calendar entry (FR-8): a routine class period, a
// piece of work due in the future, overdue work, or a major school event.
type Kind string
const (
KindPeriod Kind = "period"
KindDue Kind = "due"
KindOverdue Kind = "overdue"
KindEvent Kind = "event"
)
// 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
// From overrides the start of the window. It defaults to the start of the
// local day, so a scan that runs late in the day still records today's
// school events instead of dropping them as "past".
From time.Time
// Duration overrides DefaultDuration.
Duration time.Duration
}
// StartOfDay is midnight at the start of t's local day.
func StartOfDay(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
}
// Created is one event that the run wrote (or would write on a dry run).
type Created struct {
ItemID int64 `json:"item_id"`
Kind Kind `json:"kind"`
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"`
}
// candidate is one item selected for the calendar, with the category it belongs
// to.
type candidate struct {
store.CalendarItem
Kind Kind
}
// Sync writes every period, piece of work and school event that belongs in the
// calendar and is not there yet (FR-8):
//
// - that day's periods, per child (from the child's own day widget);
// - work due — any future date;
// - work overdue;
// - major school events.
//
// 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. Identity is the stable
// per-item key, never the date or title. 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
}
from := opts.From
if from.IsZero() {
from = StartOfDay(now)
}
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
candidates, err := buildCandidates(ctx, st, opts, from, now)
if err != nil {
return res, err
}
groups := groupCandidates(candidates)
res.Candidates = len(groups)
if len(groups) == 0 {
return res, nil
}
codes, err := st.Codes(ctx)
if err != nil {
return res, err
}
// One window covers every candidate, so the marker scan is a single call.
windowStart, windowEnd := candidateWindow(groups, from, now, duration)
existing, err := c.Events(ctx, windowStart, 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(groups)
res.Errors = append(res.Errors, msg)
for _, g := range groups {
recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, msg, log)
}
log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
return res, nil
}
known := indexByMarker(existing)
for _, g := range groups {
first := g[0]
if first.DueAt == nil {
continue
}
children := strings.Join(groupChildren(g), ", ")
start := first.DueAt.In(time.Local).Format(time.RFC3339)
if uid, ok := adopt(known, g); ok {
res.Skipped++
if !opts.DryRun {
for _, it := range g {
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err)
}
}
recordPush(ctx, st, c.Entity(), itemsOf(g), uid, true, "", log)
}
res.Events = append(res.Events, Created{
ItemID: first.ID, Kind: first.Kind, Child: children, Title: displayTitle(g, codes),
Start: start, UID: uid, Summary: "already in the calendar",
})
continue
}
created := Created{
ItemID: first.ID, Kind: first.Kind, Child: children, Title: displayTitle(g, codes),
Start: start, DryRun: opts.DryRun,
}
if opts.DryRun {
res.Created++
res.Events = append(res.Events, created)
continue
}
uid, err := c.CreateEvent(ctx, Event{
Summary: eventSummary(g, codes),
Description: eventDescription(g),
Start: first.DueAt,
End: ptrTime(first.DueAt.Add(duration)),
})
if err != nil {
res.Failed++
msg := fmt.Sprintf("item %d (%s %q): %v", first.ID, children, displayTitle(g, codes), err)
res.Errors = append(res.Errors, msg)
log.Error("calendar write failed", "item", first.ID, "child", children, "title", first.Title, "entity", c.Entity(), "err", err)
recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, err.Error(), log)
continue
}
if uid == "" {
// Home Assistant did not report an event id (the Local Calendar does
// not answer with one). Use the description marker as the item's
// reference, so the item leaves the candidate set and is never
// written twice.
log.Warn("home assistant returned no event id; using the description marker as the item reference",
"item", first.ID, "child", children, "title", first.Title, "entity", c.Entity())
}
for _, it := range g {
ref := uid
if ref == "" {
ref = Marker(it.ID)
}
if err := st.MarkItemCalendarSynced(ctx, it.ID, ref, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err)
}
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), ref, 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
}
// Pending is the number of events a run would consider, grouped so a
// school-wide event counts once. It is used by `school calendar status`.
func Pending(ctx context.Context, st *store.Store, opts Options) (int, error) {
now := opts.Now
if now.IsZero() {
now = time.Now()
}
from := opts.From
if from.IsZero() {
from = StartOfDay(now)
}
candidates, err := buildCandidates(ctx, st, opts, from, now)
if err != nil {
return 0, err
}
return len(groupCandidates(candidates)), nil
}
// buildCandidates selects every item that belongs in the calendar and tags it
// with its category. Work that is already submitted, and work with no due date,
// is never a candidate (FR-8, FR-18).
func buildCandidates(ctx context.Context, st *store.Store, opts Options, from, now time.Time) ([]candidate, error) {
var out []candidate
events, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, from, opts.Limit)
if err != nil {
return nil, err
}
for _, it := range events {
out = append(out, candidate{CalendarItem: it, Kind: KindEvent})
}
dayEnd := from.AddDate(0, 0, 1)
periods, err := st.CalendarPeriodCandidates(ctx, opts.ChildID, opts.ItemID, from, dayEnd, opts.Limit)
if err != nil {
return nil, err
}
for _, it := range periods {
out = append(out, candidate{CalendarItem: it, Kind: KindPeriod})
}
tasks, err := st.CalendarTaskCandidates(ctx, opts.ChildID, opts.ItemID, opts.Limit)
if err != nil {
return nil, err
}
for _, it := range tasks {
if dashboard.IsSubmittedStatus(dashboard.Status(it.Body)) {
continue
}
out = append(out, candidate{CalendarItem: it, Kind: workKind(it, from)})
}
return out, nil
}
// workKind splits outstanding work into overdue and due. A task is overdue when
// its due date is before today or Compass says its status is Overdue; otherwise
// it is due today or later.
func workKind(it store.CalendarItem, dayStart time.Time) Kind {
if dashboard.Status(it.Body) == "overdue" {
return KindOverdue
}
if it.DueAt != nil && it.DueAt.Before(dayStart) {
return KindOverdue
}
return KindDue
}
// groupCandidates collapses the same item stored against more than one child
// (a school-wide event) into one calendar write. The key is the source, the
// type and the stable external reference — never the date or the title.
func groupCandidates(items []candidate) [][]candidate {
index := make(map[string]int, len(items))
var out [][]candidate
for _, it := range items {
key := it.Source + "\x00" + it.Type + "\x00" + it.ExternalRef
if i, ok := index[key]; ok {
out[i] = append(out[i], it)
continue
}
index[key] = len(out)
out = append(out, []candidate{it})
}
return out
}
// candidateWindow is the Home Assistant read window that covers every candidate
// (work may be overdue since the start of term), padded by a day.
func candidateWindow(groups [][]candidate, from, now time.Time, duration time.Duration) (time.Time, time.Time) {
start := from
end := now.Add(24 * time.Hour)
for _, g := range groups {
due := g[0].DueAt
if due == nil {
continue
}
if due.Before(start) {
start = *due
}
if e := due.Add(duration + 24*time.Hour); e.After(end) {
end = e
}
}
return start.Add(-24 * time.Hour), end
}
// itemsOf returns the raw items of a group, for the push log.
func itemsOf(g []candidate) []store.CalendarItem {
out := make([]store.CalendarItem, 0, len(g))
for _, it := range g {
out = append(out, it.CalendarItem)
}
return out
}
// recordPush appends one push-log row per item in a group, so the log always
// names every child whose event was written (FR-12).
func recordPush(ctx context.Context, st *store.Store, entity string, g []store.CalendarItem, uid string, ok bool, errMsg string, log *slog.Logger) {
for _, it := range g {
if _, err := st.RecordCalendarPush(ctx, it.ID, entity, uid, ok, errMsg); err != nil {
log.Error("record calendar push", "item", it.ID, "err", err)
}
}
}
// GroupDuplicates collapses the same school event stored against more than one
// child into one calendar write. The key is the source, the type and the
// stable external reference.
func GroupDuplicates(items []store.CalendarItem) [][]store.CalendarItem {
index := make(map[string]int, len(items))
var out [][]store.CalendarItem
for _, it := range items {
key := it.Source + "\x00" + it.Type + "\x00" + it.ExternalRef
if i, ok := index[key]; ok {
out[i] = append(out[i], it)
continue
}
index[key] = len(out)
out = append(out, []store.CalendarItem{it})
}
return out
}
// adopt reports the Home Assistant event id when any item in the group already
// has its event in the calendar, so a re-run never duplicates it.
func adopt(known map[string]Event, g []candidate) (string, bool) {
for _, it := range g {
ev, ok := known[Marker(it.ID)]
if !ok {
continue
}
if ev.UID != "" {
return ev.UID, true
}
// No id from Home Assistant: keep the item out of the next candidate
// set by adopting the marker as its reference.
return Marker(it.ID), true
}
return "", false
}
// groupChildren lists the children that share one event, sorted.
func groupChildren(g []candidate) []string {
seen := make(map[string]bool, len(g))
var out []string
for _, it := range g {
if it.Child == "" || seen[it.Child] {
continue
}
seen[it.Child] = true
out = append(out, it.Child)
}
sort.Strings(out)
return out
}
// displayTitle is the one-line title used in the CLI result. A period is
// decoded into plain English (FR-17); work carries its subject.
func displayTitle(g []candidate, codes store.CodeMap) string {
first := g[0]
title := strings.TrimSpace(first.Title)
switch first.Kind {
case KindPeriod, KindEvent:
title = decode.Period(title, codes)
case KindDue, KindOverdue:
if s := strings.TrimSpace(first.Subject); s != "" && s != title {
title = s + ": " + title
}
}
return title
}
// eventSummary is the calendar entry title. A per-child entry carries the
// child's name; a school-wide event (stored against more than one child)
// carries just its title, because it is one event for the whole family.
func eventSummary(g []candidate, codes store.CodeMap) string {
title := displayTitle(g, codes)
if children := groupChildren(g); len(children) == 1 {
return children[0] + " — " + title
}
return title
}
// eventDescription carries the school detail, the Compass link and the
// idempotency markers. The markers are what make a re-run safe.
func eventDescription(g []candidate) string {
first := g[0]
var b strings.Builder
body := strings.TrimSpace(first.Body)
if body != "" && body != strings.TrimSpace(first.Title) {
b.WriteString(body)
b.WriteString("\n")
}
if first.URL != "" {
b.WriteString(first.URL)
b.WriteString("\n")
}
if first.Subject != "" {
b.WriteString("Subject: " + first.Subject + "\n")
}
b.WriteString("Kind: " + string(first.Kind) + "\n")
if children := groupChildren(g); len(children) > 1 {
b.WriteString("Children: " + strings.Join(children, ", ") + "\n")
}
b.WriteString("\n")
for _, it := range g {
b.WriteString(ItemMarker(it.ID))
b.WriteString("\n")
}
return b.String()
}
// indexByMarker maps every marker in a description to the event that carries it.
func indexByMarker(events []Event) map[string]Event {
out := make(map[string]Event, len(events))
for _, ev := range events {
for _, m := range findMarkers(ev.Description) {
out[m] = ev
}
}
return out
}
// findMarkers returns every compass-nhs marker in a text, in order.
func findMarkers(text string) []string {
var out []string
for {
m := findMarker(text)
if m == "" {
return out
}
out = append(out, m)
text = text[strings.Index(text, m)+len(m):]
}
}
// 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
}