Files
compass_teams_nhs/cmd/school/calendar.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

372 lines
10 KiB
Go

package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/url"
"os"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/calendar"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// calendarCommand is the `school calendar` entry point (AGENTS.md §11 Phase 6).
// It writes important school events to Sam's Home Assistant calendar and reads
// the calendar back. The school stays read-only.
func calendarCommand(args []string) {
if len(args) == 0 {
usage("school calendar entities | status | push [--child NAME] [--item N] [--limit N] [--dry-run] [--json] | events [--days N]")
}
switch args[0] {
case "entities":
calendarEntities(args[1:])
case "status":
calendarStatus(args[1:])
case "push":
calendarPush(args[1:])
case "events":
calendarEvents(args[1:])
default:
usage("school calendar entities | status | push | events")
}
}
// openCalendar builds the store and the Home Assistant client. The token is
// never printed: the caller only ever sees the host and the entity id.
func openCalendar(ctx context.Context) (*store.Store, *config.DBConfig, *calendar.Client, error) {
cfg, err := config.LoadDB()
if err != nil {
return nil, nil, nil, err
}
st, err := store.Open(cfg.DBPath)
if err != nil {
return nil, nil, nil, err
}
if err := st.Migrate(ctx); err != nil {
_ = st.Close()
return nil, nil, nil, err
}
client, err := calendar.New(cfg.Calendar, nil)
if err != nil {
_ = st.Close()
return nil, nil, nil, err
}
return st, cfg, client, nil
}
// calendarEntities lists the calendar entities Home Assistant reports, so the
// operator can choose SCHOOL_HA_CALENDAR_ENTITY from real values.
func calendarEntities(args []string) {
fs := flag.NewFlagSet("calendar entities", flag.ContinueOnError)
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
if err := client.Healthy(ctx); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
entities, err := client.Calendars(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL))
fmt.Printf("configured %s\n", cfg.Calendar.EntityID)
for _, e := range entities {
writable := "read-only"
if e.CanWrite() {
writable = "writable"
}
marker := " "
if e.EntityID == cfg.Calendar.EntityID {
marker = "*"
}
fmt.Printf("%s %-45s %-10s %s\n", marker, e.EntityID, writable, e.Name)
}
}
// calendarStatus shows the configured target (never the token) and the last
// recorded push, so a failure is visible outside the dashboard too (FR-12).
func calendarStatus(args []string) {
fs := flag.NewFlagSet("calendar status", flag.ContinueOnError)
var asJSON bool
fs.BoolVar(&asJSON, "json", false, "print the status as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
// A missing target is itself the status: report it, do not crash.
fmt.Fprintf(os.Stderr, "school calendar: %v\n", err)
os.Exit(2)
}
defer st.Close()
summary, err := st.CalendarPushSummary(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
now := time.Now().In(cfg.Location)
pendingEvents, err := calendar.Pending(ctx, st, calendar.Options{Now: now})
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if asJSON {
out := struct {
BaseURL string `json:"base_url"`
Entity string `json:"entity"`
TokenSet bool `json:"token_set"`
Pending int `json:"pending_events"`
ReloadedAt string `json:"checked_at"`
HomeAssist string `json:"ha_status"`
LastPush *store.CalendarPush `json:"last_push,omitempty"`
PushSummary store.CalendarPushStatus `json:"pushes"`
}{
BaseURL: haHost(cfg.Calendar.BaseURL),
Entity: cfg.Calendar.EntityID,
TokenSet: cfg.Calendar.Token != "",
Pending: pendingEvents,
ReloadedAt: now.Format(time.RFC3339),
PushSummary: summary,
}
if err := client.Healthy(ctx); err != nil {
out.HomeAssist = "unreachable: " + err.Error()
} else {
out.HomeAssist = "ok"
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(out); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
return
}
fmt.Printf("home assistant %s\n", haHost(cfg.Calendar.BaseURL))
fmt.Printf("entity %s\n", cfg.Calendar.EntityID)
fmt.Printf("token set\n")
if err := client.Healthy(ctx); err != nil {
fmt.Printf("reachable no (%v)\n", err)
} else {
fmt.Printf("reachable yes\n")
}
fmt.Printf("pending events %d (periods + due work + overdue + school events, not in the calendar yet)\n", pendingEvents)
fmt.Printf("pushes %d (%d failed)\n", summary.Total, summary.Failed)
if summary.Last != nil {
state := "ok"
if !summary.Last.OK {
state = "FAILED: " + summary.Last.Error
}
fmt.Printf("last push item %d %s %s\n", summary.Last.ItemID, summary.Last.CreatedAt.In(cfg.Location).Format("2006-01-02 15:04"), state)
} else {
fmt.Printf("last push never run\n")
}
}
// calendarPush writes the pending important events and records every attempt.
func calendarPush(args []string) {
fs := flag.NewFlagSet("calendar push", flag.ContinueOnError)
var (
child string
itemID int64
limit int
dryRun bool
asJSON bool
)
fs.StringVar(&child, "child", "", "limit to one child (Harry or Finn)")
fs.Int64Var(&itemID, "item", 0, "push exactly one stored item id")
fs.IntVar(&limit, "limit", 0, "cap how many events are written")
fs.BoolVar(&dryRun, "dry-run", false, "select and report without writing")
fs.BoolVar(&asJSON, "json", false, "print the result as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
opts := calendar.Options{Limit: limit, DryRun: dryRun, Now: time.Now().In(cfg.Location)}
if child != "" {
c, err := st.ChildByName(ctx, child)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if c == nil {
fmt.Fprintf(os.Stderr, "school calendar: unknown child %q\n", child)
os.Exit(2)
}
opts.ChildID = &c.ID
}
if itemID > 0 {
opts.ItemID = &itemID
}
res, err := calendar.Sync(ctx, st, client, opts, nil)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(res); err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
} else {
printCalendarResult(res, cfg.Location)
}
if res.Failed > 0 {
os.Exit(1)
}
}
func printCalendarResult(res calendar.Result, loc *time.Location) {
verb := "created"
if res.DryRun {
verb = "would create"
}
fmt.Printf("entity %s\n", res.Entity)
fmt.Printf("candidates %d\n", res.Candidates)
fmt.Printf("%-11s %d\n", verb, res.Created)
fmt.Printf("skipped %d (already in the calendar)\n", res.Skipped)
fmt.Printf("failed %d\n", res.Failed)
for _, ev := range res.Events {
start := ev.Start
if t, err := time.Parse(time.RFC3339, ev.Start); err == nil {
start = t.In(loc).Format("Mon 02 Jan 15:04")
}
uid := ev.UID
if uid == "" {
uid = "-"
}
kind := string(ev.Kind)
if kind == "" {
kind = "event"
}
fmt.Printf(" item %-5d %-8s %-6s %-10s %-45s uid=%s\n", ev.ItemID, kind, ev.Child, start, truncate(ev.Title, 45), uid)
}
for _, msg := range res.Errors {
fmt.Fprintf(os.Stderr, "FAILED: %s\n", msg)
}
}
// calendarEvents reads the calendar back, which is the evidence that an event
// really is in Sam's Home Assistant calendar.
func calendarEvents(args []string) {
fs := flag.NewFlagSet("calendar events", flag.ContinueOnError)
var (
days int
past int
asJSON bool
)
fs.IntVar(&days, "days", 14, "how many days ahead to read")
fs.IntVar(&past, "past", 1, "how many days back to read (overdue work sits in the past)")
fs.BoolVar(&asJSON, "json", false, "print the events as JSON")
_ = fs.Parse(args)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
st, cfg, client, err := openCalendar(ctx)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(2)
}
defer st.Close()
now := time.Now().In(cfg.Location)
start := now.AddDate(0, 0, -past)
end := now.AddDate(0, 0, days)
events, err := client.Events(ctx, start, end)
if err != nil {
fmt.Fprintln(os.Stderr, "school calendar:", err)
os.Exit(1)
}
sort.Slice(events, func(i, j int) bool {
return eventStart(events[i]).Before(eventStart(events[j]))
})
if asJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
_ = enc.Encode(events)
return
}
if len(events) == 0 {
fmt.Printf("no events on %s between %s and %s\n",
cfg.Calendar.EntityID,
start.Format("2006-01-02 15:04"),
end.Format("2006-01-02 15:04"))
return
}
for _, ev := range events {
start := eventStart(ev)
fmt.Printf("%s %-45s %s\n",
start.In(cfg.Location).Format("Mon 02 Jan 15:04"),
truncate(ev.Summary, 45),
shortMarker(ev.Description))
}
}
func eventStart(ev calendar.Event) time.Time {
if ev.Start != nil {
return *ev.Start
}
return time.Time{}
}
// shortMarker shows only the compass-nhs marker of a description, so a read-back
// stays a one-line list.
func shortMarker(desc string) string {
const open = "[compass-nhs:"
i := strings.Index(desc, open)
if i < 0 {
return ""
}
j := strings.Index(desc[i:], "]")
if j < 0 {
return ""
}
return desc[i : i+j+1]
}
// haHost prints the Home Assistant host only. The token and any query string
// stay out of the output (AGENTS.md §10 rule 3).
func haHost(base string) string {
u, err := url.Parse(base)
if err != nil || u.Host == "" {
return base
}
return u.Scheme + "://" + u.Host
}