The first live run against the new calendar.school entity failed loudly twice, and both causes are fixed here. - The Local Calendar rejects `return_response` on calendar.create_event. The client retries the call without it and ignores the changed-states array that Home Assistant returns instead. - Home Assistant then reports no event id. The item keeps the description marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set and is never written twice. The live retry proved it: both events already in the calendar were adopted by their markers, with no duplicate. - calendar.get_events reports `start` / `end` (a date-time or a bare date), not `start_date_time`; the reader now accepts all three shapes. A model must no longer classify events. The calendar write depends on the school-event rule, and the LLM labelled every event "normal"; events now keep the deterministic rule under both classifiers. Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan` class, so select each banner by its labelled section. Docs record the accepted decision: Sam chose a Home Assistant Local Calendar, so the target is calendar.school. Co-Authored-By: Paperclip <noreply@paperclip.ing>
373 lines
11 KiB
Go
373 lines
11 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
|
|
// 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"`
|
|
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
|
|
}
|
|
from := opts.From
|
|
if from.IsZero() {
|
|
from = StartOfDay(now)
|
|
}
|
|
|
|
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
|
|
|
|
candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, from, opts.Limit)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
// The Compass week calendar is school-wide, so the same event is stored
|
|
// against each child. Write it once, and keep every child's item in step.
|
|
groups := GroupDuplicates(candidates)
|
|
res.Candidates = len(groups)
|
|
if len(groups) == 0 {
|
|
return res, nil
|
|
}
|
|
|
|
// One window covers every candidate, so the marker scan is a single call.
|
|
windowEnd := now.Add(24 * time.Hour)
|
|
for _, g := range groups {
|
|
if due := g[0].DueAt; due != nil && due.After(windowEnd) {
|
|
windowEnd = due.Add(24 * time.Hour)
|
|
}
|
|
}
|
|
existing, err := c.Events(ctx, from.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(groups)
|
|
res.Errors = append(res.Errors, msg)
|
|
for _, g := range groups {
|
|
recordPush(ctx, st, c.Entity(), 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(), g, uid, true, "", log)
|
|
}
|
|
res.Events = append(res.Events, Created{
|
|
ItemID: first.ID, Child: children, Title: first.Title,
|
|
Start: start, UID: uid, Summary: "already in the calendar",
|
|
})
|
|
continue
|
|
}
|
|
|
|
created := Created{
|
|
ItemID: first.ID, Child: children, Title: first.Title,
|
|
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),
|
|
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, first.Title, 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(), 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
|
|
}
|
|
|
|
// 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 []store.CalendarItem) (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 []store.CalendarItem) []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
|
|
}
|
|
|
|
// eventSummary is the calendar entry title. A per-child event 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 []store.CalendarItem) string {
|
|
title := strings.TrimSpace(g[0].Title)
|
|
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 []store.CalendarItem) 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")
|
|
}
|
|
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
|
|
}
|