Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
15 changes: 15 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,21 @@ TestDashboardGolden -update` to regenerate), and EmberKit's decode tests read
those same files. EmberKit's models are in `Sources/EmberKit/Models/`, with one
service per feed in `Sources/EmberKit/Services/`.

**The Dashboard window (#111)** is a card grid (`macos/Ember/Dashboard/`):
Clock (live mirror + next/previous/dismiss/power), Focus, Usage, Upcoming,
Agents, Last 7 days, 12 weeks, Work hours, When you focus (weekday × hour
heatmap + 12-week strip), Agent time, Clock health, Weather. 3/2/1 columns
at ≥1040/≥700 pt; a wide card waits for a half-filled row to fill. Every
card reads plain values (`DashboardData`, built from `LiveModel` by
`DashboardWindow`) and renders through `FeedStateView`, so previews and
snapshot renders use fixtures (`Dashboard/Preview/`, the goldens above plus
synthetic history). The window holds its tier-C feeds with one `.task` for as
long as it's open. Chart transforms (bucketing, zero-fill, goal line, DST-safe
day keys, wall-clock work spans, locale week order) live in
`Sources/EmberKit/Dashboard/` with unit tests. A pre-0.28 server shows
"Needs server 0.28" on the cards whose routes 404; Usage falls back to the
sessions' 5-hour percentages.

## The "spine" — how display widgets are added

Every configurable display signal follows one pattern:
Expand Down
68 changes: 68 additions & 0 deletions macos/Ember/Dashboard/Cards/AgentTimeCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import Charts
import SwiftUI
import EmberKit

/// Card 10: how long agents worked each day, stacked by the Mac they ran on.
struct AgentTimeCard: View {
let activity: Loadable<ActivitySummary>
var calendar = Calendar.current

var body: some View {
let feed = activity.map { AgentTimeChart(summary: $0, calendar: calendar) }
DashboardCard(title: "Agent time", systemImage: "cpu") {
FeedStateView(feed: feed, placeholder: AgentTimeChart(summary: DashboardPlaceholders.activity, calendar: calendar),
isEmpty: \.isEmpty,
emptyTitle: "No agent activity this week", emptySymbol: "cpu",
offTitle: ServerRequirement.title, offSymbol: ServerRequirement.symbol) { c in
FocusTintReader { tint in chart(c, redacted: tint.redacted) }
}
} accessory: {
if let c = feed.value, !c.isEmpty {
Text("Today \(DurationText.minutes(Int(c.todayMinutes.rounded())))")
}
}
}

private func chart(_ c: AgentTimeChart, redacted: Bool) -> some View {
let top = WeekBars.axisTop(Int((c.dailyTotals.map(\.minutes).max() ?? 0).rounded(.up)))
return Chart(c.segments) { s in
BarMark(x: .value("Day", s.date, unit: .day),
y: .value("Active minutes", s.minutes),
width: .ratio(0.62))
.foregroundStyle(by: .value("Source", s.source))
}
.chartForegroundStyleScale(domain: c.sources.map(\.name),
range: c.sources.enumerated().map { i, s in
redacted ? Color.secondary.opacity(i == 0 ? 0.35 : 0.2)
: EmberColors.hex(s.colorHex, fallback: .accentColor)
})
.chartYScale(domain: 0...top)
.chartXScale(domain: DayRange.domain(c.days, calendar: calendar))
.chartXAxis {
// The day after the last is listed too: a centred label needs
// the next tick to span to, or the last day's is dropped.
AxisMarks(values: DayRange.ticks(c.days, calendar: calendar)) { value in
if let d = value.as(Date.self), d < DayRange.domain(c.days, calendar: calendar).upperBound {
AxisValueLabel(format: .dateTime.weekday(.narrow), centered: true)
}
}
}
.chartYAxis {
AxisMarks(position: .leading, values: .stride(by: Double(minutesStride(top)))) { value in
AxisGridLine()
AxisValueLabel { if let v = value.as(Int.self) { Text(minutesAxisLabel(v)) } }
}
}
.chartLegend(position: .bottom, alignment: .leading, spacing: 6)
.accessibilityLabel("Agent active time per day by Mac")
.accessibilityChartDescriptor(AgentTimeDescriptor(chart: c))
.overlay(alignment: .topTrailing) {
if !c.isRecording {
Label("Not recording", systemImage: "pause.circle")
.font(.caption)
.foregroundStyle(.secondary)
.help("The server's activity overlay is off, so new agent time isn't stored.")
}
}
}
}
115 changes: 115 additions & 0 deletions macos/Ember/Dashboard/Cards/AgentsCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import SwiftUI
import EmberKit

/// Card 5: every agent session the server knows, what needs attention first.
struct AgentsCard: View {
let snapshot: Loadable<Snapshot>
var now = Date()

/// A wide card sits in its own row, so it can hug a short table instead
/// of showing a mostly empty one; it grows to the standard wide height.
private func height(_ table: Loadable<AgentsTable>) -> CGFloat {
guard let n = (table.value ?? (table.isLoading ? Self.placeholder : nil))?.rows.count, n > 0 else {
return DashboardCardHeight.standard
}
return min(DashboardCardHeight.wide, 96 + CGFloat(n) * 28)
}

private static let placeholder = AgentsTable(sessions: DashboardPlaceholders.snapshot.sessions)

var body: some View {
let table = snapshot.map { AgentsTable(sessions: $0.sessions) }
DashboardCard(title: "Agents", systemImage: "sparkles", height: height(table)) {
FeedStateView(feed: table, placeholder: Self.placeholder, isEmpty: \.isEmpty,
emptyTitle: "No active sessions", emptySymbol: "sparkles") { t in
SessionsTable(rows: t.rows, now: now)
}
} accessory: {
if let t = table.value, !t.isEmpty {
Text("\(t.running) running · \(t.waiting) waiting")
}
}
}
}

private struct SessionsTable: View {
let rows: [AgentsTable.Row]
let now: Date

var body: some View {
Table(rows) {
TableColumn("Source") { r in
HStack(spacing: 6) {
if let hex = r.session.sourceColor {
Circle().fill(EmberColors.hex(hex, fallback: .secondary)).frame(width: 6, height: 6)
.accessibilityHidden(true)
}
Text(verbatim: r.session.source.isEmpty ? "—" : r.session.source)
}
}
.width(min: 44, ideal: 56, max: 110)
TableColumn("Tool") { r in Text(AppNames.display(r.session.tool)) }
.width(min: 48, ideal: 58, max: 90)
TableColumn("State") { r in PhaseBadge(state: r.session.stateEnum) }
.width(min: 70, ideal: 80, max: 100)
TableColumn("Activity") { r in
Text(verbatim: SessionPresentation(r.session).subtitle(maxLength: 120) ?? "—")
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.help(SessionPresentation(r.session).subtitle(maxLength: 400) ?? "")
}
.width(min: 90, ideal: 170)
TableColumn("Context") { r in ContextCell(pct: r.session.contextPct) }
.width(min: 70, ideal: 82, max: 120)
TableColumn("5h") { r in
Text(r.session.rateWindowPct.map { Percent.text(Double($0)) } ?? "—")
.monospacedDigit()
.foregroundStyle(.secondary)
}
.width(min: 34, ideal: 40, max: 56)
TableColumn("Updated") { r in
Text(RelativeText.short(r.session.updatedAt, now: now))
.foregroundStyle(.secondary)
.monospacedDigit()
}
.width(min: 60, ideal: 76, max: 100)
}
.tableStyle(.inset(alternatesRowBackgrounds: false))
.scrollContentBackground(.hidden)
.environment(\.defaultMinListRowHeight, 24)
.accessibilityLabel("Agent sessions")
}
}

private struct ContextCell: View {
let pct: Int?

var body: some View {
if let pct {
HStack(spacing: 6) {
Gauge(value: Double(min(max(pct, 0), 100)), in: 0...100) { Text("Context") }
.gaugeStyle(.accessoryLinearCapacity)
.tint(pct >= 80 ? .orange : .accentColor)
.labelsHidden()
.frame(width: 36)
Text(Percent.text(Double(pct))).monospacedDigit()
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Context")
.accessibilityValue(Percent.text(Double(pct)))
} else {
Text(verbatim: "—").foregroundStyle(.secondary)
}
}
}

/// "just now", "2 min", "1 h": short relative times for dense rows.
enum RelativeText {
static func short(_ date: Date, now: Date) -> String {
let s = max(0, now.timeIntervalSince(date))
if s < 60 { return String(localized: "just now") }
return Duration.seconds(s).formatted(
.units(allowed: [.days, .hours, .minutes], width: .abbreviated, maximumUnitCount: 1))
}
}
83 changes: 83 additions & 0 deletions macos/Ember/Dashboard/Cards/ClockCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import SwiftUI
import EmberKit

/// Card 1: the clock's real LED matrix, what it's showing, and the few
/// actions that change it.
struct ClockCard: View {
let screen: Loadable<[Int]>
let health: Loadable<ClockHealth>
var actions = DashboardActions()

var body: some View {
DashboardCard(title: "Clock", systemImage: "clock", height: DashboardCardHeight.mirror) {
// Mirror and controls as one centred group: the matrix is 4:1,
// so on a wide card extra width would only be empty space.
HStack(alignment: .center, spacing: 24) {
mirror
.frame(maxWidth: 600)
controls
.fixedSize()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} accessory: {
if let at = screen.loadedAt, screen.isStale { StaleChip(since: at) }
}
}

private var pixels: [Int] { screen.value ?? Array(repeating: 0, count: 256) }

private var mirror: some View {
MatrixScreenView(pixels: pixels)
.padding(10)
.background(.black, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay {
if screen.value == nil, !screen.isLoading {
Label("Clock unreachable", systemImage: "wifi.slash")
.font(.callout)
.foregroundStyle(.white.opacity(0.7))
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Clock display")
.accessibilityValue(Text(appName ?? (screen.value == nil ? "Not available" : "")))
}

private var appName: LocalizedStringResource? {
guard let app = health.value?.device?.currentApp, !app.isEmpty else { return nil }
return AppNames.display(app)
}

@ViewBuilder
private var controls: some View {
VStack(alignment: .leading, spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text("Showing")
.font(.caption)
.foregroundStyle(.secondary)
Text(appName ?? "—")
.font(.title3.weight(.semibold))
.lineLimit(1)
}
ControlGroup {
button(.previous, "Previous App", "chevron.backward")
button(.next, "Next App", "chevron.forward")
button(.dismiss, "Dismiss Notification", "xmark")
}
.controlGroupStyle(.navigation)
.labelStyle(.iconOnly)
.fixedSize()
if let power = health.value?.device?.matrixPower {
Toggle("Display", isOn: Binding(get: { power }, set: { actions.clock(.power($0)) }))
.toggleStyle(.switch)
.controlSize(.small)
.disabled(actions.running.contains(.clock(.power(!power))))
}
}
}

private func button(_ action: ClockAction, _ title: LocalizedStringKey, _ symbol: String) -> some View {
Button(title, systemImage: symbol) { actions.clock(action) }
.help(title)
.disabled(screen.value == nil || actions.running.contains(.clock(action)))
}
}
Loading
Loading