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:
2026-10-16 10:25:55 +11:00
parent 4950755b3d
commit 047af0f31d
40 changed files with 2049 additions and 160 deletions

View File

@@ -10,6 +10,7 @@ import (
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
@@ -26,6 +27,13 @@ type Buckets struct {
// so the summary can count them and nothing is silently dropped.
Later []store.Item
Done []store.Item
// PeriodsToday is the child's own timetable for today (FR-16/FR-17).
PeriodsToday []store.Item
// EventsToday is today's major school events (concerts, excursions, exams).
EventsToday []store.Item
// FutureWork is the outstanding work due today or later. The morning brief
// lists it under "DUE IN FUTURE" (FR-9).
FutureWork []store.Item
}
// Actionable is the number of task items that still need doing.
@@ -78,6 +86,11 @@ func Classify(items []store.Item, now time.Time, loc *time.Location) Buckets {
switch {
case !due.Before(startToday) && due.Before(endToday):
b.DueToday = append(b.DueToday, it)
if isPeriodItem(it) {
b.PeriodsToday = append(b.PeriodsToday, it)
} else {
b.EventsToday = append(b.EventsToday, it)
}
case !due.Before(endToday) && due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it)
case !due.Before(endWeek):
@@ -106,14 +119,17 @@ func Classify(items []store.Item, now time.Time, loc *time.Location) Buckets {
b.Overdue = append(b.Overdue, it)
case due.Before(endToday):
b.DueToday = append(b.DueToday, it)
b.FutureWork = append(b.FutureWork, it)
case due.Before(endWeek):
b.DueWeek = append(b.DueWeek, it)
b.FutureWork = append(b.FutureWork, it)
default:
b.Later = append(b.Later, it)
b.FutureWork = append(b.FutureWork, it)
}
}
for _, s := range [][]store.Item{b.Overdue, b.DueToday, b.DueWeek, b.NoDue, b.Later, b.Done} {
for _, s := range [][]store.Item{b.Overdue, b.DueToday, b.DueWeek, b.NoDue, b.Later, b.Done, b.PeriodsToday, b.EventsToday, b.FutureWork} {
sort.SliceStable(s, func(i, j int) bool { return less(s[i], s[j]) })
}
sort.SliceStable(b.Messages, func(i, j int) bool {
@@ -171,8 +187,20 @@ var submittedStatuses = map[string]bool{
"completed": true,
}
// IsSubmittedStatus reports whether a task status means the work no longer
// needs doing, so it is excluded from the calendar's work lists.
func IsSubmittedStatus(status string) bool { return isSubmitted(status) }
func isSubmitted(status string) bool { return submittedStatuses[status] }
// isPeriodItem reports whether an event is a routine class period from the
// child's own timetable rather than a school event. Compass writes a lesson as
// "1 - 7SCF - H14LAB - KNI" (FR-16/FR-17).
func isPeriodItem(it store.Item) bool {
parts := period.SplitDash(it.Title)
return len(parts) > 0 && period.IsPeriodLabel(parts[0])
}
func startOfDay(t time.Time, loc *time.Location) time.Time {
t = t.In(loc)
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
@@ -363,3 +391,113 @@ func line(it store.Item, loc *time.Location) string {
}
return title + due + mark
}
// CombinedBrief is one child's block of the combined brief, in the required
// order: Finn's block first, then Harry's (FR-9).
type CombinedBrief struct {
Child string
Buckets Buckets
}
// RenderBrief writes one child's block in the required Phase 7 shape (FR-9,
// FR-16, FR-17, FR-18):
//
// === FINN ===
// PERIODS TODAY
// EVENTS TODAY
// OVERDUE
// DUE IN FUTURE
//
// An empty section is printed with "none"; it is never omitted silently.
// decode turns a raw lesson title into plain English (FR-17); pass nil to
// render the raw codes.
func RenderBrief(child string, b Buckets, decode func(string) string, loc *time.Location) string {
if loc == nil {
loc = time.UTC
}
var sb strings.Builder
fmt.Fprintf(&sb, "=== %s ===\n", strings.ToUpper(strings.TrimSpace(child)))
writeBriefPeriods(&sb, b.PeriodsToday, decode, loc)
writeBriefEvents(&sb, b.EventsToday, loc)
writeBriefWork(&sb, "OVERDUE", b.Overdue, loc)
writeBriefFuture(&sb, b, loc)
return sb.String()
}
// RenderCombinedBrief writes the single brief for Jo and Sam (FR-14) as two
// separate blocks — Finn first, then Harry — and never interleaves them (FR-9).
func RenderCombinedBrief(parts []CombinedBrief, decode func(string) string, loc *time.Location) string {
blocks := make([]string, 0, len(parts))
for _, p := range parts {
blocks = append(blocks, RenderBrief(p.Child, p.Buckets, decode, loc))
}
return strings.Join(blocks, "\n")
}
// writeBriefPeriods lists the child's own periods for today, decoded (FR-17).
func writeBriefPeriods(sb *strings.Builder, items []store.Item, decode func(string) string, loc *time.Location) {
sb.WriteString("\nPERIODS TODAY\n")
if len(items) == 0 {
sb.WriteString("- none\n")
return
}
for _, it := range items {
title := strings.TrimSpace(it.Title)
if decode != nil {
title = decode(title)
}
when := ""
if it.DueAt != nil {
when = " (" + it.DueAt.In(loc).Format("15:04") + ")"
}
fmt.Fprintf(sb, "- %s%s\n", title, when)
}
}
// writeBriefEvents lists today's major school events (FR-9b).
func writeBriefEvents(sb *strings.Builder, items []store.Item, loc *time.Location) {
sb.WriteString("\nEVENTS TODAY\n")
if len(items) == 0 {
sb.WriteString("- none\n")
return
}
for _, it := range items {
when := ""
if it.DueAt != nil {
when = " (" + it.DueAt.In(loc).Format("15:04") + ")"
}
fmt.Fprintf(sb, "- %s%s\n", strings.TrimSpace(it.Title), when)
}
}
// writeBriefWork lists outstanding work (FR-9c).
func writeBriefWork(sb *strings.Builder, title string, items []store.Item, loc *time.Location) {
fmt.Fprintf(sb, "\n%s\n", title)
if len(items) == 0 {
sb.WriteString("- none\n")
return
}
for _, it := range items {
fmt.Fprintf(sb, "- %s\n", line(it, loc))
}
}
// writeBriefFuture lists work due today or later (FR-9d). The undated tail is
// never dumped: it is one line with a count and a pointer to the dashboard
// (FR-18).
func writeBriefFuture(sb *strings.Builder, b Buckets, loc *time.Location) {
sb.WriteString("\nDUE IN FUTURE\n")
wrote := false
for _, it := range b.FutureWork {
fmt.Fprintf(sb, "- %s\n", line(it, loc))
wrote = true
}
if n := len(b.NoDue); n > 0 {
fmt.Fprintf(sb, "- %d task(s) with no due date (see the dashboard)\n", n)
wrote = true
}
if !wrote {
sb.WriteString("- none\n")
}
}