Phase 6: write important school events to the Home Assistant calendar
Add the Home Assistant REST calendar client and the idempotent sync that writes important school events to Sam's Home Assistant calendar (FR-8). The school stays read-only; the only write is to the calendar (rule 7). - internal/calendar: HA REST client (states, create_event, get_events, delete_event) and the sync that selects important future events, adopts an existing event by its description marker, records the HA event id on the item, and records every attempt. - internal/store: migration 003 (items.ha_event_uid, items.ha_pushed_at and the append-only calendar_pushes table), candidates, mark-synced and the push log. - internal/parse: a school event is important; a routine class period is not. - cmd/school: `school calendar entities|status|push|events`. - web: an "Add to calendar" item action and a fail-loud calendar banner, so a failed write is never swallowed (FR-12). - scripts/calendar-push.sh, Makefile target, env example, docs and tests. The target is configurable (SCHOOL_HA_CALENDAR_ENTITY). Home Assistant has no non-Google writable calendar today, so the entity is asked on AUD-110. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -46,7 +46,7 @@ func ClassifyDeterministic(items []Item, now time.Time) {
|
||||
func deterministicImportance(it Item, week time.Time) string {
|
||||
// Only work carries a deadline that makes it important. A calendar entry's
|
||||
// due_at is its start time, and a lesson that starts this week is not
|
||||
// overdue work, so events and messages stay normal under the offline rule.
|
||||
// overdue work, so meetings stay normal under the offline rule.
|
||||
if it.Type == TypeTask {
|
||||
if it.DueAt != nil && it.DueAt.Before(week) {
|
||||
return importanceHigh
|
||||
@@ -55,9 +55,27 @@ func deterministicImportance(it Item, week time.Time) string {
|
||||
return importanceHigh
|
||||
}
|
||||
}
|
||||
// A school event is important; a routine class period is not (Phase 6
|
||||
// writes only the important ones to the calendar, AGENTS.md §4 FR-8).
|
||||
if it.Type == TypeEvent && eventIsSchoolEvent(it.Title) {
|
||||
return importanceHigh
|
||||
}
|
||||
return importanceNormal
|
||||
}
|
||||
|
||||
// eventIsSchoolEvent reports whether a calendar entry is a school event rather
|
||||
// than a routine class period. Compass writes a lesson as
|
||||
// "5 - 11HLEGB5 - D8 - LHT" and a school event as
|
||||
// "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)
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return false
|
||||
}
|
||||
return !periodLabel.MatchString(parts[0])
|
||||
}
|
||||
|
||||
// ClassifyModel asks a model to classify importance for every item (FR-4). It
|
||||
// fails, loudly, on a model error or an unparseable answer; it never silently
|
||||
// leaves a wrong label. Items are updated in place. now lets the model judge
|
||||
|
||||
@@ -304,6 +304,26 @@ func TestClassifyDeterministic(t *testing.T) {
|
||||
|
||||
func ptr(t time.Time) *time.Time { return &t }
|
||||
|
||||
// TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school
|
||||
// event is important, a routine class period is not.
|
||||
func TestClassifyDeterministicEvents(t *testing.T) {
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
at := now.Add(4 * time.Hour)
|
||||
items := []Item{
|
||||
{Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at},
|
||||
{Type: TypeEvent, Title: "Year 11 Assembly - HALL - CMA", DueAt: &at},
|
||||
{Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at},
|
||||
{Type: TypeEvent, Title: "C - 11TUTB - M6 - ANE", DueAt: &at},
|
||||
{Type: TypeEvent, Title: "RLE GROUP D - IM1 - RLE", DueAt: &at},
|
||||
}
|
||||
ClassifyDeterministic(items, now)
|
||||
for i, want := range []string{importanceHigh, importanceHigh, importanceNormal, importanceNormal, importanceNormal} {
|
||||
if items[i].Importance != want {
|
||||
t.Errorf("items[%d] %q = %q, want %q", i, items[i].Title, items[i].Importance, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tasksJSONFixture is the shape of GetAllLearningTasksByUserId: a wrapper with
|
||||
// the per-child tasks. Task 1 is overdue and carries its due date at the task
|
||||
// level; task 2 is on time and carries it on the student; task 3 belongs to the
|
||||
|
||||
Reference in New Issue
Block a user