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 }