Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
20b20c7
feat(meetings): ICS parse + recurrence expansion package
tarakanof Jun 12, 2026
53afb25
fix(meetings): window-filter moved overrides; go mod tidy
tarakanof Jun 12, 2026
f9e3b6a
fix(meetings): apply overrides moved into the window; share applyOver…
tarakanof Jun 12, 2026
f772337
feat(render): ember-meet tile, popup and preview frame
tarakanof Jun 12, 2026
a496907
fix(render): uppercase meeting preview text (font3x5 has no lowercase)
tarakanof Jun 12, 2026
258671d
docs(render): correct the calendar-sprite comment (it has date dots)
tarakanof Jun 12, 2026
4340ad3
feat(meetings): config endpoint, env-only ICS URLs, persistence
tarakanof Jun 12, 2026
2cf5f0a
fix(meetings): accept webcal/uppercase schemes, warn on dropped feed …
tarakanof Jun 12, 2026
7583f9a
feat(meetings): ICS poller, staleness guard, T-minus popup
tarakanof Jun 12, 2026
abc3859
fix(meetings): pop every meeting whose lead window is due, not just t…
tarakanof Jun 12, 2026
31ff80c
feat(meetings): ember-meet rotating tile via coordinator reconcile
tarakanof Jun 12, 2026
6effdb5
test(meetings): make the stale-tile test actually exercise the freshn…
tarakanof Jun 12, 2026
3796790
refactor(coordinator): extract the shared tile clear/dedupe/push skel…
tarakanof Jun 12, 2026
b504c67
feat(meetings): preview + state endpoints, doctor check
tarakanof Jun 12, 2026
e655605
fix(doctor): warn status is non-fatal (stale meetings feed must not 5…
tarakanof Jun 13, 2026
f19b32d
fix(doctor): meetings check is OK (not warn) when the widget is disabled
tarakanof Jun 13, 2026
9c8a13e
feat(emberkit): MeetingsConfig/State models + service
tarakanof Jun 13, 2026
f3f3daa
feat(macos): Meetings tab (preview, lead steppers, chime, upcoming list)
tarakanof Jun 13, 2026
5052021
fix(macos): correct the meetings preview footer; drop the dead previe…
tarakanof Jun 13, 2026
aa980f3
docs: meetings (next-meeting countdown) — architecture, runbook, endp…
tarakanof Jun 13, 2026
c957b1e
fix(meetings): refresh feeds before evaluating popups on a due tick
tarakanof Jun 13, 2026
bac8bc0
fix(render): meeting popups stack so simultaneous meetings don't coll…
tarakanof Jun 13, 2026
1a8c195
fix(meetings): include event UID in /state so same-title meetings don…
tarakanof Jun 13, 2026
2f43f48
Merge branch 'main' into feat/meetings
tarakanof Jun 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,13 @@ The deep dive is [`docs/STYLE.md`](docs/STYLE.md). Repository essentials:
Write (bearer auth): `POST /v1/status`, `DELETE /v1/status`, `POST /v1/clear`,
`POST /v1/notify`, `POST /v1/pomodoro/{start,pause,resume,stop,skip}`,
`GET/PUT /v1/pomodoro/config`, `GET/PUT /v1/weather/config`,
`POST /v1/reminders/fire`, `GET/PUT /v1/device/config`, `GET /v1/device/discover`,
`POST /v1/reminders/fire`, `GET/PUT /v1/meetings/config`,
`GET/PUT /v1/device/config`, `GET /v1/device/discover`,
`GET/PUT /v1/device/settings`, `GET/PUT /v1/device/sensors` (dev.json temp/hum
offsets; PUT reboots the clock), `GET/PUT /v1/quiet/config`, `GET /v1/device/stats`,
`POST /v1/device/{reboot,notify/dismiss}`. Read (no auth): `GET /state`, `GET /healthz`,
`GET /v1/preview`, `GET /v1/{weather,pomodoro,reminders}/preview`,
`GET /v1/meetings/{preview,state}`,
`GET /v1/pomodoro/{state,stats,heatmap,workhours}`,
`GET /v1/pomodoro/dashboard` (HTML). Operator: `/admin/doctor`, `/admin/reload`,
`/version`, `/metrics`. Device-only (unauthenticated): `POST /hooks/awtrix/button`
Expand Down
2 changes: 2 additions & 0 deletions cmd/ember/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,8 @@ func handleAdminReload(app *App) http.HandlerFunc {
// Likewise re-apply menu-persisted weather settings over the
// freshly reloaded file config.
app.loadPersistedWeatherSettings()
// And meetings settings (same pattern: menu edits must survive a reload).
app.loadPersistedMeetingsSettings()
// And the menu-chosen clock URL (Device tab), so a reload doesn't drop
// the store override back to the file-config baseline.
app.loadPersistedDeviceBaseURL()
Expand Down
178 changes: 85 additions & 93 deletions cmd/ember/coordinator.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ type coordinator struct {
pushedForecast *pushedUsageApp
pushedAir *pushedUsageApp

// meetings, when non-nil, holds the upcoming occurrences. reconcileMeetingApp
// pushes/refreshes/clears the "ember-meet" countdown tile from it, tracked by
// pushedMeeting (same change-and-staleness logic as the weather tiles).
meetings *meetingsStore
pushedMeeting *pushedUsageApp

// adoptedApps records whether we've seeded the push trackers from the
// device's actual app loop yet (once per process, on the first reachable
// tick). Until then ember-managed apps left on the device by a previous run
Expand Down Expand Up @@ -523,6 +529,7 @@ func (c *coordinator) onTick() {
c.reconcileWeatherApp(c.clk.Now())
c.reconcileForecastApp(c.clk.Now())
c.reconcileAirApp(c.clk.Now())
c.reconcileMeetingApp(c.clk.Now())
c.checkLimitAlarms(c.clk.Now(), snap)
}

Expand Down Expand Up @@ -562,6 +569,10 @@ func (c *coordinator) adoptDeviceManagedApps() bool {
if c.pushedAir == nil {
c.pushedAir = &pushedUsageApp{}
}
case name == "ember-meet":
if c.pushedMeeting == nil {
c.pushedMeeting = &pushedUsageApp{}
}
case strings.HasPrefix(name, "ember-usage-"):
if c.pushedUsageApps == nil {
c.pushedUsageApps = map[string]pushedUsageApp{}
Expand All @@ -579,60 +590,68 @@ func (c *coordinator) adoptDeviceManagedApps() bool {
// leave a stale temperature on the device indefinitely.
const weatherTileStaleTTL = 30 * time.Minute

// reconcileWeatherApp pushes/refreshes the single "ember-weather" rotating tile
// when the feature is enabled, set to rotate, and has a fresh observation; it
// clears the tile otherwise. Unchanged payloads pushed recently are skipped
// (change-and-staleness dedupe). Runs on the coordinator goroutine only.
func (c *coordinator) reconcileWeatherApp(now time.Time) {
if c.weather == nil {
return
}
// reconcileTile owns the shared clear/dedupe/push state machine for the
// standalone rotating tiles (weather/forecast/air/meeting). The per-tile
// decisions — whether the tile should be on the device and what it shows —
// stay at the call sites: want gates, buildPayload runs only when want is
// true. tracker is the per-tile *pushedUsageApp pointer-to-pointer so this
// helper can nil it on clear. Coordinator goroutine only.
func (c *coordinator) reconcileTile(now time.Time, name string, tracker **pushedUsageApp, want bool, buildPayload func() map[string]any) {
ctx := c.ctx
if ctx == nil {
ctx = context.Background()
}
const name = "ember-weather"
cfg := c.loadCfg().Weather
obs, have := c.weather.current()
want := cfg.Enabled && cfg.RotateInApps && have && now.Sub(obs.FetchedAt) < weatherTileStaleTTL

if !want {
if c.pushedWeather != nil {
if *tracker != nil {
if err := c.publisher.ClearApp(ctx, name); err != nil {
c.logger.Warn("weather tile clear failed", "err", err)
c.logger.Warn("tile clear failed", "app", name, "err", err)
return
}
c.pushedWeather = nil
*tracker = nil
}
return
}

tempText := weatherTempText(obs.TempC, cfg.Units)
window := forecastWindow(obs.Hourly, cfg.ForecastHours)
var payload map[string]any
switch {
case cfg.MoonPhase && obs.Condition == render.WeatherClear &&
(cfg.Latitude != 0 || cfg.Longitude != 0) && isNight(cfg.Latitude, cfg.Longitude, now):
// Moon wins over native icons — there is no per-phase gallery set.
illum, waxing := moonIllumination(now)
payload = render.WeatherPayloadMoon(tempText, window, render.MoonView{Illum: illum, Waxing: waxing}, usageAppLifetime)
case cfg.TileNativeIcons:
payload = render.WeatherPayloadNative(cfg.weatherIconID(obs.Condition), tempText, window, usageAppLifetime)
default:
payload = render.WeatherPayload(obs.Condition, tempText, window, usageAppLifetime)
}
payload := buildPayload()
body, err := json.Marshal(payload)
if err != nil {
c.logger.Warn("tile payload marshal failed", "app", name, "err", err)
return
}
if c.pushedWeather != nil && bytes.Equal(c.pushedWeather.body, body) && now.Sub(c.pushedWeather.at) < usageRefreshInterval {
if *tracker != nil && bytes.Equal((*tracker).body, body) && now.Sub((*tracker).at) < usageRefreshInterval {
return
}
if err := c.publisher.CustomApp(ctx, name, payload); err != nil {
c.logger.Warn("weather tile publish failed", "err", err)
c.logger.Warn("tile publish failed", "app", name, "err", err)
return
}
*tracker = &pushedUsageApp{body: body, at: now}
}

// reconcileWeatherApp pushes/refreshes the single "ember-weather" rotating tile
// when the feature is enabled, set to rotate, and has a fresh observation; it
// clears the tile otherwise. Coordinator goroutine only.
func (c *coordinator) reconcileWeatherApp(now time.Time) {
if c.weather == nil {
return
}
c.pushedWeather = &pushedUsageApp{body: body, at: now}
cfg := c.loadCfg().Weather
obs, have := c.weather.current()
want := cfg.Enabled && cfg.RotateInApps && have && now.Sub(obs.FetchedAt) < weatherTileStaleTTL
c.reconcileTile(now, "ember-weather", &c.pushedWeather, want, func() map[string]any {
tempText := weatherTempText(obs.TempC, cfg.Units)
window := forecastWindow(obs.Hourly, cfg.ForecastHours)
switch {
case cfg.MoonPhase && obs.Condition == render.WeatherClear &&
(cfg.Latitude != 0 || cfg.Longitude != 0) && isNight(cfg.Latitude, cfg.Longitude, now):
// Moon wins over native icons — there is no per-phase gallery set.
illum, waxing := moonIllumination(now)
return render.WeatherPayloadMoon(tempText, window, render.MoonView{Illum: illum, Waxing: waxing}, usageAppLifetime)
case cfg.TileNativeIcons:
return render.WeatherPayloadNative(cfg.weatherIconID(obs.Condition), tempText, window, usageAppLifetime)
default:
return render.WeatherPayload(obs.Condition, tempText, window, usageAppLifetime)
}
})
}

// forecastWindow returns the first `hours` hourly temps (hours clamped to a sane
Expand All @@ -652,91 +671,64 @@ func forecastWindow(hourly []float64, hours int) []float64 {

// reconcileForecastApp pushes/refreshes the standalone "ember-forecast" tile
// (hourly temperature bars) when weather is enabled, the forecast tile is turned
// on, and we have fresh hourly data; clears it otherwise. Same change-and-
// staleness dedupe as reconcileWeatherApp. Coordinator goroutine only.
// on, and we have fresh hourly data; clears it otherwise. Coordinator goroutine only.
func (c *coordinator) reconcileForecastApp(now time.Time) {
if c.weather == nil {
return
}
ctx := c.ctx
if ctx == nil {
ctx = context.Background()
}
const name = "ember-forecast"
cfg := c.loadCfg().Weather
obs, have := c.weather.current()
hourly := forecastWindow(obs.Hourly, cfg.ForecastHours)
want := cfg.Enabled && cfg.ForecastTile && have && len(hourly) > 0 &&
now.Sub(obs.FetchedAt) < weatherTileStaleTTL

if !want {
if c.pushedForecast != nil {
if err := c.publisher.ClearApp(ctx, name); err != nil {
c.logger.Warn("forecast tile clear failed", "err", err)
return
}
c.pushedForecast = nil
}
return
}

payload := render.ForecastPayload(hourly, usageAppLifetime)
body, err := json.Marshal(payload)
if err != nil {
return
}
if c.pushedForecast != nil && bytes.Equal(c.pushedForecast.body, body) && now.Sub(c.pushedForecast.at) < usageRefreshInterval {
return
}
if err := c.publisher.CustomApp(ctx, name, payload); err != nil {
c.logger.Warn("forecast tile publish failed", "err", err)
return
}
c.pushedForecast = &pushedUsageApp{body: body, at: now}
c.reconcileTile(now, "ember-forecast", &c.pushedForecast, want, func() map[string]any {
return render.ForecastPayload(hourly, usageAppLifetime)
})
}

// reconcileAirApp pushes/refreshes the standalone "ember-air" tile (current
// European AQI + hourly trend strip) when weather is enabled, the air tile is
// turned on, and the air observation is fresh; clears it otherwise. Same
// change-and-staleness dedupe as the other weather tiles. Coordinator
// turned on, and the air observation is fresh; clears it otherwise. Coordinator
// goroutine only.
func (c *coordinator) reconcileAirApp(now time.Time) {
if c.weather == nil {
return
}
ctx := c.ctx
if ctx == nil {
ctx = context.Background()
}
const name = "ember-air"
cfg := c.loadCfg().Weather
air, have := c.weather.currentAir()
want := cfg.Enabled && cfg.AirTile && have && now.Sub(air.FetchedAt) < weatherTileStaleTTL
c.reconcileTile(now, "ember-air", &c.pushedAir, want, func() map[string]any {
return render.AirPayload(air.AQI, air.HourlyAQI, usageAppLifetime)
})
}

if !want {
if c.pushedAir != nil {
if err := c.publisher.ClearApp(ctx, name); err != nil {
c.logger.Warn("air tile clear failed", "err", err)
return
}
c.pushedAir = nil
}
return
// meetingMinutes is the displayed whole-minute countdown: ceil(remaining), min 1.
// The tile never shows "0m" — it leaves the rotation at start.
func meetingMinutes(now, start time.Time) int {
m := int((start.Sub(now) + time.Minute - 1) / time.Minute)
if m < 1 {
m = 1
}
return m
}

payload := render.AirPayload(air.AQI, air.HourlyAQI, usageAppLifetime)
body, err := json.Marshal(payload)
if err != nil {
return
}
if c.pushedAir != nil && bytes.Equal(c.pushedAir.body, body) && now.Sub(c.pushedAir.at) < usageRefreshInterval {
// reconcileMeetingApp pushes/refreshes the standalone "ember-meet" countdown
// tile when meetings are enabled, the next meeting is inside the lead window,
// and the feed data is fresh; clears it otherwise (including at meeting start —
// the countdown never shows 0m). The minute-by-minute countdown needs no timer:
// the payload text changes each minute, so the bytes-diff naturally re-pushes.
// Coordinator goroutine only.
func (c *coordinator) reconcileMeetingApp(now time.Time) {
if c.meetings == nil {
return
}
if err := c.publisher.CustomApp(ctx, name, payload); err != nil {
c.logger.Warn("air tile publish failed", "err", err)
return
}
c.pushedAir = &pushedUsageApp{body: body, at: now}
cfg := c.loadCfg().Meetings
occ, ok := c.meetings.next(now)
want := cfg.Enabled && ok && c.meetings.fresh(now) &&
occ.Start.Sub(now) <= time.Duration(cfg.TileLeadMinutes)*time.Minute
c.reconcileTile(now, "ember-meet", &c.pushedMeeting, want, func() map[string]any {
return render.MeetingPayload(sanitizeMeetingTitle(occ.Title), meetingMinutes(now, occ.Start), usageAppLifetime)
})
}

const (
Expand Down
Loading