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

@@ -260,3 +260,87 @@ func TestRenderWithLimitsKeepsTheBriefBrief(t *testing.T) {
t.Errorf("the full render must keep every item:\n%s", full)
}
}
// TestRenderBriefShape proves the required Phase 7 shape (FR-9, FR-17, FR-18):
// the four headings are always present, empty sections say "none", periods are
// decoded, and the undated tail is one counted line.
func TestRenderBriefShape(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
items := []store.Item{
{Type: "event", Title: "1 - 7SCF - H14LAB - KNI", DueAt: due(t, loc, "2026-10-16 08:55")},
{Type: "event", Title: "End of Year Concert - HALL", DueAt: due(t, loc, "2026-10-16 18:00")},
{Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
{Type: "task", Subject: "Maths", Title: "Exercise 4", DueAt: due(t, loc, "2026-10-20 09:00"), Body: "Status: Pending"},
}
for i := 0; i < 7; i++ {
items = append(items, store.Item{Type: "task", Subject: "English", Title: "Task", Body: "Status: Pending"})
}
b := Classify(items, now, loc)
text := RenderBrief("Finn", b, func(s string) string {
if s == "1 - 7SCF - H14LAB - KNI" {
return "Period 1 — Year 7 Science — Room H14LAB — Mr KNI"
}
return s
}, loc)
for _, want := range []string{
"=== FINN ===",
"PERIODS TODAY",
"Period 1 — Year 7 Science — Room H14LAB — Mr KNI (08:55)",
"EVENTS TODAY",
"End of Year Concert - HALL (18:00)",
"OVERDUE",
"Science: Lab report",
"DUE IN FUTURE",
"Maths: Exercise 4",
"7 task(s) with no due date (see the dashboard)",
} {
if !strings.Contains(text, want) {
t.Errorf("brief is missing %q:\n%s", want, text)
}
}
// The undated dump is not listed item by item (FR-18).
if strings.Count(text, "English: Task") != 0 {
t.Errorf("the undated tail was dumped:\n%s", text)
}
}
// TestRenderBriefEmptySectionsSayNone proves an empty section is printed, never
// omitted silently.
func TestRenderBriefEmptySectionsSayNone(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
text := RenderBrief("Harry", Classify(nil, now, loc), nil, loc)
if strings.Count(text, "none") != 4 {
t.Errorf("want the four headings each to say none:\n%s", text)
}
}
// TestRenderCombinedBriefKeepsChildrenSeparate proves FR-9: Finn's block is
// first, Harry's second, and the two are never interleaved.
func TestRenderCombinedBriefKeepsChildrenSeparate(t *testing.T) {
loc := sydney(t)
now := time.Date(2026, 10, 16, 6, 0, 0, 0, loc)
finn := Classify([]store.Item{
{Type: "task", Subject: "Science", Title: "Lab report", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
}, now, loc)
harry := Classify([]store.Item{
{Type: "task", Subject: "English", Title: "Essay", DueAt: due(t, loc, "2026-10-10 09:00"), Body: "Status: Overdue"},
}, now, loc)
text := RenderCombinedBrief([]CombinedBrief{{Child: "Finn", Buckets: finn}, {Child: "Harry", Buckets: harry}}, nil, loc)
i, j := strings.Index(text, "=== FINN ==="), strings.Index(text, "=== HARRY ===")
if i < 0 || j < 0 || i > j {
t.Fatalf("blocks are not Finn then Harry:\n%s", text)
}
finnBlock, harryBlock := text[i:j], text[j:]
if !strings.Contains(finnBlock, "Science: Lab report") || strings.Contains(finnBlock, "English: Essay") {
t.Errorf("Finn's block is wrong or interleaved:\n%s", finnBlock)
}
if !strings.Contains(harryBlock, "English: Essay") || strings.Contains(harryBlock, "Science: Lab report") {
t.Errorf("Harry's block is wrong or interleaved:\n%s", harryBlock)
}
}