Skip to content

Next-meeting countdown (ember-meet tile + T-minus popup, server-side ICS) - #49

Merged
tarakanof merged 24 commits into
mainfrom
feat/meetings
Jun 13, 2026
Merged

tarakanof merged 24 commits into
mainfrom
feat/meetings

Conversation

@tarakanof

Copy link
Copy Markdown
Owner

Next-meeting countdown

A rotating ember-meet AWTRIX tile counting down to your next calendar meeting, plus a T-minus popup with chime. The server polls one or more secret ICS calendar feeds — so countdowns and popups keep working with the Mac asleep (unlike the EventKit/Reminders path).

Picked from the app backlog (shortlist #1). Spec + plan in the vault (Superpowers Specs/ember/2026-06-12-meeting-countdown-{design,plan}.md).

What it does

  • Tile — drawn calendar icon + native scrolling STANDUP 12m; joins the rotation only when the next meeting is within tile_lead_minutes (default 60), leaves at start. The minute countdown re-pushes via payload diff.
  • Popup — STANDUP IN 2M + chime at popup_lead_minutes before start (default 2; 0 = off). Edge-triggered, deduped per occurrence, grace window for a missed tick. Quiet hours mute the chime (audio only).
  • Meetings tab (macOS) — preview, enable + feed-count status, tile-lead/popup-lead steppers, chime toggle, upcoming-meetings list.

How it's built

  • New pure package internal/meetings — ICS parsing (arran4/golang-ical) + recurrence expansion (teambition/rrule-go): RRULE / EXDATE / RECURRENCE-ID overrides (incl. moved-in/out of the window), all-day + CANCELLED skipped, DST-correct. These are the server's 3rd/4th deps — the one deviation from stdlib-by-default, for correctness that isn't safely hand-rollable.
  • Server poller (cmd/ember/meetings_poll.go) — 1-min tick, 5-min fetch due-gate, 36h recurrence horizon, 60-min staleness guard so a meeting cancelled while feeds are unreachable can't ghost on screen. Per-feed failures are independent.
  • Coordinator ember-meet reconcile, extracted alongside weather/forecast/air into a shared reconcileTile clear/dedupe/push helper (the rule-of-three crossed at four copies).

Secrets

ICS feed URLs are credentials — they live only in EMBER_MEETINGS_ICS_URLS (comma-separated; webcal:// accepted), never in JSON, the store, logs, error strings, or any endpoint. The config API reports a count (ics_urls_configured) only; the tab shows the count and points at the env var.

Endpoints

  • GET/PUT /v1/meetings/config (bearer auth)
  • GET /v1/meetings/{preview,state} (open, read-only)
  • /admin/doctor gains a meetings line (feed count, freshness, next meeting — never a URL; a stale feed is a non-fatal WARN, doesn't 503)

Limitations

Declined-event filtering is best-effort (relies on the feed omitting them; only CANCELLED/all-day are actively skipped). Floating-time ICS values (no TZID/Z) fall back to server-local (UTC in the container). Calendars without an ICS export don't appear.

Test plan

  • go test ./... -race — green (incl. ICS fixtures: weekly RRULE+EXDATE+override, moved-in/out overrides, all-day/cancelled skips, Europe/Belgrade DST boundary; poller due-gate/partial-failure/staleness/URL-redaction; popup edge/dedupe/grace/back-to-back; coordinator reconcile; preview/state/doctor).
  • swift test --package-path macos — 114 tests green; Release xcodebuild BUILD SUCCEEDED.
  • Not yet deployed — release/deploy pending authorization.

tarakanof added 22 commits June 12, 2026 23:38
Calendar countdowns need to work when the Mac is asleep — the server
must own the feed-fetching and expansion. This package is the pure
logic layer: the upcoming poller (next task) feeds it fetched bytes.

Two deps earn their slot:
- github.com/arran4/golang-ical: handles RFC 5545 line-folding,
  property escaping, and multi-valued EXDATE correctly; hand-rolling
  that is a known error sink.
- github.com/teambition/rrule-go: RRULE expansion with timezone-aware
  DTStart, making the DST boundary test pass (09:00 CET/CEST stays
  at 09:00 local across the spring transition). Hand-rolling RRULE +
  DST is explicitly listed as a known-bad component in STYLE.md §11.
An override whose RECURRENCE-ID points to an in-window instance but whose new
DTSTART falls before the poll window start was incorrectly surfacing as the next
meeting. Thread `from` into applyOverride and reject ovStart.Before(from).

Also: promote golang-ical and rrule-go to direct dependencies in go.mod (go mod
tidy); fix unescapeText to use a single left-to-right scan so \\n yields a
literal backslash+n rather than backslash+space.
…ride with orphans

Recurring masters were expanded only for instances r.Between(from, until)
generates. An instance originally outside the window but rescheduled into it
via RECURRENCE-ID (e.g. "pull next week's meeting to tomorrow") was never
matched against any override key, so the meeting silently vanished. Fix: after
processing in-window instances, iterate unconsumed overrides for the same UID
and run each through applyOverride (which already window-filters and checks
STATUS:CANCELLED). An override is always the event's latest truth and takes
precedence regardless of any EXDATE on the original instant.

The orphan-override path (~25 lines) was a near-copy of applyOverride minus the
CANCELLED guard — a cancelled orphan override was surfaced as a real meeting.
Fix: replace the loop body with a call to applyOverride, eliminating the
duplication.

Also: Expand godoc notes malformed-event skipping; sortOccurrences / Merge gain
UID as a final tie-break (slices.SortFunc is unstable); unescapeText godoc notes
the \n→space lossy choice; self-contradictory comment in unescape_test.go fixed.
Calendar icon (neutral body + red binding rings) at cols 0–7 pairs with
native firmware text from col 9 (center:false + textOffset:9) — the same
device-verified layout as AirPopupPayload, so long meeting titles scroll
on-device without any extra config.

The chime is intentionally absent from MeetingPopupPayload: AWTRIX 0.98
silently drops sound/rtttl whenever the notification also carries a draw
or icon op; the coordinator will play it separately via /api/rtttl.

MeetingTileFrame is the preview-only path (GET /v1/meetings/preview): the
canvas cannot render native firmware text, so it draws the same icon plus
the 3×5 font text clipped at the right edge where the device would scroll.
ICS calendar URLs are credentials (possession grants calendar read
access). They must never appear in JSON responses, the store, logs, or
error strings. They live exclusively in EMBER_MEETINGS_ICS_URLS and are
parsed at startup; the config GET endpoint reports only a count via
ics_urls_configured, never the URLs themselves.

MeetingsConfig (enabled, tile_lead_minutes, popup_lead_minutes, chime)
is runtime-editable from the menu, persisted to the shared store, and
re-applied on /admin/reload. The applyMeetingsSettings write path
deliberately skips applyDefaults so popup_lead=0 (no popup) and
enabled=false are expressible and sticky.
…entries

Silent drops are operationally costly: a misconfigured EMBER_MEETINGS_ICS_URLS
entry (wrong scheme, trailing whitespace around webcal:// from a calendar
app's copy-paste) produces no feeds and no log output, leaving the operator
with no diagnostic signal. iCloud public-calendar links use webcal://, which
is identical to https:// for subscription purposes — dropping them silently
is a common foot-gun.

Changes:
- parseICSURLs now returns (urls []string, dropped int); scheme comparison
  is case-insensitive so HTTPS:// and Http:// are accepted verbatim.
- webcal:// and webcals:// are rewritten to https:// (iCloud/calendar-app
  subscription links).
- Call site in main.go emits a Warn log when dropped > 0 (never the URL
  values, which are credentials).
- Doc comment notes that literal commas in URLs are unsupported.
- Test: extended table covers uppercase, webcal, webcals, mixed valid/invalid,
  and the new dropped return value. TDD order: tests written → build failed →
  implementation → green.
- Minor hygiene: rename TestMeetingsConfigPutPersistsAndZeroSurvives →
  TestMeetingsConfigZeroSurvivesPut; replace _ = ensureStore with t.Fatalf.
- meetings_http.go comment anchored to StartMeetings/run().
Due-gate (lastFetch set on both success and failure) backs a failing
feed off the full 5-minute refresh interval rather than retrying every
tick. lastFetchOK tracks the last successful fetch; stale (>60 min)
suppresses both the tile and the popup so a gone-dark feed never ghosts
a cancelled meeting.

URL redaction: icsFetcher.fetch replaces *url.Error (which embeds the
credential URL) with a generic error string; pollMeetings logs only the
url_index integer, never the URL.

Adds: meetingsStore (next/fresh/snapshot), icsFetcher, pollMeetings,
checkMeetingPopup, sanitizeMeetingTitle, StartMeetings. App.meetings and
App.meetingsFetcher wired in NewApp; go StartMeetings added in main.
time/tzdata embedded for TZID resolution in the distroless container.
…he next

checkMeetingPopup previously called next() to get a single upcoming occurrence
and checked only that one. With a lead of up to 60 min, any second meeting whose
fire window opened while the first was still pending (or had the same start time)
was silently skipped: its window [start−lead, start−lead+grace) could pass
entirely unseen. The worst case: two back-to-back meetings < grace apart where
next() kept returning the first (not-yet-started) meeting every tick.

Replace the single next() call with a snapshot(now, 10) loop so every occurrence
whose window contains now is evaluated. 10 is a safe cap (>10 distinct meetings
inside one lead window is not a real calendar). The existing fired-map dedupe
makes repeated ticks idempotent.

Also fixes trailing-space after the 24-rune cap in sanitizeMeetingTitle: the
cap could expose a trailing space that the pre-cap TrimSpace never saw;
TrimRight(" ") after slicing handles it.
Lead-window membership (occ.Start-now ≤ TileLeadMinutes) gates whether the
ember-meet custom app is pushed or cleared. The minute-by-minute countdown
naturally re-pushes itself: the payload text changes each minute, so the
bytes-diff check that normally skips unchanged payloads doesn't fire, with
no dedicated timer needed. Clear block (single ClearApp, nil tracker) mirrors
the air/forecast/weather tiles. adoptDeviceManagedApps now seeds pushedMeeting
from any "ember-meet" entry already in the device loop on restart.
…eton

Rule-of-three crossed at four copies; invariants now live once; marshal
failure now logged.
- GET /v1/meetings/preview: open, renders live next meeting when store
  is fresh or falls back to a canned STANDUP 12m sample (never blank).
- GET /v1/meetings/state: open, returns up to 5 upcoming occurrences
  with RFC3339 whole-second timestamps (Swift .iso8601 compatible) and
  omits fetched_at when the store has never been fetched.
- checkMeetings doctor check: OK when unconfigured or feeds are fresh,
  WARN when URLs are set but never/stale-fetched; URLs never appear in
  any output.
- lastOK() RLock accessor on meetingsStore for the doctor check.
- StatusWarn CheckStatus constant + [WARN] marker in renderDoctorText.
…03 the doctor)

StatusWarn now leaves res.OK = true. Previously the OK loop tripped on any
non-OK status, so a meetings feed that hadn't been fetched yet (or had gone
stale after 60 min) flipped the overall flag — causing /admin/doctor to return
503 and `ember doctor` to exit 1 on the online path.

StatusSkipped still flips OK (offline mode is partial by design; the existing
TestRunDoctorChecks_OfflineMarksRuntimeSkipped contract is unchanged).

renderDoctorText now counts warns separately and includes them in the summary
line ("OK (N warning(s), online)") so the text output is consistent with the
exit code.

Comment fixes: enum comment updated to include "warn"; runDoctorChecks comment
no longer names a specific check count so it can't rot; CheckResult comment
drops the stale "eight" count.

Tests added (all written failing first):
- TestDoctorWarnIsNonFatal: runDoctorChecks with stale meetings URL → OK==true
- TestAdminDoctorWarnReturns200: /admin/doctor with warn check → HTTP 200, not 503
- TestRenderDoctorText_WarnSummary: one WARN and no FAILs → summary "1 warning(s)"
- TestCheckMeetingsStale: URLs set, lastFetchOK 61m old → StatusWarn with age in detail
When cfg.Meetings.Enabled is false the ICS poller never runs, so
lastOK is permanently zero.  checkMeetings now guards on the Enabled
flag before testing lastOK; a disabled widget with feeds configured
returns StatusOK with a "disabled" detail rather than a misleading
StatusWarn "never successfully fetched".

Added TestCheckMeetingsDisabledWithURLs (doctor_test.go:393) to cover
the new path.  Corrected the existing TestDoctorMeetingsCheck
"URLs + never fetched → WARN" sub-test in meetings_http_test.go to
explicitly set Enabled=true (defaultConfig leaves it false, so the
pre-existing test was only accidentally exercising the enabled path).

Also added a one-line comment on handleMeetingsState explaining why it
deliberately omits the fresh() gate that handleMeetingsPreview uses.
Older-server decode tolerance: every MeetingsConfig field uses
decodeIfPresent ?? default (same convention as WeatherConfig).
ics_urls_configured is server-reported read-only (present on encode,
ignored by the server on PUT). MeetingsState.Item.id is a computed
property — not encoded.
Adds a Meetings settings tab that mirrors WeatherTab's save/preview plumbing:
debounced PUT to /v1/meetings/config, live PanelPreview via /v1/meetings/preview,
SaveState status caption, and a reload toolbar button.

Sections: preview-over-black (meeting card), enable toggle with ICS feed status
(warns when icsUrlsConfigured==0, never exposes URLs), foldable Tile (lead stepper
5–480 min), foldable Popup (0=off lead stepper 0–60 min + chime + quiet-hours
note), and foldable Upcoming list (36-hour lookahead from /v1/meetings/state).

Wires the `meetings` pane into SettingsPane (after reminders, title "Meetings",
systemImage "calendar") and SettingsRootView.
…w refetch on save

Preview is state-driven not config-driven: the server ignores all config
params and returns the live next meeting or a canned sample.  Three fixes:
- Footer now truthfully says the preview won't react to option changes.
- refreshPreview(_ cfg:) loses the unused cfg param; scheduleSave() no
  longer calls it on every debounced write (pointless round-trip).
- fetchedAt is now surfaced as a caption row inside the Upcoming section
  so the user can judge whether the list is stale.
Stale-snapshot popup could fire a just-cancelled meeting: the old ordering
ran checkMeetingPopup before the fetch on due ticks, so a meeting removed
from the ICS feed would still fire once from the previous upcoming snapshot.

Reorder pollMeetings so the fetch+merge block runs first when a fetch is
due; checkMeetingPopup now always sees the freshest data on due ticks and
falls back to the existing store on non-due ticks (unchanged).

Also fix the merge-empty edge: replace the len(lists)>0 guard with an
anySuccess bool so a successful fetch that yields zero occurrences
(e.g. an empty VCALENDAR) correctly clears the store rather than silently
retaining stale data. All-failure keeps previous upcoming unchanged.

Test added: TestPollMeetingsRefreshesBeforePopup — fails against the old
ordering, passes after the reorder.
…apse

With stack:false, a second popup for a distinct meeting fired in the same
tick replaces the first on-device (AWTRIX stack:false semantics), silently
losing one notification. Change to stack:true so back-to-back meetings
whose lead windows overlap the same tick each queue individually.

Same-occurrence deduplication (the fired map keyed UID|start in
checkMeetingPopup) is unaffected: stack:true only lets DIFFERENT occurrences
queue; the same event still fires at most once.

Test updated: TestMeetingPopupPayloadShape now asserts stack == true.
@tarakanof

Copy link
Copy Markdown
Owner Author

Codex review pass — 2 High findings applied

Ran an independent Codex review of the diff. Two High-severity findings, both fixed on this branch:

  • Stale-snapshot popup on a due tick (c957b1e) — pollMeetings evaluated popups before the 5-min due fetch, so a meeting cancelled/moved in the just-arriving ICS could still fire once. Reordered to fetch→merge→popup on due ticks (popups still run every minute on non-due ticks). This also fixed a latent merge bug: a successful-but-empty calendar now correctly clears upcoming (cancelled-everything case) while all-fetches-failed still keeps the previous list via the 60-min staleness guard.
  • Simultaneous popups collapsing (bac8bc0) — MeetingPopupPayload used stack:false, so two distinct meetings firing in the same tick (the back-to-back lead-window loop) would replace each other on-device. Switched to stack:true (matching reminders/usage); same-occurrence dedupe stays handled caller-side by the fired map.

Codex's one Low finding (/state list id derived from title|start can alias two distinct same-title same-second meetings in the 5-item menu list) was left as-is — cosmetic, rare, and would need a UID added to the /state payload.

Both fixes TDD'd (tests fail before, pass after) and re-reviewed for regressions; go test ./... -race + 114 Swift tests green.

…'t alias in the menu list

title|start as the SwiftUI item id could silently drop a distinct meeting when
two occurrences from different feeds share the same title and whole-second start.
uid is the ICS VEVENT event identifier — not the secret feed URL — so exposing it
is safe. Swift Item.id is now uid|start (falls back to title|start when uid empty
for older-server payloads via decodeIfPresent ?? "").
@tarakanof

Copy link
Copy Markdown
Owner Author

Also addressed Codex's Low finding (1a8c195): GET /v1/meetings/state now includes the event uid, and the Swift menu list keys on uid|start instead of title|start — two distinct meetings sharing a title and start-second no longer alias (one row was silently dropping). The UID is the ICS VEVENT identifier (event metadata, like the already-exposed title), never the secret feed URL; the Go test guards that no uid contains ://. Older-server payloads without uid decode tolerantly and fall back to the prior title|start id. go test ./... -race + 116 Swift tests + Release build all green.

@tarakanof
tarakanof merged commit 1844a29 into main Jun 13, 2026
1 check passed
@tarakanof
tarakanof deleted the feat/meetings branch June 13, 2026 09:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant