Phase 6: fix the live Home Assistant write (Local Calendar, no event id)

The first live run against the new calendar.school entity failed loudly twice,
and both causes are fixed here.

- The Local Calendar rejects `return_response` on calendar.create_event. The
  client retries the call without it and ignores the changed-states array that
  Home Assistant returns instead.
- Home Assistant then reports no event id. The item keeps the description
  marker ([compass-nhs:<id>]) as its reference, so it leaves the candidate set
  and is never written twice. The live retry proved it: both events already in
  the calendar were adopted by their markers, with no duplicate.
- calendar.get_events reports `start` / `end` (a date-time or a bare date), not
  `start_date_time`; the reader now accepts all three shapes.

A model must no longer classify events. The calendar write depends on the
school-event rule, and the LLM labelled every event "normal"; events now keep
the deterministic rule under both classifiers.

Also fix the Phase 5 screenshot script: the calendar banner shares the `.scan`
class, so select each banner by its labelled section.

Docs record the accepted decision: Sam chose a Home Assistant Local Calendar,
so the target is calendar.school.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-10-15 19:09:06 +11:00
parent f56dbb46ac
commit da57efad73
9 changed files with 279 additions and 68 deletions

View File

@@ -15,10 +15,10 @@ re-run never duplicates an event) and it fails loudly (a failed write is shown
on the dashboard and recorded in `calendar_pushes`). See on the dashboard and recorded in `calendar_pushes`). See
[`docs/phase-6-calendar-notes.md`](docs/phase-6-calendar-notes.md). [`docs/phase-6-calendar-notes.md`](docs/phase-6-calendar-notes.md).
**Open decision:** the brief says *not Google*, and Home Assistant has no The target is a **Home Assistant Local Calendar**, `calendar.school` (added
non-Google writable calendar yet. The target entity is configurable 2026-10-15 after Sam chose it over the Google-backed entities that Home
(`SCHOOL_HA_CALENDAR_ENTITY`) and the question is on Assistant had). The entity stays configurable with
[AUD-110](/AUD/issues/AUD-110). `SCHOOL_HA_CALENDAR_ENTITY`.
```bash ```bash
scripts/calendar-push.sh entities # list the writable calendar entities scripts/calendar-push.sh entities # list the writable calendar entities

View File

@@ -1,8 +1,14 @@
# Phase 6 — the calendar write (Sam's Home Assistant calendar) # Phase 6 — the calendar write (Sam's Home Assistant calendar)
Status: **code complete and tested; the calendar target needs Sam's decision** Status: **done** (AUD-110). Sam chose a **Home Assistant Local Calendar**, and
(AUD-110). The service now writes important school events through the Home the service writes important school events to it through the Home Assistant REST
Assistant REST API. Nothing is written until a calendar entity is chosen. API.
| | |
|---|---|
| Target entity | `calendar.school` (Local Calendar "School", added 2026-10-15) |
| Configuration | `environment.d/school-service.env` (gitignored): `SCHOOL_HA_URL`, `SCHOOL_HA_TOKEN`, `SCHOOL_HA_CALENDAR_ENTITY`, `SCHOOL_HA_TIMEOUT` |
| First live events | *Senior Sub-School Community Service for Uniform Infringement* and *End of Year Senior Music Concert — HALL* (2026-10-15) |
## What it does ## What it does
@@ -82,32 +88,37 @@ scripts/calendar-push.sh push --dry-run --json
The item screen has an **Add to calendar** button for an important future event, The item screen has an **Add to calendar** button for an important future event,
and shows `in the calendar` with the Home Assistant event id once it is written. and shows `in the calendar` with the Home Assistant event id once it is written.
## The open decision: which calendar entity? ## Decision (accepted 2026-10-15)
The Home Assistant instance (`192.168.20.30`, HA 2026.9.4) has three calendar The Home Assistant instance (`192.168.20.30`, HA 2026.9.4) had three calendar
entities, and only two are writable: entities, and all the writable ones were Google-backed
(`calendar.samuelrolfe_googlemail_com`, `calendar.birthdays`); the brief says
**Sam's Home Assistant calendar (not Google)**, and no non-Google calendar
existed. Sam chose to **add a Local Calendar**, so:
| Entity | Writable | Backing | - The Local Calendar integration was added through the Home Assistant config
|---|---|---| flow, creating the entity **`calendar.school`** (title "School"). It is
| `calendar.samuelrolfe_googlemail_com` | yes | Google, synced into Home Assistant | Home-Assistant-local, never touches Google, and the change is purely additive.
| `calendar.birthdays` | yes | Google | - `SCHOOL_HA_CALENDAR_ENTITY=calendar.school`.
| `calendar.holidays_in_australia` | no | Google |
The brief says **Sam's Home Assistant calendar (not Google)**. Today Home The calendar is a family calendar, so an event carries the child's name only
Assistant has **no non-Google writable calendar**: the `local_calendar` when it belongs to one child.
integration is not installed, and no CalDAV/remote calendar is configured.
So the target must be chosen before the event is written: ## Home Assistant quirks found on the first live run
1. **Add a Local Calendar to Home Assistant** (recommended). One Home Assistant Both are handled, and both were caught by the fail-loudly design rather than
config-flow entry creates `calendar.school`. It is Home-Assistant-local, never silently writing nothing:
touches Google, and is entirely additive. The service then uses
`SCHOOL_HA_CALENDAR_ENTITY=calendar.school`.
2. **Use `calendar.samuelrolfe_googlemail_com`.** No Home Assistant change, but
every school event also appears in Sam's personal Google Calendar.
The question is posted on AUD-110. The code needs only the configuration line - **The Local Calendar rejects `return_response`.** `calendar.create_event` is
changed once the answer is in. called with the service response to learn the event id; the Local Calendar
answers `Service does not support responses`. The client retries the call
without it. Home Assistant then replies with the changed states, a JSON array,
which the client ignores.
- **The Local Calendar reports no event id.** With no id to store, the item keeps
the description marker (`[compass-nhs:<item id>]`) as its reference, so the
item leaves the candidate set and is never written twice. The live retry
proved it: after a failed first attempt the two events already in the calendar
were adopted by their markers, and nothing was duplicated.
## Where the code lives ## Where the code lives
@@ -141,8 +152,12 @@ make test # go test ./...
``` ```
The tests cover the Home Assistant client against a fake Home Assistant (create, The tests cover the Home Assistant client against a fake Home Assistant (create,
read-back, error mapping), the sync run (write once, adopt by marker, dry run, read-back, error mapping, the no-response fallback, the `start`/`end` shapes),
loud failure, refuse-to-write-blind), the store (candidates, the push log) and the sync run (write once, adopt by marker, dry run, loud failure,
the web surface (the action, the CSRF guard, the failure banner). refuse-to-write-blind, no-event-id idempotency), the store (candidates, the push
log), the parser (a model cannot label an event) and the web surface (the
action, the CSRF guard, the failure banner).
Not tested here: the live Home Assistant write. That needs the entity decision. Live proof on `.13` (2026-10-15): two Compass school events were written to
`calendar.school`, read back through the Home Assistant API, and the dashboard
and item screen show the calendar state.

View File

@@ -166,6 +166,11 @@ type Event struct {
// Assistant event id when the service reports one. It asks for the service // Assistant event id when the service reports one. It asks for the service
// response, so the id can be stored on the item and a re-run cannot duplicate // response, so the id can be stored on the item and a re-run cannot duplicate
// the event. // the event.
//
// Not every calendar answers with a response: the Local Calendar integration
// rejects `return_response` outright. That is not a write failure, so the call
// is retried without it and the empty id is reported to the caller, which then
// falls back to the description marker for idempotency.
func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) { func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
if strings.TrimSpace(ev.Summary) == "" { if strings.TrimSpace(ev.Summary) == "" {
return "", errors.New("calendar: event summary is empty") return "", errors.New("calendar: event summary is empty")
@@ -186,13 +191,29 @@ func (c *Client) CreateEvent(ctx context.Context, ev Event) (string, error) {
body["location"] = ev.Location body["location"] = ev.Location
} }
resp, err := c.callService(ctx, "calendar", "create_event", body) resp, err := c.callService(ctx, "calendar", "create_event", body, true)
if err != nil { if err != nil {
if noResponseSupport(err) {
if _, rerr := c.callService(ctx, "calendar", "create_event", body, false); rerr != nil {
return "", rerr
}
return "", nil
}
return "", err return "", err
} }
return serviceUID(resp, c.entity), nil return serviceUID(resp, c.entity), nil
} }
// noResponseSupport reports whether Home Assistant refused the request only
// because this calendar does not answer with a service response.
func noResponseSupport(err error) bool {
var haErr *Error
if !errors.As(err, &haErr) {
return false
}
return strings.Contains(strings.ToLower(haErr.Message), "does not support responses")
}
// DeleteEvent removes an event by its Home Assistant uid. It exists so a test // DeleteEvent removes an event by its Home Assistant uid. It exists so a test
// event can be cleaned up, and so a wrong write can be undone. // event can be cleaned up, and so a wrong write can be undone.
func (c *Client) DeleteEvent(ctx context.Context, uid string) error { func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
@@ -202,7 +223,7 @@ func (c *Client) DeleteEvent(ctx context.Context, uid string) error {
_, err := c.callService(ctx, "calendar", "delete_event", map[string]any{ _, err := c.callService(ctx, "calendar", "delete_event", map[string]any{
"entity_id": c.entity, "entity_id": c.entity,
"uid": uid, "uid": uid,
}) }, false)
return err return err
} }
@@ -214,7 +235,7 @@ func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, err
"entity_id": c.entity, "entity_id": c.entity,
"start_date_time": start.Format(time.RFC3339), "start_date_time": start.Format(time.RFC3339),
"end_date_time": end.Format(time.RFC3339), "end_date_time": end.Format(time.RFC3339),
}) }, true)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -223,12 +244,20 @@ func (c *Client) Events(ctx context.Context, start, end time.Time) ([]Event, err
// ---------- transport ---------- // ---------- transport ----------
// callService posts to /api/services/<domain>/<service> with return_response, // callService posts to /api/services/<domain>/<service>. When withResponse is
// so a service that reports data (the created event id) answers with it. // true it adds ?return_response, so a service that reports data (the created
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any) (map[string]any, error) { // event id) answers with it. Without a response Home Assistant replies with the
path := fmt.Sprintf("/api/services/%s/%s?return_response", domain, service) // changed states, a JSON array, which the caller does not need.
func (c *Client) callService(ctx context.Context, domain, service string, body map[string]any, withResponse bool) (map[string]any, error) {
path := fmt.Sprintf("/api/services/%s/%s", domain, service)
if !withResponse {
if err := c.do(ctx, http.MethodPost, path, body, nil); err != nil {
return nil, err
}
return nil, nil
}
var out map[string]any var out map[string]any
if err := c.do(ctx, http.MethodPost, path, body, &out); err != nil { if err := c.do(ctx, http.MethodPost, path+"?return_response", body, &out); err != nil {
return nil, err return nil, err
} }
return out, nil return out, nil
@@ -372,13 +401,19 @@ func servicePayloads(resp map[string]any, entity string) []map[string]any {
return out return out
} }
// parseHATime reads an event time from a calendar.get_events result. Home
// Assistant reports `start` / `end` as either a date-time with an offset or a
// bare date for an all-day event; the *_date_time / *_date shapes are also
// accepted for older replies.
func parseHATime(m map[string]any, prefix string) (*time.Time, string) { func parseHATime(m map[string]any, prefix string) (*time.Time, string) {
if raw := str(m[prefix+"_date_time"]); raw != "" { for _, key := range []string{prefix + "_date_time", prefix + "_date", prefix} {
raw := str(m[key])
if raw == "" {
continue
}
if t, err := time.Parse(time.RFC3339, raw); err == nil { if t, err := time.Parse(time.RFC3339, raw); err == nil {
return &t, raw return &t, raw
} }
}
if raw := str(m[prefix+"_date"]); raw != "" {
if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil { if t, err := time.ParseInLocation("2006-01-02", raw, time.Local); err == nil {
return &t, raw return &t, raw
} }

View File

@@ -18,15 +18,16 @@ import (
type fakeHA struct { type fakeHA struct {
*httptest.Server *httptest.Server
mu sync.Mutex mu sync.Mutex
entity string entity string
events []map[string]any events []map[string]any
createCalls int createCalls int
getCalls int getCalls int
failCreate bool failCreate bool
returnUID bool returnUID bool
lastCreate map[string]any rejectReturnResponse bool
lastGet map[string]any lastCreate map[string]any
lastGet map[string]any
} }
func newFakeHA(t *testing.T, entity string) *fakeHA { func newFakeHA(t *testing.T, entity string) *fakeHA {
@@ -57,6 +58,14 @@ func newFakeHA(t *testing.T, entity string) *fakeHA {
if !f.authOK(w, r) { if !f.authOK(w, r) {
return return
} }
if f.rejectReturnResponse && r.URL.Query().Has("return_response") {
w.WriteHeader(http.StatusBadRequest)
writeJSON(w, map[string]any{"error": map[string]any{
"code": "unknown_error",
"message": "Service does not support responses. Remove return_response from request.",
}})
return
}
var body map[string]any var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil { if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest) http.Error(w, "bad json", http.StatusBadRequest)
@@ -76,12 +85,17 @@ func newFakeHA(t *testing.T, entity string) *fakeHA {
uid = "uid-" + str(body["summary"]) uid = "uid-" + str(body["summary"])
} }
f.events = append(f.events, map[string]any{ f.events = append(f.events, map[string]any{
"uid": uid, "uid": uid,
"summary": body["summary"], "summary": body["summary"],
"description": body["description"], "description": body["description"],
"start_date_time": body["start_date_time"], "start": body["start_date_time"],
"end_date_time": body["end_date_time"], "end": body["end_date_time"],
}) })
if !r.URL.Query().Has("return_response") {
// Home Assistant replies with the changed states, a JSON array.
writeJSON(w, []map[string]any{})
return
}
writeJSON(w, map[string]any{ writeJSON(w, map[string]any{
"changed_entities": map[string]any{}, "changed_entities": map[string]any{},
"service_response": map[string]any{f.entity: map[string]any{"uid": uid}}, "service_response": map[string]any{f.entity: map[string]any{"uid": uid}},
@@ -309,3 +323,52 @@ func TestFindMarker(t *testing.T) {
} }
} }
} }
// TestCreateEventFallsBackWhenTheCalendarHasNoResponses proves the Local
// Calendar integration is usable: it rejects `return_response`, which is not a
// write failure.
func TestCreateEventFallsBackWhenTheCalendarHasNoResponses(t *testing.T) {
ha := newFakeHA(t, "calendar.school")
ha.rejectReturnResponse = true
c := testClient(t, ha.URL, "calendar.school")
start := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
uid, err := c.CreateEvent(context.Background(), Event{
Summary: "Concert", Start: &start, End: ptrTime(start.Add(time.Hour)),
Description: "source: " + Marker(29),
})
if err != nil {
t.Fatalf("CreateEvent: %v", err)
}
if uid != "" {
t.Errorf("want no uid, got %q", uid)
}
if ha.created() != 1 {
t.Fatalf("event was not written (%d create calls)", ha.created())
}
}
// TestParseHATime covers the shapes calendar.get_events reports.
func TestParseHATime(t *testing.T) {
timed := map[string]any{"start": "2026-10-15T15:05:00+11:00"}
got, raw := parseHATime(timed, "start")
if got == nil || raw != "2026-10-15T15:05:00+11:00" {
t.Fatalf("timed = %v %q", got, raw)
}
if got.Hour() != 15 || got.Minute() != 5 {
t.Errorf("timed = %v", got)
}
allDay := map[string]any{"start": "2026-10-15"}
got, raw = parseHATime(allDay, "start")
if got == nil || raw != "2026-10-15" {
t.Fatalf("all-day = %v %q", got, raw)
}
if y, m, d := got.Date(); y != 2026 || m != time.October || d != 15 {
t.Errorf("all-day = %v", got)
}
if got, raw := parseHATime(map[string]any{}, "start"); got != nil || raw != "" {
t.Errorf("missing = %v %q", got, raw)
}
}

View File

@@ -192,17 +192,25 @@ func Sync(ctx context.Context, st *store.Store, c *Client, opts Options, log *sl
continue continue
} }
if uid == "" { if uid == "" {
// Home Assistant did not report an event id. The marker in the // Home Assistant did not report an event id (the Local Calendar does
// description still makes the next run idempotent, but say so. // not answer with one). Use the description marker as the item's
log.Warn("home assistant returned no event id; relying on the description marker", // reference, so the item leaves the candidate set and is never
// written twice.
log.Warn("home assistant returned no event id; using the description marker as the item reference",
"item", first.ID, "child", children, "title", first.Title, "entity", c.Entity()) "item", first.ID, "child", children, "title", first.Title, "entity", c.Entity())
} }
for _, it := range g { for _, it := range g {
if err := st.MarkItemCalendarSynced(ctx, it.ID, uid, now); err != nil { ref := uid
if ref == "" {
ref = Marker(it.ID)
}
if err := st.MarkItemCalendarSynced(ctx, it.ID, ref, now); err != nil {
log.Error("mark calendar item", "item", it.ID, "err", err) log.Error("mark calendar item", "item", it.ID, "err", err)
} }
if _, err := st.RecordCalendarPush(ctx, it.ID, c.Entity(), ref, true, ""); err != nil {
log.Error("record calendar push", "item", it.ID, "err", err)
}
} }
recordPush(ctx, st, c.Entity(), g, uid, true, "", log)
res.Created++ res.Created++
created.UID = uid created.UID = uid
res.Events = append(res.Events, created) res.Events = append(res.Events, created)

View File

@@ -106,10 +106,10 @@ func TestSyncAdoptsAnEventByItsMarker(t *testing.T) {
// The event exists in Home Assistant, but the item has no id (a crashed // The event exists in Home Assistant, but the item has no id (a crashed
// run, or a Home Assistant that reports no uid). The marker adopts it. // run, or a Home Assistant that reports no uid). The marker adopts it.
ha.events = []map[string]any{{ ha.events = []map[string]any{{
"uid": "uid-existing", "uid": "uid-existing",
"summary": "Harry — Year 11 Assembly - HALL - CMA", "summary": "Harry — Year 11 Assembly - HALL - CMA",
"description": "source: " + Marker(item.ID), "description": "source: " + Marker(item.ID),
"start_date_time": due.Format(time.RFC3339), "start": due.Format(time.RFC3339),
}} }}
c := testClient(t, ha.URL, "calendar.school") c := testClient(t, ha.URL, "calendar.school")
@@ -290,3 +290,43 @@ func TestSyncWritesASchoolWideEventOnce(t *testing.T) {
} }
} }
} }
// TestSyncWithoutEventIDsStillNeverDuplicates proves the fallback path: when
// Home Assistant reports no event id, the item keeps the description marker as
// its reference, so a re-run writes nothing.
func TestSyncWithoutEventIDsStillNeverDuplicates(t *testing.T) {
ctx := context.Background()
st, childID := testStore(t)
due := time.Now().Add(4 * time.Hour).UTC().Truncate(time.Minute)
item := seedEvent(t, st, childID, "Year 11 Assembly - HALL", due, "high", "assembly")
ha := newFakeHA(t, "calendar.school")
ha.returnUID = false
c := testClient(t, ha.URL, "calendar.school")
first, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("first Sync: %v", err)
}
if first.Created != 1 || ha.created() != 1 {
t.Fatalf("first result = %+v (create calls %d)", first, ha.created())
}
ci, err := st.CalendarItemByID(ctx, item.ID)
if err != nil || ci == nil {
t.Fatalf("item: %v %v", ci, err)
}
if ci.HAEventUID != Marker(item.ID) {
t.Errorf("reference = %q, want the description marker %q", ci.HAEventUID, Marker(item.ID))
}
second, err := Sync(ctx, st, c, Options{Now: time.Now()}, silentLogger())
if err != nil {
t.Fatalf("second Sync: %v", err)
}
if second.Candidates != 0 || second.Created != 0 {
t.Fatalf("second result = %+v", second)
}
if ha.created() != 1 {
t.Fatalf("re-run duplicated the event (%d create calls)", ha.created())
}
}

View File

@@ -80,10 +80,17 @@ func eventIsSchoolEvent(title string) bool {
// fails, loudly, on a model error or an unparseable answer; it never silently // 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 // leaves a wrong label. Items are updated in place. now lets the model judge
// whether a due date is overdue. // whether a due date is overdue.
//
// Calendar events keep the deterministic rule. A model must not decide whether
// a class period is a "school event": the calendar write depends on that call
// (FR-8), and a wrong label there either spams the family calendar with every
// lesson or silently drops the notice they needed.
func ClassifyModel(ctx context.Context, c Completer, items []Item, model string, now time.Time) error { func ClassifyModel(ctx context.Context, c Completer, items []Item, model string, now time.Time) error {
if c == nil { if c == nil {
return fmt.Errorf("classify: no model client configured") return fmt.Errorf("classify: no model client configured")
} }
ClassifyDeterministic(items, now)
const system = "You classify school items for a parent's morning brief. " + const system = "You classify school items for a parent's morning brief. " +
"Reply with JSON only. No prose, no code fences." "Reply with JSON only. No prose, no code fences."
@@ -107,6 +114,9 @@ func ClassifyModel(ctx context.Context, c Completer, items []Item, model string,
if i >= len(batch) { if i >= len(batch) {
return fmt.Errorf("classify: items %d-%d: model returned %d labels for %d items", start, end-1, len(labels), len(batch)) return fmt.Errorf("classify: items %d-%d: model returned %d labels for %d items", start, end-1, len(labels), len(batch))
} }
if batch[i].Type == TypeEvent {
continue // events keep the deterministic rule
}
switch label { switch label {
case importanceHigh, importanceNormal, importanceLow: case importanceHigh, importanceNormal, importanceLow:
batch[i].Importance = label batch[i].Importance = label

View File

@@ -302,6 +302,35 @@ func TestClassifyDeterministic(t *testing.T) {
} }
} }
// TestClassifyModelLeavesEventsDeterministic proves a model cannot label a
// school event: the calendar write depends on that call (FR-8).
func TestClassifyModelLeavesEventsDeterministic(t *testing.T) {
at := time.Date(2026, 10, 15, 18, 0, 0, 0, time.UTC)
items := []Item{
{Type: TypeEvent, Title: "End of Year Senior Music Concert - HALL - KSH", DueAt: &at},
{Type: TypeEvent, Title: "5 - 11HLEGB5 - D8 - LHT", DueAt: &at},
{Type: TypeTask, Title: "Essay", DueAt: &at},
}
reply, _ := json.Marshal([]map[string]any{
{"i": 0, "importance": "low"},
{"i": 1, "importance": "high"},
{"i": 2, "importance": "low"},
})
c := &stubCompleter{reply: string(reply)}
if err := ClassifyModel(context.Background(), c, items, "test-model", at); err != nil {
t.Fatalf("ClassifyModel: %v", err)
}
if items[0].Importance != importanceHigh {
t.Errorf("school event = %q, want %q", items[0].Importance, importanceHigh)
}
if items[1].Importance != importanceNormal {
t.Errorf("class period = %q, want %q", items[1].Importance, importanceNormal)
}
if items[2].Importance != importanceLow {
t.Errorf("task = %q, want the model's %q", items[2].Importance, importanceLow)
}
}
func ptr(t time.Time) *time.Time { return &t } func ptr(t time.Time) *time.Time { return &t }
// TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school // TestClassifyDeterministicEvents proves the Phase 6 importance rule: a school

View File

@@ -97,14 +97,25 @@ try {
} }
} }
// 4. The scan banner on its own, cropped to the top of the dashboard. // 4. The scan banner on its own, cropped to the top of the dashboard. The
// class is shared with the calendar banner, so select by the labelled
// section, not by `.scan` alone.
await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); await page.goto(`${BASE}/`, { waitUntil: "networkidle" });
await page.waitForSelector(".scan", { timeout: 20000 }); const scanBanner = page.locator('.scan[aria-labelledby="scan-title"]');
await scanBanner.waitFor({ timeout: 20000 });
const bannerFile = path.join(OUT, "scan-banner.png"); const bannerFile = path.join(OUT, "scan-banner.png");
await page.locator(".scan").screenshot({ path: bannerFile }); await scanBanner.screenshot({ path: bannerFile });
shots.push(bannerFile); shots.push(bannerFile);
console.log(`shot ${bannerFile}`); console.log(`shot ${bannerFile}`);
// 5. The calendar banner (Phase 6, FR-8 / FR-12).
const calendarBanner = page.locator('.scan[aria-labelledby="calendar-title"]');
await calendarBanner.waitFor({ timeout: 20000 });
const calendarFile = path.join(OUT, "calendar-banner.png");
await calendarBanner.screenshot({ path: calendarFile });
shots.push(calendarFile);
console.log(`shot ${calendarFile}`);
await sleep(100); await sleep(100);
console.log(`done: ${shots.length} screenshot(s) in ${OUT}`); console.log(`done: ${shots.length} screenshot(s) in ${OUT}`);
} finally { } finally {