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>
This commit is contained in:
@@ -9,6 +9,8 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,17 @@ func Marker(itemID int64) string {
|
||||
// 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).
|
||||
@@ -55,6 +68,7 @@ func StartOfDay(t time.Time) time.Time {
|
||||
// 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"`
|
||||
@@ -76,13 +90,27 @@ type Result struct {
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
}
|
||||
|
||||
// Sync pushes every important school event that is not in the calendar yet.
|
||||
// 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. Every attempt is recorded
|
||||
// in the append-only calendar_pushes table, so a failure is visible and loud
|
||||
// rather than swallowed (FR-12).
|
||||
// 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")
|
||||
@@ -108,26 +136,24 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
|
||||
|
||||
res := Result{Entity: c.Entity(), DryRun: opts.DryRun}
|
||||
|
||||
candidates, err := st.CalendarCandidates(ctx, opts.ChildID, opts.ItemID, from, opts.Limit)
|
||||
candidates, err := buildCandidates(ctx, st, opts, from, now)
|
||||
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)
|
||||
groups := groupCandidates(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)
|
||||
}
|
||||
codes, err := st.Codes(ctx)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
existing, err := c.Events(ctx, from.Add(-24*time.Hour), windowEnd)
|
||||
|
||||
// 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.
|
||||
@@ -135,7 +161,7 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
|
||||
res.Failed = len(groups)
|
||||
res.Errors = append(res.Errors, msg)
|
||||
for _, g := range groups {
|
||||
recordPush(ctx, st, c.Entity(), g, "", false, msg, log)
|
||||
recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, msg, log)
|
||||
}
|
||||
log.Error("calendar sync aborted", "entity", c.Entity(), "err", err)
|
||||
return res, nil
|
||||
@@ -158,17 +184,17 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
|
||||
log.Error("mark calendar item", "item", it.ID, "err", err)
|
||||
}
|
||||
}
|
||||
recordPush(ctx, st, c.Entity(), g, uid, true, "", log)
|
||||
recordPush(ctx, st, c.Entity(), itemsOf(g), uid, true, "", log)
|
||||
}
|
||||
res.Events = append(res.Events, Created{
|
||||
ItemID: first.ID, Child: children, Title: first.Title,
|
||||
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, Child: children, Title: first.Title,
|
||||
ItemID: first.ID, Kind: first.Kind, Child: children, Title: displayTitle(g, codes),
|
||||
Start: start, DryRun: opts.DryRun,
|
||||
}
|
||||
if opts.DryRun {
|
||||
@@ -178,17 +204,17 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
|
||||
}
|
||||
|
||||
uid, err := c.CreateEvent(ctx, Event{
|
||||
Summary: eventSummary(g),
|
||||
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, first.Title, err)
|
||||
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(), g, "", false, err.Error(), log)
|
||||
recordPush(ctx, st, c.Entity(), itemsOf(g), "", false, err.Error(), log)
|
||||
continue
|
||||
}
|
||||
if uid == "" {
|
||||
@@ -220,6 +246,120 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
|
||||
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) {
|
||||
@@ -250,7 +390,7 @@ func GroupDuplicates(items []store.CalendarItem) [][]store.CalendarItem {
|
||||
|
||||
// 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) {
|
||||
func adopt(known map[string]Event, g []candidate) (string, bool) {
|
||||
for _, it := range g {
|
||||
ev, ok := known[Marker(it.ID)]
|
||||
if !ok {
|
||||
@@ -267,7 +407,7 @@ func adopt(known map[string]Event, g []store.CalendarItem) (string, bool) {
|
||||
}
|
||||
|
||||
// groupChildren lists the children that share one event, sorted.
|
||||
func groupChildren(g []store.CalendarItem) []string {
|
||||
func groupChildren(g []candidate) []string {
|
||||
seen := make(map[string]bool, len(g))
|
||||
var out []string
|
||||
for _, it := range g {
|
||||
@@ -281,11 +421,27 @@ func groupChildren(g []store.CalendarItem) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// eventSummary is the calendar entry title. A per-child event carries the
|
||||
// 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 []store.CalendarItem) string {
|
||||
title := strings.TrimSpace(g[0].Title)
|
||||
func eventSummary(g []candidate, codes store.CodeMap) string {
|
||||
title := displayTitle(g, codes)
|
||||
if children := groupChildren(g); len(children) == 1 {
|
||||
return children[0] + " — " + title
|
||||
}
|
||||
@@ -294,7 +450,7 @@ func eventSummary(g []store.CalendarItem) string {
|
||||
|
||||
// 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 {
|
||||
func eventDescription(g []candidate) string {
|
||||
first := g[0]
|
||||
var b strings.Builder
|
||||
body := strings.TrimSpace(first.Body)
|
||||
@@ -309,6 +465,7 @@ func eventDescription(g []store.CalendarItem) string {
|
||||
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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user