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

@@ -15,12 +15,14 @@ import (
"context"
"fmt"
"log/slog"
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/apprise"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/config"
"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"
)
@@ -119,7 +121,11 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string,
return "", false, fmt.Errorf("items for %s: %w", child.Name, err)
}
buckets := dashboard.Classify(items, s.Now(), s.Loc)
text := dashboard.RenderWithLimits(child.Name, buckets, s.Loc, dashboard.MorningLimits)
dec, err := s.decoder(ctx)
if err != nil {
return "", false, err
}
text := dashboard.RenderBrief(child.Name, buckets, dec, s.Loc)
sc, err := s.Store.ScanSummaryForChild(ctx, child.ID)
if err != nil {
@@ -132,18 +138,32 @@ func (s *Service) ChildrenText(ctx context.Context, child store.Child) (string,
return notice + "\n" + text, false, nil
}
// decoder loads the editable codes map and returns the lesson-title decoder the
// brief uses (FR-17). An unknown code renders raw, never blank.
func (s *Service) decoder(ctx context.Context) (func(string) string, error) {
codes, err := s.Store.Codes(ctx)
if err != nil {
return nil, fmt.Errorf("decode map: %w", err)
}
return func(title string) string { return decode.Period(title, codes) }, nil
}
// CombinedText returns the single brief for Jo and Sam — both boys in one
// message — and the children whose scan did not complete.
func (s *Service) CombinedText(ctx context.Context, children []store.Child) (string, []string, error) {
parts := make([]dashboard.Combined, 0, len(children))
parts := make([]dashboard.CombinedBrief, 0, len(children))
var failed []string
var notices []string
dec, err := s.decoder(ctx)
if err != nil {
return "", nil, err
}
for _, c := range children {
items, err := s.Store.ItemsForChild(ctx, c.ID)
if err != nil {
return "", nil, fmt.Errorf("items for %s: %w", c.Name, err)
}
parts = append(parts, dashboard.Combined{
parts = append(parts, dashboard.CombinedBrief{
Child: c.Name,
Buckets: dashboard.Classify(items, s.Now(), s.Loc),
})
@@ -156,7 +176,7 @@ func (s *Service) CombinedText(ctx context.Context, children []store.Child) (str
failed = append(failed, c.Name)
}
}
text := dashboard.RenderCombinedWithLimits(parts, s.Loc, dashboard.MorningLimits)
text := dashboard.RenderCombinedBrief(parts, dec, s.Loc)
if len(notices) > 0 {
text = strings.Join(notices, "\n") + "\n\n" + text
}
@@ -328,6 +348,8 @@ func (s *Service) plan(ctx context.Context) ([]planned, error) {
if len(children) == 0 {
return nil, fmt.Errorf("no children on file: nothing to brief")
}
// Finn's block first, then Harry's: the required order (FR-9).
children = OrderChildren(children)
var out []planned
for _, c := range children {
@@ -379,6 +401,15 @@ func (s *Service) plan(ctx context.Context) ([]planned, error) {
return out, nil
}
// OrderChildren returns the children in brief order: Finn's block first, then
// Harry's (FR-9). The order is by name, which puts Finn before Harry; it is
// shared by the send path and the dry-run build so both agree.
func OrderChildren(children []store.Child) []store.Child {
out := append([]store.Child(nil), children...)
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func combinedNames(children []store.Child) string {
names := make([]string, 0, len(children))
for _, c := range children {

View File

@@ -148,16 +148,20 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) {
t.Fatalf("morning: %v", err)
}
if len(sends) != 3 {
t.Fatalf("sends = %d, want Harry, Finn and combined", len(sends))
t.Fatalf("sends = %d, want Finn, Harry and combined", len(sends))
}
for _, s := range sends {
if !s.OK() {
t.Errorf("send %s not OK: %+v", s.Scope, s)
}
}
// Finn's block comes first, then Harry's (FR-9).
if sends[0].Scope != "Finn" || sends[1].Scope != "Harry" {
t.Fatalf("send order = %s, %s; want Finn then Harry", sends[0].Scope, sends[1].Scope)
}
// Harry: 1 recipient x 2 channels. Combined: 2 recipients x 2 channels.
if got := len(sends[0].Deliveries); got != 2 {
if got := len(sends[1].Deliveries); got != 2 {
t.Errorf("Harry deliveries = %d, want 2", got)
}
combined := sends[2]
@@ -193,10 +197,16 @@ func TestMorningSendsPerBoyAndCombined(t *testing.T) {
}
}
if !strings.Contains(combined.Text, "OVERDUE") ||
!strings.Contains(combined.Text, "Harry: English: Essay") ||
!strings.Contains(combined.Text, "Finn: Science: Lab report") {
!strings.Contains(combined.Text, "=== FINN ===") ||
!strings.Contains(combined.Text, "=== HARRY ===") ||
!strings.Contains(combined.Text, "Science: Lab report") ||
!strings.Contains(combined.Text, "English: Essay") {
t.Errorf("combined text = %q", combined.Text)
}
// Finn's block is first and the two children are never interleaved.
if i, j := strings.Index(combined.Text, "=== FINN ==="), strings.Index(combined.Text, "=== HARRY ==="); i < 0 || j < 0 || i > j {
t.Errorf("combined blocks are not Finn then Harry: %q", combined.Text)
}
rows, err := st.RecentNotifications(context.Background(), 20)
if err != nil {
@@ -278,19 +288,19 @@ func TestFailedScanIsNeverNothingDue(t *testing.T) {
if err != nil {
t.Fatalf("morning: %v", err)
}
if len(sends[0].ScanFailed) != 1 || sends[0].ScanFailed[0] != "Harry" {
t.Errorf("Harry scan failure not reported: %+v", sends[0].ScanFailed)
if len(sends[1].ScanFailed) != 1 || sends[1].ScanFailed[0] != "Harry" {
t.Errorf("Harry scan failure not reported: %+v", sends[1].ScanFailed)
}
if !strings.Contains(sends[0].Text, "SCAN FAILED") {
t.Errorf("brief does not say the scan failed: %q", sends[0].Text)
if !strings.Contains(sends[1].Text, "SCAN FAILED") {
t.Errorf("brief does not say the scan failed: %q", sends[1].Text)
}
if !strings.Contains(sends[0].Text, "UNKNOWN") {
t.Errorf("brief does not say due work is unknown: %q", sends[0].Text)
if !strings.Contains(sends[1].Text, "UNKNOWN") {
t.Errorf("brief does not say due work is unknown: %q", sends[1].Text)
}
if !strings.Contains(sends[2].Text, "SCAN FAILED") {
t.Errorf("combined brief does not carry the failure: %q", sends[2].Text)
}
if sends[0].OK() {
if sends[1].OK() {
t.Error("a scan failure must make the run not OK")
}
}

View File

@@ -156,8 +156,8 @@ type Event struct {
Summary string `json:"summary"`
Description string `json:"description,omitempty"`
Location string `json:"location,omitempty"`
Start *time.Time `json:"-"`
End *time.Time `json:"-"`
Start *time.Time `json:"start,omitempty"`
End *time.Time `json:"end,omitempty"`
StartRaw string `json:"-"`
EndRaw string `json:"-"`
}

View File

@@ -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")
}

View File

@@ -330,3 +330,85 @@ func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}
// seedCalendarItem stores one item and returns it.
func seedCalendarItem(t *testing.T, st *store.Store, childID int64, item store.Item) store.Item {
t.Helper()
if _, _, err := st.UpsertItems(context.Background(), childID, []store.Item{item}); err != nil {
t.Fatalf("upsert: %v", err)
}
items, err := st.ItemsForChild(context.Background(), childID)
if err != nil {
t.Fatalf("items: %v", err)
}
for _, it := range items {
if it.ExternalRef == item.ExternalRef {
return it
}
}
t.Fatalf("seeded item %q not found", item.ExternalRef)
return store.Item{}
}
// TestSyncWritesPeriodsWorkAndOverdueOnce proves FR-8: a child's own periods,
// the work due in the future and the overdue work all reach the calendar, and a
// re-run adds nothing. Submitted work is never written.
func TestSyncWritesPeriodsWorkAndOverdueOnce(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
loc := time.Local
now := StartOfDay(time.Now().In(loc)).Add(6 * time.Hour)
period := now.Add(2 * time.Hour).Truncate(time.Minute)
overdue := now.AddDate(0, 0, -3).Truncate(time.Minute)
future := now.AddDate(0, 0, 3).Truncate(time.Minute)
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "event", Subject: "7SCF",
Title: "1 - 7SCF - H14LAB - KNI", DueAt: &period,
SourcePage: "child-profile", Importance: "normal", ExternalRef: "period-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "Science", Title: "Lab report",
Body: "Status: Overdue", DueAt: &overdue, Importance: "high", ExternalRef: "overdue-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "Maths", Title: "Exercise 4",
Body: "Status: Pending", DueAt: &future, Importance: "normal", ExternalRef: "due-1",
})
seedCalendarItem(t, st, childID, store.Item{
Source: "compass", Type: "task", Subject: "English", Title: "Done essay",
Body: "Status: On Time", DueAt: &overdue, Importance: "normal", ExternalRef: "submitted-1",
})
ha := newFakeHA(t, "calendar.school")
c := testClient(t, ha.URL, "calendar.school")
res, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if res.Created != 3 || res.Failed != 0 {
t.Fatalf("first result = %+v", res)
}
if ha.created() != 3 {
t.Fatalf("first Sync made %d create calls, want 3", ha.created())
}
kinds := map[Kind]int{}
for _, ev := range res.Events {
kinds[ev.Kind]++
}
if kinds[KindPeriod] != 1 || kinds[KindOverdue] != 1 || kinds[KindDue] != 1 {
t.Errorf("kinds = %+v, want one period, one overdue and one due", kinds)
}
second, err := Sync(ctx, st, c, Options{Now: now}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Created != 0 || second.Candidates != 0 {
t.Fatalf("second result = %+v, want nothing new", second)
}
if ha.created() != 3 {
t.Fatalf("re-run duplicated events (%d create calls)", ha.created())
}
}

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")
}
}

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)
}
}

84
internal/decode/decode.go Normal file
View File

@@ -0,0 +1,84 @@
// Package decode turns a raw Compass lesson label into plain English using the
// editable codes map (AGENTS.md §4 FR-17). An unknown code renders as the raw
// code, never blank.
package decode
import (
"strings"
"unicode"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
// Period renders one lesson title as plain English, for example
//
// "1 - 7SCF - H14LAB - KNI" -> "Period 1 — Year 7 Science — Room H14LAB — Mr KNI"
//
// A title that is not a lesson (a concert, an excursion) is returned unchanged.
// A code that is not in the map falls back to the raw code (FR-17).
func Period(title string, m store.CodeMap) string {
slot, subject, room, teacher, ok := period.Split(strings.TrimSpace(title))
if !ok {
return strings.TrimSpace(title)
}
parts := make([]string, 0, 4)
parts = append(parts, periodName(slot, m))
if subject != "" {
parts = append(parts, codeName(m, store.CodeSubject, subject))
}
if room != "" {
parts = append(parts, codeName(m, store.CodeRoom, room))
}
if teacher != "" {
parts = append(parts, teacherName(m, teacher))
}
return strings.Join(parts, " — ")
}
// teacherName decodes a teacher field. Compass sometimes carries several codes
// in the one field ("GT SMH"), so every token is decoded and joined.
func teacherName(m store.CodeMap, field string) string {
tokens := strings.Fields(field)
if len(tokens) <= 1 {
return codeName(m, store.CodeTeacher, field)
}
out := make([]string, 0, len(tokens))
for _, tok := range tokens {
out = append(out, codeName(m, store.CodeTeacher, tok))
}
return strings.Join(out, " / ")
}
// periodName renders the period slot: a word label such as "C" uses the map
// ("Connect"), a number becomes "Period 3", and anything unknown stays raw.
func periodName(slot string, m store.CodeMap) string {
if name := strings.TrimSpace(m.Lookup(store.CodePeriod, slot)); name != "" {
return name
}
if isNumber(slot) {
return "Period " + slot
}
return slot
}
// codeName returns the mapped name, or the raw code when it is not mapped.
func codeName(m store.CodeMap, kind, code string) string {
if name := strings.TrimSpace(m.Lookup(kind, code)); name != "" {
return name
}
return code
}
func isNumber(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if !unicode.IsDigit(r) {
return false
}
}
return true
}

View File

@@ -0,0 +1,52 @@
package decode
import (
"testing"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/store"
)
func testCodes() store.CodeMap {
return store.CodeMap{
Subject: map[string]string{"7SCF": "Year 7 Science", "11MGMAC2": "Year 11 General Maths"},
Room: map[string]string{"H14LAB": "Room H14LAB", "B11": "Room B11"},
Teacher: map[string]string{"KNI": "Mr KNI", "GT": "Mr GT"},
Period: map[string]string{"C": "Connect"},
}
}
// TestPeriodDecodesKnownCodes proves FR-17: a lesson reads as plain English.
func TestPeriodDecodesKnownCodes(t *testing.T) {
cases := map[string]string{
"1 - 7SCF - H14LAB - KNI": "Period 1 — Year 7 Science — Room H14LAB — Mr KNI",
"3 - 11MGMAC2 - B11 - GT": "Period 3 — Year 11 General Maths — Room B11 — Mr GT",
"C - 11MGMAC2 - B11 - GT": "Connect — Year 11 General Maths — Room B11 — Mr GT",
"RLE GROUP D - IM1 - RLE": "RLE GROUP D — IM1 — RLE",
"5 - 11HLEGB5 - D8": "Period 5 — 11HLEGB5 — D8",
"2 - 11HLEGB5": "Period 2 — 11HLEGB5",
}
for raw, want := range cases {
if got := Period(raw, testCodes()); got != want {
t.Errorf("Period(%q) = %q, want %q", raw, got, want)
}
}
}
// TestPeriodUnknownCodesRenderRaw proves the fallback: a code that is not in
// the editable map renders as the raw code, never blank (FR-17).
func TestPeriodUnknownCodesRenderRaw(t *testing.T) {
got := Period("1 - 7ZZZ - Z99 - ZZZ", testCodes())
want := "Period 1 — 7ZZZ — Z99 — ZZZ"
if got != want {
t.Errorf("Period = %q, want %q", got, want)
}
}
// TestPeriodLeavesEventsAlone proves a school event is not mangled by the
// lesson decoder.
func TestPeriodLeavesEventsAlone(t *testing.T) {
raw := "End of Year Senior Music Concert - HALL - KSH, CFR"
if got := Period(raw, testCodes()); got != raw {
t.Errorf("Period = %q, want the event unchanged", got)
}
}

View File

@@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
)
// calendarPxPerHour is the Compass week-grid scale: the day gutter is 1200px
@@ -21,13 +23,12 @@ var (
calendarTop = regexp.MustCompile(`top:\s*([0-9.]+)px`)
// clockPrefix matches the leading "8:55: " of an event label.
clockPrefix = regexp.MustCompile(`^(\d{1,2}):(\d{2}):\s*(.*)$`)
// periodLabel matches the period part of a lesson label: "5", "C" or
// "RLE GROUP D".
periodLabel = regexp.MustCompile(`^(\d{1,2}|[A-Z]|RLE GROUP [A-Z])$`)
)
// parseCalendar parses the week calendar into event items. It fails loudly when
// the page has no calendar grid (FR-12).
// parseCalendar parses the week calendar page. The week calendar is
// school-wide and its lesson grid belongs to the eldest child, so routine
// lessons are dropped here: each child's own periods come from that child's
// day widget (FR-16). Only real school events are kept.
func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error) {
items, found, err := extractCalendar(child, page, loc)
if err != nil {
@@ -36,7 +37,13 @@ func parseCalendar(child string, page Page, loc *time.Location) ([]Item, error)
if !found {
return nil, fmt.Errorf("parse: %s: no calendar grid found in %s (layout changed or fetch failed)", child, page.Slug)
}
return items, nil
out := items[:0]
for _, it := range items {
if eventIsSchoolEvent(it.Title) {
out = append(out, it)
}
}
return out, nil
}
// extractCalendar returns the calendar events in one page. found reports whether
@@ -140,8 +147,8 @@ func eventItem(child string, ev *node, page Page, loc *time.Location) (Item, boo
// label is "5 - 11HLEGB5 - D8 - LHT"; a school event is
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR, ...".
func splitEvent(label, title string) (subject, teacher, body string) {
parts := splitDash(title)
if len(parts) >= 2 && periodLabel.MatchString(parts[0]) {
parts := period.SplitDash(title)
if len(parts) >= 2 && period.IsPeriodLabel(parts[0]) {
for _, p := range parts[1:] {
if subject == "" && classCode.MatchString(p) {
subject = p
@@ -159,11 +166,4 @@ func splitEvent(label, title string) (subject, teacher, body string) {
}
// splitDash splits on " - " and trims each part.
func splitDash(s string) []string {
raw := strings.Split(s, " - ")
out := make([]string, 0, len(raw))
for _, p := range raw {
out = append(out, strings.TrimSpace(p))
}
return out
}
func splitDash(s string) []string { return period.SplitDash(s) }

114
internal/parse/daywidget.go Normal file
View File

@@ -0,0 +1,114 @@
package parse
import (
"fmt"
"strings"
"time"
)
// This file parses the per-child day widget that Compass renders on a child's
// own pages ("/Records/User.aspx?userId=..." and the Learning Tasks page). The
// widget is labelled "Today" and its events are that child's own periods:
//
// 8:55: 1 - 7SCF - H14LAB - KNI
//
// The week calendar is school-wide (it carries the eldest child's lesson grid),
// so it must never be the source of a child's timetable (AGENTS.md §4 FR-16).
// This is the per-child source.
// dayWidgetBaseHour is unused: the widget's grid start moves with the day's
// first event (a page whose first event is 7:45 starts at 07:00, one whose
// first event is 8:55 starts at 06:00), so the printed clock is the reliable
// source and the grid offset is not.
// parseDayWidget returns the periods in one child's day widget. found reports
// whether the page carried a day widget at all.
func parseDayWidget(child string, page Page, loc *time.Location, ref time.Time) (items []Item, found bool, err error) {
root := parseHTML(page.HTML)
events := findAll(root, func(n *node) bool {
return n.Tag == "div" && n.hasClassPart("day-bd-evt-") && n.hasClassPart("calendardaywidget-")
})
if len(events) == 0 {
return nil, false, nil
}
day := ref.In(loc)
if day.IsZero() {
day = time.Now().In(loc)
}
date := time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, loc)
for _, ev := range events {
labelSpan := find(ev, func(n *node) bool { return n.hasClass("ext-evt-bd") })
if labelSpan == nil {
continue
}
label := collapse(labelSpan.Text())
if label == "" {
continue
}
cm := clockPrefix.FindStringSubmatch(label)
if cm == nil {
return nil, true, fmt.Errorf("parse: %s: day-widget event %q has no start time", page.Slug, label)
}
at, err := dayWidgetTime(cm, date)
if err != nil {
return nil, true, fmt.Errorf("parse: %s: day-widget event %q: %w", page.Slug, label, err)
}
title := strings.TrimSpace(cm[3])
if title == "" {
title = label
}
subject, teacher, body := splitEvent(label, title)
url := page.URL
if url == "" {
url = compassHost + "/Organise/Calendar/"
}
items = append(items, Item{
Child: child,
Source: SourceCompass,
Type: TypeEvent,
Subject: subject,
Teacher: teacher,
Title: title,
Body: body,
DueAt: &at,
URL: url,
SourcePage: page.Slug,
ExternalRef: dedupeKey("day-period", at.Format(time.RFC3339), title),
Importance: importanceNormal,
})
}
return items, true, nil
}
// dayWidgetTime reads an event's start from the printed clock. Compass prints a
// 12-hour clock with no AM/PM marker ("1:26" is the afternoon period), so the
// school-day convention applies: 1-6 are PM, 7-11 are AM, 12 is noon.
func dayWidgetTime(clock []string, date time.Time) (time.Time, error) {
hour, minute := atoiSafe(clock[1]), atoiSafe(clock[2])
if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
return time.Time{}, fmt.Errorf("bad time %02d:%02d", hour, minute)
}
if hour >= 1 && hour <= 6 { // 1:26 means 1:26 PM in the Compass day grid
hour += 12
}
return date.Add(time.Duration(hour)*time.Hour + time.Duration(minute)*time.Minute), nil
}
// atoiSafe parses a small decimal number, returning -1 when it is not one.
func atoiSafe(s string) int {
n := 0
if s == "" {
return -1
}
for _, r := range s {
if r < '0' || r > '9' {
return -1
}
n = n*10 + int(r-'0')
}
return n
}

View File

@@ -7,6 +7,8 @@ import (
"sort"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/compass_teams_nhs/internal/period"
)
// Importance labels stored in items.importance (FR-4).
@@ -69,11 +71,11 @@ func deterministicImportance(it Item, week time.Time) string {
// "End of Year Senior Music Concert - HALL (...) - KSH, CFR". The first part of
// a lesson is a period label ("5", "C" or "RLE GROUP D").
func eventIsSchoolEvent(title string) bool {
parts := splitDash(title)
parts := period.SplitDash(title)
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
return false
}
return !periodLabel.MatchString(parts[0])
return !period.IsPeriodLabel(parts[0])
}
// ClassifyModel asks a model to classify importance for every item (FR-4). It

View File

@@ -45,13 +45,16 @@ type Item struct {
// Page is one stored raw page: its slug, the URL it was fetched from, and the
// bytes. HTML pages carry HTML; the JSON service page carries JSON and the
// child's Compass userId (from the fetcher's meta file).
// child's Compass userId (from the fetcher's meta file). FetchedAt is when the
// fetcher stored the page, so a "Today" widget can be dated correctly even when
// an old run is re-parsed.
type Page struct {
Slug string
URL string
HTML []byte
JSON []byte
UserID string
Slug string
URL string
HTML []byte
JSON []byte
UserID string
FetchedAt time.Time
}
// Parse turns the stored pages for one child into structured items. It fails,
@@ -96,30 +99,39 @@ func Parse(child string, pages []Page, loc *time.Location) ([]Item, error) {
items = append(items, got.items...)
recognised++
case PageLearningTasks:
if hasAuthoritativeTasks {
recognised++
continue
if !hasAuthoritativeTasks {
got, err := parseLearningTasks(child, p, loc)
if err != nil {
return nil, err
}
items = append(items, got...)
}
got, err := parseLearningTasks(child, p, loc)
// The child's own day widget is the per-child timetable (FR-16).
day, _, err := parseDayWidget(child, p, loc, p.FetchedAt)
if err != nil {
return nil, err
}
items = append(items, got...)
items = append(items, day...)
recognised++
case PageChildProfile:
// The React profile page has no server-rendered grid; when it does
// carry the legacy grid, use it. The JSON list supersedes it.
if hasAuthoritativeTasks {
recognised++
continue
if !hasAuthoritativeTasks {
got, found, err := extractLearningTasks(child, p, loc)
if err != nil {
return nil, err
}
if found {
items = append(items, got...)
}
}
got, found, err := extractLearningTasks(child, p, loc)
// The profile page carries the child's own "Today" widget: the
// authoritative per-child periods (FR-16).
day, _, err := parseDayWidget(child, p, loc, p.FetchedAt)
if err != nil {
return nil, err
}
if found {
items = append(items, got...)
}
items = append(items, day...)
recognised++
case PageHome:
got, err := parseNews(child, p, loc)

View File

@@ -175,23 +175,20 @@ func TestParseCalendar(t *testing.T) {
if err != nil {
t.Fatalf("parseCalendar: %v", err)
}
if len(items) != 2 {
t.Fatalf("got %d items, want 2", len(items))
// The week calendar is school-wide, so its lesson grid is dropped: a
// child's own periods come from that child's day widget (FR-16). Only the
// real school event survives.
if len(items) != 1 {
t.Fatalf("got %d items, want 1 (the routine lesson is not a per-child period)", len(items))
}
if items[0].Type != TypeEvent {
t.Errorf("type = %q", items[0].Type)
}
if got := items[0].DueAt.Format("2006-01-02 15:04"); got != "2026-10-15 18:00" {
t.Errorf("event 1 time = %s", got)
t.Errorf("event time = %s", got)
}
if items[0].Title != "End of Year Concert - HALL - KSH" {
t.Errorf("event 1 title = %q", items[0].Title)
}
if items[1].Subject != "11HLEGB5" || items[1].Teacher != "LHT" {
t.Errorf("event 2 subject/teacher = %q/%q", items[1].Subject, items[1].Teacher)
}
if got := items[1].DueAt.Format("15:04"); got != "14:06" {
t.Errorf("event 2 time = %q", got)
t.Errorf("event title = %q", items[0].Title)
}
}
@@ -430,3 +427,87 @@ func TestParseReconcilesOverdueWithHome(t *testing.T) {
t.Fatal("Parse accepted a home page with no record of the child (FR-12)")
}
}
// dayWidgetFixture is the per-child "Today" widget Compass renders on a child's
// own profile page. The child is Finn (Year 7).
const dayWidgetFixture = `<html><body>
<div class="x-panel compass-day-calendar x-cal-panel" id="calendardaywidget-1055">
<div class="x-toolbar">Today</div>
<div class="calendardaywidget-1055-day-bd-evt-a x-cal-default calendar-event-93806 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:95.8px;height:48.1px;">
<span class="ext-evt-bd" lang="en-GB">8:55: 1 - 7SCF - H14LAB - KNI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-b x-cal-default calendar-event-93737 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:50%;width:50%;top:145px;height:44px;">
<span class="ext-evt-bd" lang="en-GB">9:54: 2 - 7DESF - B1 - PSI</span>
</div>
<div class="calendardaywidget-1055-day-bd-evt-c x-cal-default calendar-event-93755 custom-event ext-cal-evt activity-type-1 ext-event-no-roll" style="left:0%;width:100%;top:321.6666666666667px;height:48px;">
<span class="ext-evt-bd" lang="en-GB">1:26: 4 - 7PEF - GYM4 - LTA</span>
</div>
</div>
</body></html>`
// TestParseDayWidgetIsPerChild proves FR-16: a child's periods come from that
// child's own day widget, and the school-wide week calendar's lesson grid is
// never stored against the child.
func TestParseDayWidgetIsPerChild(t *testing.T) {
loc := melbourne(t)
fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc)
pages := []Page{
{Slug: PageChildProfile, URL: "https://nhs-vic.compass.education/Records/User.aspx?userId=4242", HTML: []byte(dayWidgetFixture), FetchedAt: fetched},
{Slug: PageCalendar, URL: "https://nhs-vic.compass.education/Organise/Calendar/", HTML: []byte(calendarFixture), FetchedAt: fetched},
}
items, err := Parse("Finn", pages, loc)
if err != nil {
t.Fatalf("Parse: %v", err)
}
leaked := 0
periods := 0
for _, it := range items {
if it.Subject == "11HLEGB5" {
leaked++
}
if it.SourcePage == PageChildProfile && it.Type == TypeEvent {
periods++
if it.DueAt == nil || it.DueAt.In(loc).Format("2006-01-02") != "2026-10-16" {
t.Errorf("period %q dated %v, want 2026-10-16", it.Title, it.DueAt)
}
if it.SourcePage != PageChildProfile {
t.Errorf("period %q source page = %q, want %q", it.Title, it.SourcePage, PageChildProfile)
}
}
}
if leaked != 0 {
t.Errorf("Finn's items carry %d Year 11 lesson(s) from the school-wide calendar", leaked)
}
if periods != 3 {
t.Errorf("Finn has %d periods, want 3", periods)
}
}
// TestDayWidgetParsesRoomAndTeacher proves the label is split into its parts so
// the brief can decode them.
func TestDayWidgetParsesRoomAndTeacher(t *testing.T) {
loc := melbourne(t)
fetched := time.Date(2026, 10, 16, 7, 0, 0, 0, loc)
items, found, err := parseDayWidget("Finn", Page{Slug: PageChildProfile, HTML: []byte(dayWidgetFixture), FetchedAt: fetched}, loc, fetched)
if err != nil || !found {
t.Fatalf("parseDayWidget: found=%v err=%v", found, err)
}
if len(items) != 3 {
t.Fatalf("got %d periods, want 3", len(items))
}
if items[0].Subject != "7SCF" {
t.Errorf("subject = %q", items[0].Subject)
}
if items[0].Title != "1 - 7SCF - H14LAB - KNI" {
t.Errorf("title = %q", items[0].Title)
}
if got := items[0].DueAt.In(loc).Format("15:04"); got != "08:55" {
t.Errorf("start = %q, want 08:55", got)
}
// The printed clock is 12-hour: the 1:26 period is 1:26 PM (the grid
// offset is the authority).
if got := items[2].DueAt.In(loc).Format("15:04"); got != "13:26" {
t.Errorf("afternoon start = %q, want 13:26", got)
}
}

54
internal/period/period.go Normal file
View File

@@ -0,0 +1,54 @@
// Package period understands the Compass lesson label:
//
// [period] - [subject code] - [room code] - [teacher initials]
//
// for example "1 - 7SCF - H14LAB - KNI". It is a leaf package so the parser,
// the brief and the calendar all agree on what a routine class period is
// (AGENTS.md §4 FR-16/FR-17, §7).
package period
import (
"regexp"
"strings"
)
// labelRe matches the period part of a lesson label: "5", "C" or
// "RLE GROUP D". A school event's first part ("End of Year Senior Music
// Concert") does not match, which is how a lesson is told from an event.
var labelRe = regexp.MustCompile(`^(\d{1,2}|[A-Z]|RLE GROUP [A-Z])$`)
// IsPeriodLabel reports whether s is a period slot rather than an event name.
func IsPeriodLabel(s string) bool {
return labelRe.MatchString(strings.TrimSpace(s))
}
// Split breaks a lesson title into its components. ok is false when the first
// part is not a period label, so the caller renders the title unchanged.
//
// A lesson may have fewer than four parts when Compass omits the room or the
// teacher ("1 - 11HLEGB5 -"), so missing components come back as "".
func Split(title string) (period, subject, room, teacher string, ok bool) {
parts := SplitDash(title)
if len(parts) < 2 || !IsPeriodLabel(parts[0]) {
return "", "", "", "", false
}
period = parts[0]
subject = parts[1]
if len(parts) >= 3 {
room = parts[2]
}
if len(parts) >= 4 {
teacher = parts[3]
}
return period, subject, room, teacher, true
}
// SplitDash splits on " - " and trims each part.
func SplitDash(s string) []string {
raw := strings.Split(s, " - ")
out := make([]string, 0, len(raw))
for _, p := range raw {
out = append(out, strings.TrimSpace(p))
}
return out
}

View File

@@ -47,6 +47,52 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
AND i.due_at IS NOT NULL
AND i.due_at >= ?`
args := []any{from.UTC().Format(time.RFC3339)}
q, args = narrowCalendarQuery(q, args, childID, itemID)
q += ` ORDER BY i.due_at ASC, i.id ASC`
q, args = capCalendarQuery(q, args, limit)
return s.queryCalendarItems(ctx, q, args)
}
// CalendarPeriodCandidates returns the per-child periods for one day that are
// not in the calendar yet. They come from the child's own day widget
// (source_page child-profile / learning-tasks), never from the school-wide week
// calendar (FR-16). A period is a routine lesson, so it carries importance
// "normal"; school events are written by CalendarCandidates.
func (s *Store) CalendarPeriodCandidates(ctx context.Context, childID, itemID *int64, dayStart, dayEnd time.Time, limit int) ([]CalendarItem, error) {
q := calendarSelect + `
WHERE i.type = 'event'
AND i.source = 'compass'
AND i.importance = 'normal'
AND COALESCE(i.ha_event_uid, '') = ''
AND i.source_page IN ('child-profile', 'learning-tasks')
AND i.due_at IS NOT NULL
AND i.due_at >= ? AND i.due_at < ?`
args := []any{dayStart.UTC().Format(time.RFC3339), dayEnd.UTC().Format(time.RFC3339)}
q, args = narrowCalendarQuery(q, args, childID, itemID)
q += ` ORDER BY i.due_at ASC, i.id ASC`
q, args = capCalendarQuery(q, args, limit)
return s.queryCalendarItems(ctx, q, args)
}
// CalendarTaskCandidates returns the outstanding work that carries a due date
// and is not in the calendar yet: the work due in the future and the work
// overdue (FR-8). Submitted work is filtered by the caller, which holds the
// body-status rule.
func (s *Store) CalendarTaskCandidates(ctx context.Context, childID, itemID *int64, limit int) ([]CalendarItem, error) {
q := calendarSelect + `
WHERE i.type = 'task'
AND i.source = 'compass'
AND COALESCE(i.ha_event_uid, '') = ''
AND i.due_at IS NOT NULL`
args := []any{}
q, args = narrowCalendarQuery(q, args, childID, itemID)
q += ` ORDER BY i.due_at ASC, i.id ASC`
q, args = capCalendarQuery(q, args, limit)
return s.queryCalendarItems(ctx, q, args)
}
// narrowCalendarQuery appends the optional child and item filters.
func narrowCalendarQuery(q string, args []any, childID, itemID *int64) (string, []any) {
if childID != nil {
q += ` AND i.child_id = ?`
args = append(args, *childID)
@@ -55,12 +101,19 @@ func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64,
q += ` AND i.id = ?`
args = append(args, *itemID)
}
q += ` ORDER BY i.due_at ASC, i.id ASC`
return q, args
}
func capCalendarQuery(q string, args []any, limit int) (string, []any) {
if limit > 0 {
q += ` LIMIT ?`
args = append(args, limit)
}
return q, args
}
// queryCalendarItems runs a candidate query and reads every row.
func (s *Store) queryCalendarItems(ctx context.Context, q string, args []any) ([]CalendarItem, error) {
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("calendar candidates: %w", err)
@@ -118,6 +171,45 @@ func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem,
return &it, nil
}
// DeleteChildRoutineCalendarEvents removes a child's routine class periods that
// came from the school-wide week calendar (source_page "calendar", importance
// "normal"). The week calendar's lesson grid belongs to the eldest child, so
// storing it against a younger child is a leak (FR-16); the child's own day
// widget is the per-child source. Real school events (importance "high") were
// written with the same page and are kept.
func (s *Store) DeleteChildRoutineCalendarEvents(ctx context.Context, childID int64) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM items
WHERE child_id = ? AND type = 'event' AND source_page = 'calendar' AND importance = 'normal'`,
childID)
if err != nil {
return 0, fmt.Errorf("delete routine calendar events: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return n, nil
}
// DeleteChildDayWidgetEvents removes a child's previously parsed day-widget
// periods so a re-parse replaces the snapshot instead of accumulating stale
// days or wrong times. It only touches events from the child's own pages.
func (s *Store) DeleteChildDayWidgetEvents(ctx context.Context, childID int64) (int64, error) {
res, err := s.db.ExecContext(ctx,
`DELETE FROM items
WHERE child_id = ? AND type = 'event' AND source_page IN ('child-profile', 'learning-tasks')`,
childID)
if err != nil {
return 0, fmt.Errorf("delete day-widget events: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return n, nil
}
// MarkItemCalendarSynced records the Home Assistant event id and the push time
// on one item. After this the item is no longer a calendar candidate.
func (s *Store) MarkItemCalendarSynced(ctx context.Context, itemID int64, uid string, at time.Time) error {

View File

@@ -113,3 +113,36 @@ func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
t.Fatalf("last = %+v", summary.Last)
}
}
// TestCodesSeedAndEdit proves the decode map is seeded, editable and that a
// correction survives a re-seed (FR-17).
func TestCodesSeedAndEdit(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
codes, err := st.Codes(ctx)
if err != nil {
t.Fatalf("codes: %v", err)
}
if codes.Subject["7SCF"] != "Year 7 Science" {
t.Errorf("7SCF = %q, want the seeded Year 7 Science", codes.Subject["7SCF"])
}
if codes.Subject["NOPE"] != "" {
t.Errorf("unknown code should be missing, got %q", codes.Subject["NOPE"])
}
if _, err := st.SetCode(ctx, "teacher", "KNI", "Mr Smith"); err != nil {
t.Fatalf("set: %v", err)
}
again, err := st.Codes(ctx)
if err != nil {
t.Fatalf("codes again: %v", err)
}
if again.Teacher["KNI"] != "Mr Smith" {
t.Errorf("KNI = %q, want the correction to survive the re-seed", again.Teacher["KNI"])
}
if _, err := st.SetCode(ctx, "bogus", "X", "Y"); err == nil {
t.Error("an unknown kind should be rejected")
}
}

263
internal/store/codes.go Normal file
View File

@@ -0,0 +1,263 @@
package store
import (
"context"
"fmt"
"strings"
)
// This file owns the editable decode map (AGENTS.md §4 FR-17, §7): the raw
// Compass subject, room, teacher and period codes and the plain-English names
// they render as. The map is data because the codes change between years and
// new subjects appear each term; the seed below only fills gaps, so a
// correction Sam makes in the dashboard is never overwritten.
// Code kinds stored in the codes table.
const (
CodeSubject = "subject"
CodeRoom = "room"
CodeTeacher = "teacher"
CodePeriod = "period"
)
// CodeMap is the whole decode table, grouped by kind. A missing code is not an
// error: the renderer falls back to the raw code (never blank).
type CodeMap struct {
Subject map[string]string
Room map[string]string
Teacher map[string]string
Period map[string]string
}
// Lookup returns the mapped name for one kind and code, or "" when the code is
// not mapped.
func (m CodeMap) Lookup(kind, code string) string {
code = strings.TrimSpace(code)
switch kind {
case CodeSubject:
return m.Subject[code]
case CodeRoom:
return m.Room[code]
case CodeTeacher:
return m.Teacher[code]
case CodePeriod:
return m.Period[code]
default:
return ""
}
}
// seedCodes are the mappings the project ships with. Subjects use the school's
// own names; rooms are "Room <code>"; teacher entries are placeholders because
// Compass only supplies initials, so Sam replaces them with surnames.
var seedCodes = []struct {
Kind string
Code string
Name string
}{
// Subjects seen in the Phase 2/4 captures (AGENTS.md §7 seed table plus the
// real captures).
{CodeSubject, "7SCF", "Year 7 Science"},
{CodeSubject, "7DESF", "Year 7 Design"},
{CodeSubject, "7TUTF", "Year 7 Tutorial"},
{CodeSubject, "7ENF", "Year 7 English"},
{CodeSubject, "7PEF", "Year 7 Physical Education"},
{CodeSubject, "7DRAF", "Year 7 Drama"},
{CodeSubject, "7HU", "Year 7 Humanities"},
{CodeSubject, "7EN", "Year 7 English"},
{CodeSubject, "7PE", "Year 7 Physical Education"},
{CodeSubject, "7CHI", "Year 7 Chinese"},
{CodeSubject, "11MGMAC2", "Year 11 General Maths"},
{CodeSubject, "11XMUSA6", "Year 11 Music"},
{CodeSubject, "11SBIOA1", "Year 11 Biology"},
{CodeSubject, "11HLEGB5", "Year 11 Legal Studies"},
{CodeSubject, "11AACPA4", "Year 11 Art Creative Practice"},
{CodeSubject, "11EENGB3", "Year 11 English"},
{CodeSubject, "11TUTB", "Year 11 Tutorial"},
{CodeSubject, "11MGMA", "Year 11 General Maths"},
{CodeSubject, "11XMUS", "Year 11 Music"},
{CodeSubject, "11SBIO", "Year 11 Biology"},
{CodeSubject, "11HLEG", "Year 11 Legal Studies"},
{CodeSubject, "11EENG", "Year 11 English"},
{CodeSubject, "11AACP", "Year 11 Art Creative Practice"},
// Rooms seen in the captures (AGENTS.md §7: B11 / M6 / A1 read as
// "Room B11" / "Room M6" / "Room A1").
{CodeRoom, "H14LAB", "Room H14LAB"},
{CodeRoom, "B11", "Room B11"},
{CodeRoom, "M6", "Room M6"},
{CodeRoom, "A1", "Room A1"},
{CodeRoom, "G7", "Room G7"},
{CodeRoom, "B1", "Room B1"},
{CodeRoom, "C1", "Room C1"},
{CodeRoom, "H3", "Room H3"},
{CodeRoom, "M4", "Room M4"},
{CodeRoom, "D8", "Room D8"},
{CodeRoom, "IM1", "Room IM1"},
{CodeRoom, "IM3", "Room IM3"},
{CodeRoom, "IM5", "Room IM5"},
{CodeRoom, "B13", "Room B13"},
{CodeRoom, "GYM4", "Gym 4"},
{CodeRoom, "THEATRE", "Theatre"},
{CodeRoom, "HALL", "Hall"},
{CodeRoom, "ENS", "Ensemble Room"},
// Teacher initials. Compass gives only initials, so these are placeholders
// in the AGENTS.md "Mr GT" form; Sam replaces them with surnames.
{CodeTeacher, "KNI", "Mr KNI"},
{CodeTeacher, "PSI", "Mr PSI"},
{CodeTeacher, "KDI", "Mr KDI"},
{CodeTeacher, "AFR", "Mr AFR"},
{CodeTeacher, "LHT", "Mr LHT"},
{CodeTeacher, "RLE", "Mr RLE"},
{CodeTeacher, "JBR", "Mr JBR"},
{CodeTeacher, "GT", "Mr GT"},
{CodeTeacher, "ANE", "Mr ANE"},
{CodeTeacher, "BDR", "Mr BDR"},
{CodeTeacher, "CHA", "Mr CHA"},
{CodeTeacher, "HZH", "Mr HZH"},
{CodeTeacher, "SLW", "Mr SLW"},
{CodeTeacher, "KWO", "Mr KWO"},
{CodeTeacher, "HHL", "Mr HHL"},
{CodeTeacher, "OFG", "Mr OFG"},
{CodeTeacher, "DCA", "Mr DCA"},
{CodeTeacher, "LTA", "Mr LTA"},
{CodeTeacher, "KSH", "Mr KSH"},
{CodeTeacher, "CFR", "Mr CFR"},
{CodeTeacher, "CMA", "Mr CMA"},
{CodeTeacher, "KSM", "Mr KSM"},
// Period labels that are words rather than numbers (AGENTS.md §7: C and
// "RLE GROUP D" are tutorials, Connect or assembly). A number is rendered
// as "Period N" without a table entry; anything else falls back to raw.
{CodePeriod, "C", "Connect"},
{CodePeriod, "RLE GROUP D", "RLE Group D"},
}
// SeedCodes inserts the shipped mappings that are not present yet. It is
// idempotent and never overwrites a value, so Sam's corrections survive every
// start and every scan.
func (s *Store) SeedCodes(ctx context.Context) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("seed codes: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
for _, c := range seedCodes {
if _, err := tx.ExecContext(ctx,
`INSERT INTO codes (kind, code, name) VALUES (?, ?, ?)
ON CONFLICT(kind, code) DO NOTHING`,
c.Kind, c.Code, c.Name); err != nil {
return fmt.Errorf("seed code %s %q: %w", c.Kind, c.Code, err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("seed codes: commit: %w", err)
}
return nil
}
// Codes returns the whole decode map. It seeds the shipped defaults first, so a
// fresh database renders the known captures and a corrected row is kept.
func (s *Store) Codes(ctx context.Context) (CodeMap, error) {
if err := s.SeedCodes(ctx); err != nil {
return CodeMap{}, err
}
rows, err := s.db.QueryContext(ctx, `SELECT kind, code, name FROM codes`)
if err != nil {
return CodeMap{}, fmt.Errorf("codes: %w", err)
}
defer rows.Close()
out := CodeMap{
Subject: map[string]string{},
Room: map[string]string{},
Teacher: map[string]string{},
Period: map[string]string{},
}
for rows.Next() {
var kind, code, name string
if err := rows.Scan(&kind, &code, &name); err != nil {
return CodeMap{}, fmt.Errorf("scan code: %w", err)
}
switch kind {
case CodeSubject:
out.Subject[code] = name
case CodeRoom:
out.Room[code] = name
case CodeTeacher:
out.Teacher[code] = name
case CodePeriod:
out.Period[code] = name
}
}
return out, rows.Err()
}
// Code is one row of the decode map, for the `school codes` CLI.
type Code struct {
Kind string `json:"kind"`
Code string `json:"code"`
Name string `json:"name"`
}
// ListCodes returns every mapping, ordered by kind then code.
func (s *Store) ListCodes(ctx context.Context) ([]Code, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT kind, code, name FROM codes ORDER BY kind, code`)
if err != nil {
return nil, fmt.Errorf("list codes: %w", err)
}
defer rows.Close()
var out []Code
for rows.Next() {
var c Code
if err := rows.Scan(&c.Kind, &c.Code, &c.Name); err != nil {
return nil, fmt.Errorf("scan code: %w", err)
}
out = append(out, c)
}
return out, rows.Err()
}
// SetCode creates or corrects one mapping and returns the row. It is how Sam
// edits the decode map (FR-17).
func (s *Store) SetCode(ctx context.Context, kind, code, name string) (Code, error) {
kind = strings.ToLower(strings.TrimSpace(kind))
code = strings.TrimSpace(code)
name = strings.TrimSpace(name)
switch kind {
case CodeSubject, CodeRoom, CodeTeacher, CodePeriod:
default:
return Code{}, fmt.Errorf("unknown code kind %q (want subject, room, teacher or period)", kind)
}
if code == "" {
return Code{}, fmt.Errorf("code is required")
}
if name == "" {
return Code{}, fmt.Errorf("name is required (an empty mapping renders blank)")
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO codes (kind, code, name, updated_at) VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(kind, code) DO UPDATE SET name = excluded.name, updated_at = datetime('now')`,
kind, code, name); err != nil {
return Code{}, fmt.Errorf("set code %s %q: %w", kind, code, err)
}
return Code{Kind: kind, Code: code, Name: name}, nil
}
// DeleteCode removes one mapping, so the code falls back to raw. It reports
// whether a row existed.
func (s *Store) DeleteCode(ctx context.Context, kind, code string) (bool, error) {
res, err := s.db.ExecContext(ctx, `DELETE FROM codes WHERE kind = ? AND code = ?`,
strings.ToLower(strings.TrimSpace(kind)), strings.TrimSpace(code))
if err != nil {
return false, fmt.Errorf("delete code: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return false, err
}
return n > 0, nil
}

View File

@@ -0,0 +1,19 @@
-- Migration 005 — the editable decode map (AGENTS.md §4 FR-17, §7).
--
-- Compass writes a lesson as "[period] - [subject code] - [room code] -
-- [teacher initials]", for example "1 - 7SCF - H14LAB - KNI". The brief must
-- read that as plain English ("Period 1 — Year 7 Science — Room H14LAB — Mr
-- KNI"), so the mapping is data, not code. School codes change between years,
-- so the table is editable and the seed only fills the gaps.
--
-- A code that is not in the table renders as the raw Compass code, never blank.
CREATE TABLE IF NOT EXISTS codes (
id INTEGER PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('subject', 'room', 'teacher', 'period')),
code TEXT NOT NULL,
name TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (kind, code)
);
CREATE INDEX IF NOT EXISTS idx_codes_kind ON codes(kind, code);

View File

@@ -375,6 +375,40 @@ func (s *Store) DeleteChildCompassTasksFromPages(ctx context.Context, childID in
return res.RowsAffected()
}
// DeleteItemsFromPages is DeleteItems narrowed to the given source pages. It is
// used to drop a stale snapshot (for example a child's previously parsed day
// widget) without touching the same child's other rows.
func (s *Store) DeleteItemsFromPages(ctx context.Context, childID int64, source, itemType string, pages ...string) (int64, error) {
if len(pages) == 0 {
return 0, nil
}
placeholders := strings.Repeat("?, ", len(pages))
placeholders = strings.TrimSuffix(placeholders, ", ")
q := `DELETE FROM items WHERE child_id = ?`
args := []any{childID}
if source != "" {
q += ` AND source = ?`
args = append(args, source)
}
if itemType != "" {
q += ` AND type = ?`
args = append(args, itemType)
}
q += ` AND source_page IN (` + placeholders + `)`
for _, p := range pages {
args = append(args, p)
}
res, err := s.db.ExecContext(ctx, q, args...)
if err != nil {
return 0, fmt.Errorf("delete items for child %d from pages: %w", childID, err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, err
}
return n, nil
}
// TypeCount is the number of items of one type for one child.
type TypeCount struct {
Child string

View File

@@ -312,7 +312,7 @@ func TestSummaryBuildAndDelete(t *testing.T) {
}
page = getBody(t, client, ts.URL+"/?child="+itoa(harry.ID))
if !strings.Contains(page, "Harry — 0 overdue") {
if !strings.Contains(page, "=== HARRY ===") {
t.Errorf("brief not shown on dashboard: %s", snippet(page))
}
resp2, err := client.PostForm(ts.URL+"/summaries/"+itoa(summaries[0].ID)+"/delete",