Repository navigation
feat(menu): native Dashboard with Pomodoro and agent statistics - #136
Conversation
tarakanof
left a comment
There was a problem hiding this comment.
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
focusandcalendarSettings deep links match the #112 spec'sSettingsPanecases (design line 231). On the current enum they fall back to Connection, as the PR says. track()is a single refcounted hold withdefer release, and it's tied to theWindowscene's.task. Closing the window tears down the content, which cancels the task and releases the hold. Settings relies on the same teardown foronDisappear.- Clock actions refresh
.clockHealthand.screenafterwards (ActionRunner.feeds), so the Display switch settles within one round trip. - Previews and fixtures are
#if DEBUGonly. - Localisation extraction works.
SWIFT_EMIT_LOC_STRINGS=YESproduced.stringsdatafor every Dashboard file, including all the card titles, empty/off copy and chart descriptors. A command-line build doesn't write them back intoLocalizable.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
-
The whole grid re-renders on every screen or state tick.
DashboardWindow.datareadslive.screen(1 s),live.snapshot(3 s) andlive.pomodoro(3 s) in the window's body, and it stampsnow: Date()intoDashboardData.- Any change to any feed therefore invalidates
DashboardWindow. Every card then gets a newnow, 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.feedis a computed property read three times per body (body once, accessory twice), soHeatmapGridandCalendarStripare built three times per pass.screenchanges 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.screenitself. Keepnowout of the value passed to the chart cards (use a minute-granularTimelineView, or passnowonly to the cards that show relative time). Build each card's model once per body.
- Any change to any feed therefore invalidates
-
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 toLocalizable.xcstrings. -
Work hours x-axis: fit it to the data (the orchestrator's (b) — confirmed).
WorkHoursChartuseslo = min(6, …)andhi = 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+.secondaryat a trailing-axis position, which is hard to read (visible in every render). Use.captionwith.primaryfor the duration, or move the text into an annotation at the end of each bar, as the §2.4 wireframe shows.
-
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-levelContentUnavailableView(with Retry and Connection Settings), like the existing.unconfiguredbranch, 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
- 12 weeks: the smoothing is already monotone (the orchestrator's (a) — not confirmed). Both
AreaMarkandLineMarkuse.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. serverLacksWeeklycan'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.
- The last point is the current, partial week, and it's included in
- 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. - Clock card leaves a large empty area (the orchestrator's (d) — confirmed).
mirroris.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. - 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. - 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". UseDate.FormatStyle.hour()on a reference date. HeatmapGrid.peak's tie-break doesn't match its doc. The doc says "earliest in the week order", but the code comparesid = weekday*24+hour, which is Sunday-based. In a Monday-first locale, a Sunday tie wins over Monday. No test covers ties.- 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). - 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. - "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. - 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/screenat 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, becausescenePhasedoesn't reflect occlusion on macOS. Consider releasing.screenwhile 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.
26b2c49 to
fd12e7c
Compare
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)
/v1/clock/healthreportsmatrix_powerrate_window_pcton servers withoutGET /v1/usage. Hidden until some tool reportsTable: Source · Tool · State badge · Activity · Context gauge · 5h · Updated. Waiting/error first. Header "N running · N waiting". Height fits the rowsLayout 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
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.weeklyfield and says "Needs server 0.28" rather than "No data yet".Data (
Sources/EmberKit/Dashboard/, unit-tested)DashboardLayout: columns and runs.DayKey: day and ISO-week keys viaCalendar, 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/statefallback),UpcomingItem,AgentsTable,ClockHealthReadout,WeatherReadout.These return numbers and dates, never user-facing text. That's why
Localizable.xcstringsis untouched.Refresh
.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.settings.pomodoro.load(): focus length for the goal line.settings.meetings.load(): whether Upcoming shows.GET /v1/device/config: the clock web URL.live.refreshNow().Accessibility
accessibilityLabeland anAXChartDescriptorRepresentable(audio graphs): last 7 days, 12 weeks, work hours, heatmap, agent time.Previews and visual check
Dashboard/Preview/DashboardFixtures.swiftbuilds 7 scenarios from the Go goldens incmd/ember/testdata/dashboardplus deterministic synthetic history: established, new user, old server, Pomodoro off, stale, offline, loading.DashboardPreviews.swifthas a#Previewper card and per scenario.cacheDisplay, scratch dir, not committed) at 760/980/1200 pt, in light and dark, and iterated on them.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, inDashboardLayoutTests,DashboardChartsTestsandDashboardCardModelsTests. 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 buildand the Release ad-hoc build: BUILD SUCCEEDED, 0 source warnings.Cross-ticket changes
Ember/Shared/FeedStateView.swift: a newoffSymbolparameter (default"power", so existing callers are unchanged). The "Needs server 0.28" state usesarrow.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.Ember/Dashboard/**,Sources/EmberKit/Dashboard/**,Tests/EmberKitTests/Dashboard*, and one paragraph ofdocs/ARCHITECTURE.md.Localizable.xcstringsis untouched: EmberKit's dashboard code returns no user-facing text, so there's nothing to add by hand.Review round (Opus review)
DashboardSource. Each card is its own view that reads only its own feeds fromLiveModel.TimelineView: 60 s, or 10 min for the heatmap and 12 weeks.clock=5) and 5 timer ticks re-render only Focus (focus=5).NSWindow.occlusionState) and re-acquired when it's visible again.^[\(n) day](inflect: true)), in the descriptors too..captionwith the duration in primary.ContentUnavailableViewwith Try Again and Open Connection Settings.ServerRequirement).Date.FormatStyle.Needs on-device verification
/v1/device/screenpolling should stop on close, and the Display switch should toggle the matrix.