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:
221
internal/store/calendar.go
Normal file
221
internal/store/calendar.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file holds the Phase 6 calendar helpers (AGENTS.md §4 FR-8, §11 Phase 6):
|
||||
// the events that are worth writing to Sam's Home Assistant calendar, the mark
|
||||
// that records the Home Assistant event id on the item, and the append-only
|
||||
// push log that makes a failed write loud (FR-12).
|
||||
|
||||
// calendarSelect extends the shared item column list with the Home Assistant
|
||||
// columns, so a candidate carries its own push state.
|
||||
const calendarSelect = `SELECT i.id, i.child_id, COALESCE(ch.name, ''), i.source, i.type,
|
||||
COALESCE(i.subject, ''), COALESCE(i.teacher, ''), i.title,
|
||||
COALESCE(i.body, ''), i.due_at, COALESCE(i.url, ''),
|
||||
COALESCE(i.source_page, ''), COALESCE(i.importance, ''), COALESCE(i.external_ref, ''),
|
||||
COALESCE(i.ha_event_uid, ''), i.ha_pushed_at
|
||||
FROM items i LEFT JOIN children ch ON ch.id = i.child_id`
|
||||
|
||||
// CalendarItem is one item plus its Home Assistant push state.
|
||||
type CalendarItem struct {
|
||||
Item
|
||||
// HAEventUID is the Home Assistant event id once the item is in the
|
||||
// calendar ("" until then).
|
||||
HAEventUID string
|
||||
// HAPushedAt is when the item was pushed, or nil.
|
||||
HAPushedAt *time.Time
|
||||
}
|
||||
|
||||
// CalendarCandidates returns the important school events that belong in the
|
||||
// calendar: Compass events marked "high" by the Phase 3 classifier, starting at
|
||||
// or after from, newest-first excluded. childID and itemID narrow the set, and
|
||||
// limit caps it (0 means no cap).
|
||||
//
|
||||
// An item that already carries a Home Assistant event id is not a candidate:
|
||||
// the write is idempotent, so a re-run must not duplicate it.
|
||||
func (s *Store) CalendarCandidates(ctx context.Context, childID, itemID *int64, from time.Time, limit int) ([]CalendarItem, error) {
|
||||
q := calendarSelect + `
|
||||
WHERE i.type = 'event'
|
||||
AND i.source = 'compass'
|
||||
AND i.importance = 'high'
|
||||
AND COALESCE(i.ha_event_uid, '') = ''
|
||||
AND i.due_at IS NOT NULL
|
||||
AND i.due_at >= ?`
|
||||
args := []any{from.UTC().Format(time.RFC3339)}
|
||||
if childID != nil {
|
||||
q += ` AND i.child_id = ?`
|
||||
args = append(args, *childID)
|
||||
}
|
||||
if itemID != nil {
|
||||
q += ` AND i.id = ?`
|
||||
args = append(args, *itemID)
|
||||
}
|
||||
q += ` ORDER BY i.due_at ASC, i.id ASC`
|
||||
if limit > 0 {
|
||||
q += ` LIMIT ?`
|
||||
args = append(args, limit)
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("calendar candidates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []CalendarItem
|
||||
for rows.Next() {
|
||||
var it CalendarItem
|
||||
var due, pushed sql.NullString
|
||||
if err := rows.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
|
||||
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
|
||||
&it.SourcePage, &it.Importance, &it.ExternalRef,
|
||||
&it.HAEventUID, &pushed); err != nil {
|
||||
return nil, fmt.Errorf("scan calendar item: %w", err)
|
||||
}
|
||||
if due.Valid {
|
||||
t := parseTime(due.String)
|
||||
it.DueAt = &t
|
||||
}
|
||||
if pushed.Valid {
|
||||
t := parseTime(pushed.String)
|
||||
it.HAPushedAt = &t
|
||||
}
|
||||
out = append(out, it)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CalendarItemByID returns one item with its Home Assistant push state, or nil
|
||||
// when it does not exist. The item screen uses it to show the calendar status.
|
||||
func (s *Store) CalendarItemByID(ctx context.Context, id int64) (*CalendarItem, error) {
|
||||
row := s.db.QueryRowContext(ctx, calendarSelect+` WHERE i.id = ?`, id)
|
||||
|
||||
var it CalendarItem
|
||||
var due, pushed sql.NullString
|
||||
err := row.Scan(&it.ID, &it.ChildID, &it.Child, &it.Source, &it.Type,
|
||||
&it.Subject, &it.Teacher, &it.Title, &it.Body, &due, &it.URL,
|
||||
&it.SourcePage, &it.Importance, &it.ExternalRef,
|
||||
&it.HAEventUID, &pushed)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("item %d: %w", id, err)
|
||||
}
|
||||
if due.Valid {
|
||||
t := parseTime(due.String)
|
||||
it.DueAt = &t
|
||||
}
|
||||
if pushed.Valid {
|
||||
t := parseTime(pushed.String)
|
||||
it.HAPushedAt = &t
|
||||
}
|
||||
return &it, 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 {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE items SET ha_event_uid = ?, ha_pushed_at = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
uid, at.UTC().Format(time.RFC3339), itemID); err != nil {
|
||||
return fmt.Errorf("mark item %d calendar-synced: %w", itemID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CalendarPush is one row of the append-only calendar push log.
|
||||
type CalendarPush struct {
|
||||
ID int64 `json:"id"`
|
||||
ItemID int64 `json:"item_id"`
|
||||
EntityID string `json:"entity_id"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// CalendarPushStatus summarises the push log for the dashboard. A failed push is
|
||||
// shown as an explicit failure and is never swallowed (FR-12).
|
||||
type CalendarPushStatus struct {
|
||||
// EverRun is false until the first push is recorded.
|
||||
EverRun bool `json:"ever_run"`
|
||||
// Total is the number of push rows.
|
||||
Total int `json:"total"`
|
||||
// Failed is the number of rows with ok = 0.
|
||||
Failed int `json:"failed"`
|
||||
// Last is the most recent push, when one exists.
|
||||
Last *CalendarPush `json:"last,omitempty"`
|
||||
}
|
||||
|
||||
// RecordCalendarPush appends one push attempt. ok is false with an error message
|
||||
// for a failed write, which the dashboard then shows.
|
||||
func (s *Store) RecordCalendarPush(ctx context.Context, itemID int64, entityID, uid string, ok bool, errMsg string) (int64, error) {
|
||||
var errVal any
|
||||
if errMsg != "" {
|
||||
errVal = errMsg
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO calendar_pushes (item_id, entity_id, uid, ok, error) VALUES (?, ?, ?, ?, ?)`,
|
||||
itemID, entityID, uid, ok, errVal)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("record calendar push for item %d: %w", itemID, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// LastCalendarPush returns the most recent push row, or nil when none exists.
|
||||
func (s *Store) LastCalendarPush(ctx context.Context) (*CalendarPush, error) {
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, item_id, entity_id, COALESCE(uid, ''), ok, COALESCE(error, ''), created_at
|
||||
FROM calendar_pushes ORDER BY id DESC LIMIT 1`)
|
||||
p, err := scanCalendarPush(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// CalendarPushSummary summarises the push log (FR-12).
|
||||
func (s *Store) CalendarPushSummary(ctx context.Context) (CalendarPushStatus, error) {
|
||||
var out CalendarPushStatus
|
||||
row := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*), COALESCE(SUM(CASE WHEN ok = 0 THEN 1 ELSE 0 END), 0) FROM calendar_pushes`)
|
||||
if err := row.Scan(&out.Total, &out.Failed); err != nil {
|
||||
return out, fmt.Errorf("calendar push summary: %w", err)
|
||||
}
|
||||
out.EverRun = out.Total > 0
|
||||
last, err := s.LastCalendarPush(ctx)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Last = last
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func scanCalendarPush(r rowScanner) (CalendarPush, error) {
|
||||
var p CalendarPush
|
||||
var created string
|
||||
var uid, errMsg sql.NullString
|
||||
var ok int
|
||||
if err := r.Scan(&p.ID, &p.ItemID, &p.EntityID, &uid, &ok, &errMsg, &created); err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.UID = uid.String
|
||||
p.OK = ok != 0
|
||||
p.Error = errMsg.String
|
||||
p.CreatedAt = parseTime(created)
|
||||
return p, nil
|
||||
}
|
||||
115
internal/store/calendar_test.go
Normal file
115
internal/store/calendar_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCalendarCandidatesSelectsImportantFutureEventsOnly proves the Phase 6
|
||||
// selection rule: only important, future Compass events that are not in the
|
||||
// calendar yet are candidates.
|
||||
func TestCalendarCandidatesSelectsImportantFutureEventsOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
finn, _ := st.ChildByName(ctx, "Finn")
|
||||
|
||||
now := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)
|
||||
future := now.Add(4 * time.Hour)
|
||||
past := now.Add(-4 * time.Hour)
|
||||
|
||||
items := []Item{
|
||||
{Source: "compass", Type: "event", Title: "Concert", DueAt: &future, Importance: "high", ExternalRef: "e1"},
|
||||
{Source: "compass", Type: "event", Title: "Lesson", DueAt: &future, Importance: "normal", ExternalRef: "e2"},
|
||||
{Source: "compass", Type: "event", Title: "Yesterday", DueAt: &past, Importance: "high", ExternalRef: "e3"},
|
||||
{Source: "compass", Type: "task", Title: "Essay", DueAt: &future, Importance: "high", ExternalRef: "e4"},
|
||||
{Source: "teams", Type: "event", Title: "Teams thing", DueAt: &future, Importance: "high", ExternalRef: "e5"},
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, items); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
if _, _, err := st.UpsertItems(ctx, finn.ID, []Item{
|
||||
{Source: "compass", Type: "event", Title: "Finn event", DueAt: &future, Importance: "high", ExternalRef: "f1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert finn: %v", err)
|
||||
}
|
||||
|
||||
all, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("want 2 candidates (one per child), got %d: %+v", len(all), all)
|
||||
}
|
||||
if all[0].Title != "Concert" || all[1].Title != "Finn event" {
|
||||
t.Errorf("unexpected candidate order: %+v", all)
|
||||
}
|
||||
|
||||
onlyHarry, err := st.CalendarCandidates(ctx, &harry.ID, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(onlyHarry) != 1 || onlyHarry[0].Child != "Harry" {
|
||||
t.Fatalf("per-child filter failed: %+v", onlyHarry)
|
||||
}
|
||||
|
||||
// Mark the concert as synced; it must leave the candidate set.
|
||||
if err := st.MarkItemCalendarSynced(ctx, all[0].ID, "uid-1", now); err != nil {
|
||||
t.Fatalf("mark: %v", err)
|
||||
}
|
||||
after, err := st.CalendarCandidates(ctx, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("candidates: %v", err)
|
||||
}
|
||||
if len(after) != 1 || after[0].Title != "Finn event" {
|
||||
t.Fatalf("synced item is still a candidate: %+v", after)
|
||||
}
|
||||
ci, err := st.CalendarItemByID(ctx, all[0].ID)
|
||||
if err != nil || ci == nil {
|
||||
t.Fatalf("item: %v %v", ci, err)
|
||||
}
|
||||
if ci.HAEventUID != "uid-1" || ci.HAPushedAt == nil {
|
||||
t.Errorf("mark did not stick: %+v", ci)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarPushSummaryIsAppendOnly proves a failed write stays on record.
|
||||
func TestCalendarPushSummaryIsAppendOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
st := newTestStore(t)
|
||||
harry, _ := st.ChildByName(ctx, "Harry")
|
||||
due := time.Now().Add(time.Hour).UTC()
|
||||
if _, _, err := st.UpsertItems(ctx, harry.ID, []Item{
|
||||
{Source: "compass", Type: "event", Title: "Concert", DueAt: &due, Importance: "high", ExternalRef: "e1"},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
items, _ := st.ItemsForChild(ctx, harry.ID)
|
||||
|
||||
empty, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if empty.EverRun || empty.Total != 0 || empty.Last != nil {
|
||||
t.Fatalf("fresh summary = %+v", empty)
|
||||
}
|
||||
|
||||
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "", false, "boom"); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
if _, err := st.RecordCalendarPush(ctx, items[0].ID, "calendar.school", "uid-1", true, ""); err != nil {
|
||||
t.Fatalf("record: %v", err)
|
||||
}
|
||||
|
||||
summary, err := st.CalendarPushSummary(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("summary: %v", err)
|
||||
}
|
||||
if summary.Total != 2 || summary.Failed != 1 || !summary.EverRun {
|
||||
t.Fatalf("summary = %+v", summary)
|
||||
}
|
||||
if summary.Last == nil || !summary.Last.OK || summary.Last.UID != "uid-1" {
|
||||
t.Fatalf("last = %+v", summary.Last)
|
||||
}
|
||||
}
|
||||
24
internal/store/migrations/003_calendar.sql
Normal file
24
internal/store/migrations/003_calendar.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
-- Migration 003 — Phase 6 calendar write (AGENTS.md §4 FR-8, §11 Phase 6).
|
||||
--
|
||||
-- Important school events are written to Sam's Home Assistant calendar. The
|
||||
-- write must be idempotent: the Home Assistant event id is stored on the item,
|
||||
-- so a re-run updates nothing and never duplicates an event. The push log is
|
||||
-- append-only, so a failed calendar write is visible and loud instead of being
|
||||
-- swallowed (FR-12).
|
||||
|
||||
ALTER TABLE items ADD COLUMN ha_event_uid TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE items ADD COLUMN ha_pushed_at TEXT;
|
||||
|
||||
-- One row per calendar write attempt. ok = 0 with an error is a loud failure.
|
||||
CREATE TABLE IF NOT EXISTS calendar_pushes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
item_id INTEGER NOT NULL REFERENCES items(id),
|
||||
entity_id TEXT NOT NULL,
|
||||
uid TEXT,
|
||||
ok INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_pushes_item ON calendar_pushes(item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_pushes_created ON calendar_pushes(created_at);
|
||||
Reference in New Issue
Block a user