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 }