Skip to content

feat(menu): native Dashboard with Pomodoro and agent statistics - #136

Merged
tarakanof merged 7 commits into
overhaul/ui-ng-2026-09from
feat/111-dashboard
Sep 26, 2026
Merged

tarakanof merged 7 commits into
overhaul/ui-ng-2026-09from
feat/111-dashboard

Conversation

@tarakanof

@tarakanof tarakanof commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #111

The Dashboard now shows statistics. It used to be a mirror, raw session rows and a Pomodoro block that repeated the menu. This replaces DashboardWindow's body with the design §2 card grid, built with Swift Charts and stock SwiftUI (GroupBox, Gauge, Table, ContentUnavailableView, FeedStateView).

Cards (in order)

# Card Size Shows
1 Clock wide Live LED mirror, the current app, Previous/Next/Dismiss, and a Display switch once /v1/clock/health reports matrix_power
2 Focus std Ring of today's sessions against the goal, or the running phase with a live countdown. Focus today, streak, best streak, 30-day completion
3 Usage std 5h/7d gauges per tool with reset time and per-model percentages. Falls back to the sessions' rate_window_pct on servers without GET /v1/usage. Hidden until some tool reports
4 Upcoming std Meetings and Apple reminders over the next 36 h, up to 5, relative time under 1 h. Hidden when both are off
5 Agents wide Table: Source · Tool · State badge · Activity · Context gauge · 5h · Updated. Waiting/error first. Header "N running · N waiting". Height fits the rows
6 Last 7 days std Focus bars with today highlighted, dashed goal line (goal sessions × focus length), hover callout
7 12 weeks std Weekly trend (area + line), dashed average, last-point value, hover callout
8 Work hours wide One span per day from first to last worked minute on an hour axis, red "now" tick, "1h 45m active · 5 sessions" per row
9 When you focus wide Weekday × hour heatmap in the locale's week order, with hover, next to a 12-week calendar strip. Header shows the peak slot
10 Agent time std Minutes per day stacked by Mac (producer colours), legend, today's total
11 Clock health std Battery, Wi-Fi (orange under −80 dBm), temperature/humidity, uptime, firmware (+ "x.y.z available"), publishes delivered in the last 24 h, last publish, current app, "Clock Web UI" link
12 Weather std Temperature in the server's units, condition, place, next-hours temperature line, AQI band, sunrise and sunset. Hidden when weather is off

Layout is 3/2/1 columns at ≥1040/≥700 pt. A wide card waits for a half-filled row to fill, which reproduces the §2.4 wireframe at 2 columns.

States

  • Every card renders through FeedStateView: redacted placeholder while loading, then loaded, empty, "Needs server 0.28" / "Pomodoro is off" with a Settings button, "Server unreachable", "Needs token", and a stale chip.
  • On a 0.27 server:
    • Agent time, Clock health and Weather say "Needs server 0.28".
    • Usage uses the sessions.
    • 12 weeks detects the missing weekly field and says "Needs server 0.28" rather than "No data yet".
  • Sparse data (a new user on day 3):
    • Axes never collapse; the minimum is 1 h.
    • The weekly trend keeps 4 weeks of context.
    • Work hours drops old empty days down to one week.
    • Agent time zero-fills to 7 days.

Data (Sources/EmberKit/Dashboard/, unit-tested)

  • DashboardLayout: columns and runs.
  • DayKey: day and ISO-week keys via Calendar, DST-safe.
  • WeekBars, WeeklyTrend: goal line, axis top, zero-fill.
  • HeatmapGrid, CalendarStrip: locale week order, ending on the server's logical today.
  • WorkHoursChart: wall-clock hours, so 09:00 is 9 on a 25-hour day, and past midnight goes above 24.
  • AgentTimeChart: stacked by source, fallback colours.
  • FocusSummary, UsageRow (with the /state fallback), UpcomingItem, AgentsTable, ClockHealthReadout, WeatherReadout.

These return numbers and dates, never user-facing text. That's why Localizable.xcstrings is untouched.

Refresh

  • One .task { await env.live.track(stats, usage, meetings, clockHealth, screen, activity, workhours, heatmap, weather) }. The hold ends when the window closes. No timers of its own.
  • The window also runs these reads once per server:
    • settings.pomodoro.load(): focus length for the goal line.
    • settings.meetings.load(): whether Upcoming shows.
    • GET /v1/device/config: the clock web URL.
  • ⌘R and the toolbar Refresh call live.refreshNow().

Accessibility

  • Every chart has an accessibilityLabel and an AXChartDescriptorRepresentable (audio graphs): last 7 days, 12 weeks, work hours, heatmap, agent time.
  • The mirror is one element: "Clock display", value = current app.
  • Gauges and stat cells combine into label/value pairs.
  • State badges carry text, so nothing is colour-only.
  • Numbers go through the locale's formatters.

Previews and visual check

  • Debug-only Dashboard/Preview/DashboardFixtures.swift builds 7 scenarios from the Go goldens in cmd/ember/testdata/dashboard plus deterministic synthetic history: established, new user, old server, Pomodoro off, stale, offline, loading.
  • DashboardPreviews.swift has a #Preview per card and per scenario.
  • I rendered the scenarios to PNG with an off-tree tool (NSHostingView + cacheDisplay, scratch dir, not committed) at 760/980/1200 pt, in light and dark, and iterated on them.
  • What the renders showed:
    • At 980 pt the grid matches the wireframe: Clock; Focus | Usage; Upcoming | Last 7 days; Agents; 12 weeks | Agent time; Work hours; When you focus; Clock health | Weather.
    • At 760 pt all 7 table columns fit.
    • Dark mode keeps the heat ramp readable.
  • Fixes that came from the renders:
    • Heatmap cells and work spans drew as hairlines on categorical axes. They now use numeric unit bands.
    • The calendar strip was squeezed out.
    • The last day's centred axis label was dropped.
    • Wide-card empty states were left-aligned.
    • The Agents table overflowed at 760 pt.
  • GitHub can't host the local PNGs.

Tests

  • swift test --package-path macos: 445 pass after rebasing onto feat(menu): glanceable menu-bar menu #134 and feat(menu): restructure Settings per macOS HIG #137. About 35 are new, in DashboardLayoutTests, DashboardChartsTests and DashboardCardModelsTests. They cover bucketing, goal lines, heatmap and strip grids, work-hour spans, stacking by source, DST boundaries (Amsterdam, 23- and 25-hour days, ISO week 53, year boundary), the usage fallback and upcoming merging.
  • xcodebuild … Debug CODE_SIGNING_ALLOWED=NO build and the Release ad-hoc build: BUILD SUCCEEDED, 0 source warnings.

Cross-ticket changes

  • Ember/Shared/FeedStateView.swift: a new offSymbol parameter (default "power", so existing callers are unchanged). The "Needs server 0.28" state uses arrow.up.circle.
  • Ember/EmberApp.swift: the Refresh command (⌘R) posts .emberRefreshRequested (one line). The Dashboard listens for it and reloads the configs it reads, which aren't live feeds.
  • Otherwise every change is in Ember/Dashboard/**, Sources/EmberKit/Dashboard/**, Tests/EmberKitTests/Dashboard*, and one paragraph of docs/ARCHITECTURE.md.
  • Localizable.xcstrings is untouched: EmberKit's dashboard code returns no user-facing text, so there's nothing to add by hand.

Review round (Opus review)

  • Re-renders.
    • Cards read through a DashboardSource. Each card is its own view that reads only its own feeds from LiveModel.
    • Cards that show relative time tick on a TimelineView: 60 s, or 10 min for the heatmap and 12 weeks.
    • Each card model is built once per body.
    • The mirror and the timer no longer invalidate the grid. A DEBUG render counter, driven by an observable fake in the snapshot tool, shows 5 mirror frames re-render only Clock (clock=5) and 5 timer ticks re-render only Focus (focus=5).
  • Occlusion. The feed hold is released while the window is minimised or fully covered (NSWindow.occlusionState) and re-acquired when it's visible again.
  • Plurals. Counts use automatic grammar agreement (^[\(n) day](inflect: true)), in the descriptors too.
  • Work hours. The axis fits the data: worked range ±1 h, at least 8 h, stretched to show now. The right-hand summary is .caption with the duration in primary.
  • Offline. When the server is offline and nothing ever loaded, the window shows one ContentUnavailableView with Try Again and Open Connection Settings.
  • 12 weeks. The average covers finished weeks only. The current week is drawn as a hollow point labelled "so far".
  • "Needs server 0.28" has a symbol and comes from one constant (ServerRequirement).
  • Clock card. The mirror and controls are one centred group.
  • Loading. Every card shows redacted sample data, charts included, with grey marks.
  • Times. Hours and times follow the user's 12/24-hour setting via Date.FormatStyle.
  • Heatmap peak ties go to the earliest slot in the locale's week, then the earliest hour. Covered by a test.
  • Colours.
    • The heatmap uses the accent, like the other focus charts.
    • Past-day bars are stronger in dark mode.
  • Refresh. Refresh and ⌘R also reload the Pomodoro and meetings configs and the clock web URL.
  • Upcoming. The unreachable off-state button is gone; the card is hidden when both meetings and reminders are off.

Needs on-device verification

  • Open ⌘0 against the live server. Cards should fill in, /v1/device/screen polling should stop on close, and the Display switch should toggle the matrix.
  • Check the hover callouts on the heatmap, 7-day and 12-week charts.
  • VoiceOver chart summaries and audio graphs.
  • The window-level toolbar glass. The renders are content-only.

@tarakanof tarakanof left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: #136 native Dashboard (closes #111)

Verdict: fix first. Nothing blocks the merge, but #1 and #2 are cheap and a user will notice both. The rest are polish.

What I checked:

  • swift test --package-path macos: 379 pass.
  • xcodegen + xcodebuild -scheme Ember -configuration Debug CODE_SIGNING_ALLOWED=NO build: BUILD SUCCEEDED, no new warnings.
  • gh pr checks 136: all green.
  • The author's renders: established at 760/980/1200, dark, new user, old server, offline, loading, stale.

Also verified, and fine as written:

  • The card list, order and sizes match design §2.3.
  • "Needs server 0.28" is correct: the latest tag is v0.27.1, so the integration branch ships as 0.28.0.
  • The focus and calendar Settings deep links match the #112 spec's SettingsPane cases (design line 231). On the current enum they fall back to Connection, as the PR says.
  • track() is a single refcounted hold with defer release, and it's tied to the Window scene's .task. Closing the window tears down the content, which cancels the task and releases the hold. Settings relies on the same teardown for onDisappear.
  • Clock actions refresh .clockHealth and .screen afterwards (ActionRunner.feeds), so the Display switch settles within one round trip.
  • Previews and fixtures are #if DEBUG only.
  • Localisation extraction works. SWIFT_EMIT_LOC_STRINGS=YES produced .stringsdata for every Dashboard file, including all the card titles, empty/off copy and chart descriptors. A command-line build doesn't write them back into Localizable.xcstrings, though; that sync only happens in the Xcode IDE. English falls back to the key, so nothing is broken at runtime. See #2 for the gap this leaves.

Should-fix

  1. The whole grid re-renders on every screen or state tick. DashboardWindow.data reads live.screen (1 s), live.snapshot (3 s) and live.pomodoro (3 s) in the window's body, and it stamps now: Date() into DashboardData.

    • Any change to any feed therefore invalidates DashboardWindow. Every card then gets a new now, so none of them compare equal.
    • Each time, the heatmap (168 cells plus 84 strip cells, each with clipShape), work hours, the 12-week chart and the rest rebuild their EmberKit transforms and their Charts.
    • WhenYouFocusCard.feed is a computed property read three times per body (body once, accessory twice), so HeatmapGrid and CalendarStrip are built three times per pass.
    • screen changes at 1 Hz whenever the clock animates (the #101 bot, notifications), so this is sustained 1 Hz work for as long as the window is open.
    • Fix: give the mirror its own subview that reads env.live.screen itself. Keep now out of the value passed to the chart cards (use a minute-granular TimelineView, or pass now only to the cards that show relative time). Build each card's model once per body.
  2. Plurals are missing. Several interpolated count strings will show "1 days" or "1 sessions":

    • "\(s.streak) days" (Streak)
    • "\(done) sessions today"
    • "Goal: \(goal) sessions a day"
    • "… active · \(r.sessions) sessions" (Work hours)
    • "\(m.strip.activeDays) active days"
    • "\(c.rows.count) days"
    • The hover callouts' "· \(sessions) sessions"

    The catalog has no plural variations, and the CLI build won't add any (see above). A new user sees "1 days" on day 1. Use ^[\(n) day](inflect: true) or add plural variants to Localizable.xcstrings.

  3. Work hours x-axis: fit it to the data (the orchestrator's (b) — confirmed).

    • WorkHoursChart uses lo = min(6, …) and hi = max(24, …), so the axis always runs 06–24. Typical 08–17 days leave the right third empty.
    • Suggest floor(min start) − 1 … ceil(max end) + 1, clamped to 0…30 with a minimum 8 h span. Extend it so the now tick stays in range.
    • The trailing "1h 45m active · 5 sessions" column is .caption2 + .secondary at a trailing-axis position, which is hard to read (visible in every render). Use .caption with .primary for the duration, or move the text into an annotation at the end of each bar, as the §2.4 wireframe shows.
  4. Offline repeats itself 11 times. With nothing cached, every card shows the same "Server unreachable" (see dash-offline-980.png). HIG leans towards one window-level ContentUnavailableView (with Retry and Connection Settings), like the existing .unconfigured branch, once every feed is .failed(.offline, last: nil). Per-card states stay for partial failures. (Design §2.5 asks for per-card, so this is a design follow-up rather than a spec violation.)

Nits

  1. 12 weeks: the smoothing is already monotone (the orchestrator's (a) — not confirmed). Both AreaMark and LineMark use .interpolationMethod(.monotone), which doesn't overshoot between points. Two smaller points:
    • The last point is the current, partial week, and it's included in averageMinutes. That pulls the average down early in the week. Consider leaving the in-progress week out of the average, or styling its point as partial.
    • serverLacksWeekly can't flag a 0.27 server when nobody focused in the last 7 days. That case shows "No data yet". Acceptable, but worth a comment.
  2. Off/empty cards use the full card height (the orchestrator's (c), partly). Row heights are fixed so neighbours line up, so a compact height only works when the whole row is off (e.g. 12 weeks | Agent time and Clock health | Weather on a 0.27 server). The "Needs server 0.28" states also have no symbol, while every other empty state does (dash-oldserver-980.png). Add one (e.g. arrow.up.circle), and consider collapsing a row whose cards are all off.
  3. Clock card leaves a large empty area (the orchestrator's (d) — confirmed). mirror is .frame(maxWidth: .infinity) around a fixed-aspect matrix, and the controls are pinned to 170 pt on the far right. At 980 and 1200 pt that leaves wide gaps on both sides of the mirror. Lead-align the mirror and put the controls next to it. Or use the space for something useful: the next-app queue, or the health readout's battery and Wi-Fi.
  4. Loading states are inconsistent. Focus, Usage and Upcoming show redacted placeholders; the chart cards and Agents show a spinner (dash-loading-980.png). Design §2.5 asks for redacted sample data everywhere.
  5. Hours are hard-coded to 24-hour format. String(format: "%02d") is used for the heatmap and work-hours axis labels, and "Peak \(label) \(String(format: "%02d:00", …))" for the peak. en_US users get 00–23 and "Peak Mon 11:00". Use Date.FormatStyle .hour() on a reference date.
  6. HeatmapGrid.peak's tie-break doesn't match its doc. The doc says "earliest in the week order", but the code compares id = weekday*24+hour, which is Sunday-based. In a Monday-first locale, a Sunday tie wins over Monday. No test covers ties.
  7. The palette is inconsistent. The heatmap and strip are blue (HeatScale) while every other focus chart uses the accent green. In dark mode, the 0.5-opacity past-day bars in Last 7 days and Work hours have low contrast (dash-newuser-980-dark.png).
  8. The toolbar Refresh and ⌘R don't re-run loadConfigs(). A focus-length change made in the web UI only reaches the goal line when the server changes or the window reopens.
  9. "Needs server 0.28" is hard-coded in four files. Put it in one constant, or a "Needs server %@" format, so the next bump is a one-line change.
  10. Upcoming's offSettingsPane: "calendar" can never show. The card is hidden whenever both meetings and reminders are off (showsUpcoming), so the button is unreachable.

Notes (no action)

  • Request volume while open is about 110 requests/min to the server, dominated by /v1/device/screen at 1 s. It matches §2.6. Each screen read is a server→clock hop over a link that already loses packets, and a minimised window keeps holding every feed, because scenePhase doesn't reflect occlusion on macOS. Consider releasing .screen while the window is miniaturised or occluded (NSWindow.occlusionState).
  • Swift Charts at 252 heatmap marks is fine on its own. The cost comes from how often it re-renders (#1), not from the mark count.
  • Accessibility coverage matches §2.7:
    • The mirror is one element with a label and a value.
    • The ring, stat cells and gauges are combined into label/value pairs.
    • Five charts have AXChartDescriptorRepresentables.
    • State badges carry text.
    • Not verified with VoiceOver on a device.

The native Dashboard (#111) needs its data shaped for Swift Charts and
its card logic testable without a window. Everything that decides what a
card shows lives here, so the views only lay it out:

- DashboardLayout: 3/2/1 columns by width, and card runs that let a wide
  card wait until the row above it is full (the §2.4 wireframe).
- DayKey: server day and ISO-week keys to dates via Calendar arithmetic,
  so 23/25-hour DST days land on the right key.
- WeekBars / WeeklyTrend: last 7 days with the goal line
  (sessions x focus length), 12 zero-filled weeks; axes never collapse and
  a new user's single week keeps 4 weeks of context.
- HeatmapGrid / CalendarStrip: weekday x hour in the locale's week order,
  and the 12-week consistency strip ending on the server's logical today.
- WorkHoursChart: wall-clock spans (09:00 is 9 on a DST day, past
  midnight continues above 24), old empty days trimmed to a week.
- AgentTimeChart: per-day minutes stacked by source, zero-filled to 7
  days, producer colours with stable fallbacks.
- FocusSummary, UsageRow (incl. the /state fallback for old servers),
  UpcomingItem, AgentsTable, ClockHealthReadout.

None returns user-facing text, so the string catalog is untouched.
Two more card decisions moved out of the views so they're tested:

- WeatherReadout: European AQI bands, °C/°F from the server's units,
  and the next hours of the temperature series for the card's line.
- WeeklyTrend.serverLacksWeekly: a pre-0.28 server omits `weekly`,
  which decodes as empty. Focus in the 7-day history with no weeks means
  the server is too old, so the 12-week card says "Needs server 0.28"
  instead of a misleading "No data yet".
The old dashboard was a mirror, raw session rows and a Pomodoro block
that repeated the menu, with one line of stats and a browser link. The
user wants statistics. This replaces DashboardWindow's body with the §2
card grid on Swift Charts and stock SwiftUI:

- Clock: the real LED mirror, the current app, previous/next/dismiss,
  and a display power switch once the server reports it.
- Focus: a ring of today's sessions against the goal, or the running
  phase and its countdown; focus time, streak, best, 30-day completion.
- Usage (5h/7d gauges, reset time, per-model), with the /state
  fallback for servers without GET /v1/usage.
- Upcoming meetings and reminders; Agents as a Table sorted by what
  needs attention.
- Last 7 days (bars + goal line), 12 weeks (trend + average), Work hours
  (daily spans, now marker), When you focus (weekday x hour heatmap +
  12-week strip), Agent time (stacked by Mac), Clock health, Weather.

Every card reads plain values (DashboardData) and renders through
FeedStateView, so loading, empty, off, offline, 401 and stale look the
same everywhere, and cards render from fixtures without the app.
Grid charts use numeric unit bands: on categorical axes Swift Charts
drew the heatmap cells and work spans as hairlines. Feeds are held with
one .task while the window is open; the window also loads the Pomodoro
and meetings configs (focus length for the goal line, whether Upcoming
shows) and the clock's web address, read-only. Hover callouts on the
7-day, 12-week and heatmap charts; every chart has an accessibility
label and a chart descriptor (audio graphs).
Charts have to hold up for a new user with two days of history and for a
0.27 server, which nobody sees on a live setup. Debug-only fixtures
build every scenario (established, new user, old server, Pomodoro off,
stale, offline, loading) from the Go goldens in
cmd/ember/testdata/dashboard plus deterministic synthetic history, and
there's a #Preview per card and per scenario. The same fixtures drive
the off-tree ImageRenderer/NSHostingView snapshots used to tune the
layout; EMBER_REPO_ROOT points a copied build at the goldens.
ARCHITECTURE listed the Dashboard read API but not the window that reads it: which cards exist, where the transforms live, and how old servers degrade.
Review of #136:
- Work hours always ran 06-24, so an 08-17 day left a third of the card
  empty. The axis now spans the worked range padded by an hour, stretched
  to show now on today's row, at least 8 hours wide.
- The 12-week average included the current, still-filling week and was
  pulled down early in the week; it now covers finished weeks only.
- HeatmapGrid.peak broke ties by a Sunday-based id, contradicting its doc;
  it now picks the earliest cell in the locale's week order, then hour.
Review of #136.

Rendering: the window built one DashboardData from every feed and
stamped Date() into it, so the 1 s mirror or the 3 s timer re-rendered
all twelve cards and rebuilt the heatmap each time. Cards now read
through a DashboardSource: each card is its own view that reads only its
feeds from LiveModel, cards showing relative time tick on a TimelineView,
and each model is built once per body. A DEBUG render counter confirms
it: five mirror frames re-render only the Clock card, five timer ticks
only Focus.

Feeds: the hold is released while the window is minimised or fully
covered (NSWindow occlusion); SwiftUI's scenePhase stays active on macOS,
so the 1 s mirror reads kept crossing the lossy server-to-clock link.

Polish:
- Counts use automatic grammar agreement ("1 day", "5 sessions").
- One window-level "Server unreachable" (Try Again, Connection
  Settings) when the server is offline and nothing ever loaded, instead of
  the same message on eleven cards.
- "Needs server 0.28" has a symbol and one constant.
- Clock card: mirror and controls as one centred group.
- Loading shows redacted sample data on every card, charts included.
- Hours and times follow the user's 12/24-hour setting.
- Heatmap uses the accent like the other focus charts; past-day bars
  are stronger in dark mode.
- Work hours summary is larger with the duration in primary.
- 12-week current week is a hollow point labelled "so far".
- Refresh and Cmd-R reload the Pomodoro/meetings configs and the clock
  address too.
- Upcoming drops its unreachable off state (the card is hidden then).

Cross-ticket: FeedStateView gains `offSymbol` (default "power"), and the
app's Refresh command posts `.emberRefreshRequested` (one line) so the
Dashboard can reload what isn't a live feed.
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