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:
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user