Repository navigation
Next-meeting countdown (ember-meet tile + T-minus popup, server-side ICS) - #49
Conversation
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.
Codex review pass — 2 High findings appliedRan an independent Codex review of the diff. Two High-severity findings, both fixed on this branch:
Codex's one Low finding ( Both fixes TDD'd (tests fail before, pass after) and re-reviewed for regressions; |
…'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 ?? "").
|
Also addressed Codex's Low finding ( |
Next-meeting countdown
A rotating
ember-meetAWTRIX 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
STANDUP 12m; joins the rotation only when the next meeting is withintile_lead_minutes(default 60), leaves at start. The minute countdown re-pushes via payload diff.STANDUP IN 2M+ chime atpopup_lead_minutesbefore start (default 2; 0 = off). Edge-triggered, deduped per occurrence, grace window for a missed tick. Quiet hours mute the chime (audio only).How it's built
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 +CANCELLEDskipped, 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.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.ember-meetreconcile, extracted alongside weather/forecast/air into a sharedreconcileTileclear/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/doctorgains 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; ReleasexcodebuildBUILD SUCCEEDED.