diff --git a/Sources/Juice/JuiceApp.swift b/Sources/Juice/JuiceApp.swift index 936a8e1..b273992 100644 --- a/Sources/Juice/JuiceApp.swift +++ b/Sources/Juice/JuiceApp.swift @@ -47,9 +47,10 @@ struct JuiceApp: App { init() { let isMacMini = MacHardware.isCurrentMacMini - _model = StateObject(wrappedValue: BatteryViewModel( + let initialModel = BatteryViewModel( onReading: JuiceApp.handleReading, - isMacMini: isMacMini)) + isMacMini: isMacMini) + _model = StateObject(wrappedValue: initialModel) // Menu bar only: no Dock icon, no main window. NSApplication.shared.setActivationPolicy(.accessory) @@ -74,16 +75,28 @@ struct JuiceApp: App { for: .menuBar(Self.menuBarConsumerID)) } - #if DEV_HELPER || DEBUG + #if DEV_BUILD || DEV_HELPER || DEBUG // Deterministic native-window entry point for development UI // verification. It is absent from production builds and does nothing // unless explicitly requested on the command line. - if isMacMini, CommandLine.arguments.contains("--show-server-stats") { + let shouldShowStats = CommandLine.arguments.contains("--show-stats") + || (isMacMini && CommandLine.arguments.contains("--show-server-stats")) + if shouldShowStats { Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) - StatsWindowPresenter.shared.showServer(store: Self.sampler?.store) + if isMacMini { + StatsWindowPresenter.shared.showServer(store: Self.sampler?.store) + } else { + StatsWindowPresenter.shared.show( + selector: EnergySourceSelector(), + timelineSource: nil, + model: initialModel) + } } } + #endif + + #if DEV_HELPER || DEBUG if isMacMini, CommandLine.arguments.contains("--show-popover") { Task { @MainActor in try? await Task.sleep(for: .milliseconds(500)) diff --git a/Sources/Juice/Stats/MacMiniStatsView.swift b/Sources/Juice/Stats/MacMiniStatsView.swift index 21df778..ad85057 100644 --- a/Sources/Juice/Stats/MacMiniStatsView.swift +++ b/Sources/Juice/Stats/MacMiniStatsView.swift @@ -2,9 +2,9 @@ import SwiftUI import Charts import JuiceCore -/// Full server dashboard. Unlike the battery Stats window, current app watts -/// remain visible for every selected history range. -struct MacMiniStatsView: View { +/// Mac mini-specific state and data loading for the shared Stats page. Unlike +/// battery mode, current app watts remain visible for every history range. +struct MacMiniStatsDashboard: View { static let minimumContentWidth: CGFloat = 860 // The header, chart's 180-point floor, and footer need this much vertical // space together. Keeping the old 500-point minimum let an autosaved frame @@ -87,24 +87,15 @@ struct MacMiniStatsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - Divider() - - HStack(alignment: .top, spacing: 0) { - appPane - .frame(minWidth: 500) - Divider() - powerPane - .frame(minWidth: 320) - } - - Divider() - footer - } - .frame( - minWidth: Self.minimumContentWidth, - minHeight: Self.minimumContentHeight) + StatsDashboardLayout( + minimumContentWidth: Self.minimumContentWidth, + minimumAppPaneWidth: 500, + minimumDetailPaneWidth: 320, + minimumContentHeight: Self.minimumContentHeight, + header: { header }, + appPane: { appPane }, + detailPane: { powerPane }, + footer: { footer }) .task(id: LoadRequest(range: range, retryGeneration: retryGeneration)) { attachLive() await load() @@ -121,16 +112,10 @@ struct MacMiniStatsView: View { } private var header: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 2) { - Text("Mac mini Stats") - .font(.title2.weight(.semibold)) - Text("Current app watts and \(rangeDescription) energy") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() + StatsDashboardHeader( + title: "Mac mini Stats", + subtitle: "Current app watts and \(rangeDescription) energy", + actions: { if let watts = live.reading?.totalMeteredWatts { VStack(alignment: .trailing, spacing: 1) { Text(liveWattsText(watts)) @@ -146,23 +131,22 @@ struct MacMiniStatsView: View { retryGeneration &+= 1 } .controlSize(.small) - } - - HStack(spacing: 16) { - Picker("Server history range", selection: $range) { - ForEach(macMiniPowerRanges, id: \.self) { - Text($0.macMiniPickerLabel).tag($0) + }, + controls: { + HStack(spacing: 16) { + Picker("Server history range", selection: $range) { + ForEach(macMiniPowerRanges, id: \.self) { + Text($0.macMiniPickerLabel).tag($0) + } } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 360, alignment: .leading) + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 360, alignment: .leading) - Spacer(minLength: 0) - ElectricityRateControl() - } - } - .padding(16) + Spacer(minLength: 0) + ElectricityRateControl() + } + }) } private struct LoadRequest: Hashable { @@ -171,81 +155,62 @@ struct MacMiniStatsView: View { } private var appPane: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Apps using power") - .font(.headline) - if live.status == .sampling || live.status == .warmingUp { - LiveHint() - } - Spacer() - } - - HStack(spacing: 10) { - Text("APP") - .frame(maxWidth: .infinity, alignment: .leading) - Text("LIVE W") - .frame(width: 64, alignment: .trailing) - Text("ENERGY / COST") - .frame(width: 72, alignment: .trailing) - Text("PEAK W") - .frame(width: 64, alignment: .trailing) - Color.clear.frame(width: 10, height: 1) - } - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(.tertiary) - - if let loadError { - Text(loadError) - .font(.caption) - .foregroundStyle(.orange) - } else if data == nil { - ProgressView("Loading app energy history…") - .controlSize(.small) - } - - if appRows.isEmpty { - if data != nil { - Text("Collecting app energy—live apps appear as soon as they draw measurable power.") + StatsAppTablePane( + title: "Apps using power", + showsLiveActivity: live.status == .sampling || live.status == .warmingUp, + columns: .server, + content: { + if let loadError { + Text(loadError) .font(.caption) - .foregroundStyle(.tertiary) + .foregroundStyle(.orange) + } else if data == nil { + ProgressView("Loading app energy history…") + .controlSize(.small) } - Spacer() - } else { - ScrollView { - LazyVStack(spacing: 6) { - ForEach(appRows) { app in - appRow(app) + + if appRows.isEmpty { + if data != nil { + Text("Collecting app energy—live apps appear as soon as they draw measurable power.") + .font(.caption) + .foregroundStyle(.tertiary) + } + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 6) { + ForEach(appRows) { app in + appRow(app) + } } + .padding(.trailing, 4) } - .padding(.trailing, 4) } - } - - if let reading = live.reading { - Text(serverPowerBreakdownText( - reading, - includesMeteredTotal: true)) - .font(.caption2) - .foregroundStyle(.secondary) - .monospacedDigit() - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) + }, + summary: { + if let reading = live.reading { + Text(serverPowerBreakdownText( + reading, + includesMeteredTotal: true)) + .font(.caption2) + .foregroundStyle(.secondary) + .monospacedDigit() + } + }) } private func appRow(_ app: AppRow) -> some View { - Button(action: { - showDetail(app) - }, label: { - appRowLabel(app) - }) - .buttonStyle(.plain) - .accessibilityElement(children: .ignore) - .accessibilityLabel(app.displayName) - .accessibilityValue(appAccessibilityValue(app)) - .accessibilityHint("Opens app energy details") + StatsAppTableRow( + appKey: app.appKey, + displayName: app.displayName, + share: (app.energyWh ?? 0) / maxAppEnergy, + columns: .server, + liveWattsText: app.liveWatts.map(liveWattsText), + energyText: app.energyWh.map(serverEnergyText), + costText: costText(app.energyWh), + detailText: app.peakWatts.map(liveWattsText), + accessibilityValue: appAccessibilityValue(app), + onTap: { showDetail(app) }) } private func appAccessibilityValue(_ app: AppRow) -> String { @@ -258,77 +223,6 @@ struct MacMiniStatsView: View { return "\(base), estimated cost \(cost) \(accessibilityRangeDescription)" } - private func appRowLabel(_ app: AppRow) -> some View { - let isLive = app.liveWatts != nil - let liveText = app.liveWatts.map(liveWattsText) ?? "—" - let barFraction = CGFloat(max(0, min(1, (app.energyWh ?? 0) / maxAppEnergy))) - let rowBackground = isLive ? Color.green.opacity(0.06) : Color.clear - - return HStack(spacing: 10) { - AppIconView(bundleId: app.appKey, displayName: app.displayName) - .frame(width: 22, height: 22) - - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 5) { - if isLive { - Circle() - .fill(.green) - .frame(width: 5, height: 5) - } - Text(app.displayName) - .font(.callout) - .lineLimit(1) - } - GeometryReader { geometry in - ZStack(alignment: .leading) { - Capsule().fill(Color.secondary.opacity(0.15)) - Capsule() - .fill(isLive - ? Color.accentColor - : Color.accentColor.opacity(0.65)) - .frame(width: geometry.size.width * barFraction) - } - } - .frame(height: 5) - } - .frame(maxWidth: .infinity, alignment: .leading) - - Text(liveText) - .font(isLive ? .callout.weight(.semibold) : .callout) - .foregroundStyle(isLive ? Color.green : Color.secondary) - .monospacedDigit() - .frame(width: 64, alignment: .trailing) - - VStack(alignment: .trailing, spacing: 1) { - Text(app.energyWh.map(serverEnergyText) ?? "—") - .font(.callout) - if let cost = costText(app.energyWh) { - Text(cost) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) - } - } - .monospacedDigit() - .frame(width: 72, alignment: .trailing) - - Text(app.peakWatts.map(liveWattsText) ?? "—") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - .frame(width: 64, alignment: .trailing) - - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) - .frame(width: 10) - } - .padding(.vertical, 5) - .padding(.horizontal, 7) - .background(rowBackground, in: RoundedRectangle(cornerRadius: 7)) - } - private func showDetail(_ app: AppRow) { AppDetailPresenter.shared.show( appKey: app.appKey, diff --git a/Sources/Juice/Stats/StatsDashboardComponents.swift b/Sources/Juice/Stats/StatsDashboardComponents.swift new file mode 100644 index 0000000..91ed58d --- /dev/null +++ b/Sources/Juice/Stats/StatsDashboardComponents.swift @@ -0,0 +1,284 @@ +import SwiftUI + +/// The shared page structure for every Stats dashboard. Battery and server +/// modes supply different data panes, but the window anatomy and spacing stay +/// identical. +struct StatsDashboardLayout: View { + let minimumContentWidth: CGFloat + let minimumAppPaneWidth: CGFloat + let minimumDetailPaneWidth: CGFloat + let minimumContentHeight: CGFloat + + private let header: Header + private let appPane: AppPane + private let detailPane: DetailPane + private let footer: Footer + + init( + minimumContentWidth: CGFloat, + minimumAppPaneWidth: CGFloat, + minimumDetailPaneWidth: CGFloat, + minimumContentHeight: CGFloat, + @ViewBuilder header: () -> Header, + @ViewBuilder appPane: () -> AppPane, + @ViewBuilder detailPane: () -> DetailPane, + @ViewBuilder footer: () -> Footer + ) { + self.minimumContentWidth = minimumContentWidth + self.minimumAppPaneWidth = minimumAppPaneWidth + self.minimumDetailPaneWidth = minimumDetailPaneWidth + self.minimumContentHeight = minimumContentHeight + self.header = header() + self.appPane = appPane() + self.detailPane = detailPane() + self.footer = footer() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + + HStack(alignment: .top, spacing: 0) { + appPane + .frame(minWidth: minimumAppPaneWidth) + Divider() + detailPane + .frame(minWidth: minimumDetailPaneWidth) + } + + Divider() + footer + } + .frame( + minWidth: minimumContentWidth, + minHeight: minimumContentHeight) + } +} + +/// The shared title and control-stack anatomy for every Stats mode. Each mode +/// supplies only the actions and controls that make sense for its data source. +struct StatsDashboardHeader: View { + let title: String + let subtitle: String + + private let actions: Actions + private let controls: Controls + + init( + title: String, + subtitle: String, + @ViewBuilder actions: () -> Actions, + @ViewBuilder controls: () -> Controls + ) { + self.title = title + self.subtitle = subtitle + self.actions = actions() + self.controls = controls() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.title2.weight(.semibold)) + Text(subtitle) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + actions + } + controls + } + .padding(16) + } +} + +/// Column configuration shared by battery and server app tables. +struct StatsAppTableColumns: Equatable { + let showsLiveWatts: Bool + let detailTitle: String + let detailWidth: CGFloat + + static func battery(showsLiveWatts: Bool) -> Self { + Self( + showsLiveWatts: showsLiveWatts, + detailTitle: "CPU TIME", + detailWidth: 72) + } + + static let server = Self( + showsLiveWatts: true, + detailTitle: "PEAK W", + detailWidth: 64) +} + +/// The titled columns above every Stats app table. Its dimensions mirror +/// ``StatsAppTableRow`` so labels remain aligned in every device mode. +struct StatsAppTableHeader: View { + let columns: StatsAppTableColumns + + var body: some View { + HStack(spacing: 10) { + Text("APP") + .frame(maxWidth: .infinity, alignment: .leading) + if columns.showsLiveWatts { + Text("LIVE W") + .frame(width: 64, alignment: .trailing) + } + Text("ENERGY / COST") + .frame(width: 72, alignment: .trailing) + Text(columns.detailTitle) + .frame(width: columns.detailWidth, alignment: .trailing) + Color.clear.frame(width: 10, height: 1) + } + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 7) + } +} + +/// The shared left pane around every Stats app table. Modes provide their own +/// loading, empty, and summary content without duplicating pane typography, +/// column labels, spacing, or padding. +struct StatsAppTablePane: View { + let title: String + let showsLiveActivity: Bool + let columns: StatsAppTableColumns + + private let content: Content + private let summary: Summary + + init( + title: String, + showsLiveActivity: Bool, + columns: StatsAppTableColumns, + @ViewBuilder content: () -> Content, + @ViewBuilder summary: () -> Summary + ) { + self.title = title + self.showsLiveActivity = showsLiveActivity + self.columns = columns + self.content = content() + self.summary = summary() + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Text(title) + .font(.headline) + if showsLiveActivity { + LiveHint() + } + Spacer() + } + + StatsAppTableHeader(columns: columns) + content + summary + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(16) + } +} + +/// A single reusable Stats app row. Device-specific code supplies formatted +/// values, while icon, bar, column geometry, live styling, hover affordance, +/// and accessibility behavior remain consistent. +struct StatsAppTableRow: View { + let appKey: String + let displayName: String + let share: Double + let columns: StatsAppTableColumns + let liveWattsText: String? + let energyText: String? + let costText: String? + let detailText: String? + let accessibilityValue: String + let onTap: () -> Void + + @State private var hovering = false + + var body: some View { + Button(action: onTap) { + HStack(spacing: 10) { + AppIconView(bundleId: appKey, displayName: displayName) + .frame(width: 20, height: 20) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 5) { + if liveWattsText != nil { + Circle() + .fill(.green) + .frame(width: 5, height: 5) + } + Text(displayName) + .font(.callout) + .lineLimit(1) + } + + GeometryReader { geometry in + ZStack(alignment: .leading) { + Capsule().fill(Color.secondary.opacity(0.15)) + Capsule() + .fill(Color.accentColor.opacity(liveWattsText == nil ? 0.65 : 1)) + .frame( + width: geometry.size.width + * CGFloat(max(0, min(1, share)))) + } + } + .frame(height: 5) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if columns.showsLiveWatts { + Text(liveWattsText ?? "—") + .font(liveWattsText == nil ? .callout : .callout.weight(.semibold)) + .foregroundStyle(liveWattsText == nil ? Color.secondary : Color.green) + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + } + + VStack(alignment: .trailing, spacing: 1) { + Text(energyText ?? "—") + .font(.callout) + if let costText { + Text(costText) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .monospacedDigit() + .frame(width: 72, alignment: .trailing) + + Text(detailText ?? "—") + .font(.caption) + .foregroundStyle(.secondary) + .monospacedDigit() + .frame(width: columns.detailWidth, alignment: .trailing) + + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.tertiary) + .opacity(hovering ? 1 : 0) + .frame(width: 10) + } + .padding(.vertical, 5) + .padding(.horizontal, 7) + .background( + liveWattsText == nil ? Color.clear : Color.green.opacity(0.06), + in: RoundedRectangle(cornerRadius: 7)) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .accessibilityElement(children: .ignore) + .accessibilityLabel(displayName) + .accessibilityValue(accessibilityValue) + .accessibilityHint("Opens app energy details") + } +} diff --git a/Sources/Juice/Stats/StatsView.swift b/Sources/Juice/Stats/StatsView.swift index ef8a7fa..421f004 100644 --- a/Sources/Juice/Stats/StatsView.swift +++ b/Sources/Juice/Stats/StatsView.swift @@ -1,12 +1,58 @@ import SwiftUI -import AppKit import JuiceCore -/// The standalone Stats window content: a full per-app energy table alongside a -/// 7-day charge timeline, with a battery-health footer. +/// The one Stats page used by both battery Macs and Mac minis. Each mode keeps +/// its own data lifecycle, while the dashboard shell, header, and app table are +/// shared components. struct StatsView: View { + private enum Content { + case battery( + selector: EnergySourceSelector, + timelineSource: EnergySource?, + model: BatteryViewModel) + case server(store: JuiceStore?) + } + + static let batteryMinimumContentWidth = BatteryStatsDashboard.minimumContentWidth + static let batteryMinimumContentHeight = BatteryStatsDashboard.minimumContentHeight + static let serverMinimumContentWidth = MacMiniStatsDashboard.minimumContentWidth + static let serverMinimumContentHeight = MacMiniStatsDashboard.minimumContentHeight + + private let content: Content + + init( + selector: EnergySourceSelector, + timelineSource: EnergySource?, + model: BatteryViewModel + ) { + content = .battery( + selector: selector, + timelineSource: timelineSource, + model: model) + } + + init(serverStore: JuiceStore?) { + content = .server(store: serverStore) + } + + @ViewBuilder + var body: some View { + switch content { + case let .battery(selector, timelineSource, model): + BatteryStatsDashboard( + selector: selector, + timelineSource: timelineSource, + model: model) + case let .server(store): + MacMiniStatsDashboard(store: store) + } + } +} + +/// Battery-specific state and data loading for the shared Stats page. +private struct BatteryStatsDashboard: View { /// The app rows include fixed-width energy and CPU columns, and live Today - /// rows add a 60 pt watts column plus its 10 pt spacing. Keep enough room + /// rows add a 64 pt watts column plus its 10 pt spacing. Keep enough room /// for an app name instead of letting that column collapse first. static let minimumAppTableWidth: CGFloat = 462 static let minimumTimelineWidth: CGFloat = 280 @@ -116,26 +162,102 @@ struct StatsView: View { pricePerKilowattHour: pricePerKilowattHour) } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - header - Divider() - - HStack(alignment: .top, spacing: 0) { - appTable - .frame(minWidth: Self.minimumAppTableWidth) - Divider() - timelinePane - .frame(minWidth: Self.minimumTimelineWidth) - } + private func historicalAppRow(_ app: AppEnergy, share: Double) -> some View { + let cost = costText(app.energyWh) + let energyAccessibility = String( + format: "%.1f watt-hours, %.1f CPU-hours", + app.energyWh, + app.cpuHours) + let accessibilityValue = cost.map { + "\(energyAccessibility), estimated cost \($0)" + } ?? energyAccessibility + + return StatsAppTableRow( + appKey: app.bundleId, + displayName: app.displayName, + share: share, + columns: .battery(showsLiveWatts: showsLivePower), + liveWattsText: nil, + energyText: String(format: "%.1f Wh", app.energyWh), + costText: cost, + detailText: String(format: "%.1f h", app.cpuHours), + accessibilityValue: accessibilityValue, + onTap: { + showAppDetail( + appKey: app.bundleId, + displayName: app.displayName) + }) + } + + private func activeAppRow( + _ app: HybridTodayList.ActiveApp, + energyWh: Double?, + cpuHours: Double?, + share: Double + ) -> some View { + let liveText = liveWattsText(app.watts) + let cost = costText(energyWh) + var accessibilityValues = [liveText] + if let energyWh { + accessibilityValues.append( + String(format: "%.1f watt-hours", energyWh) + + " \(energyContext)") + } + if let cost { + accessibilityValues.append( + "estimated cost \(cost) \(energyContext)") + } + if let cpuHours { + accessibilityValues.append( + String(format: "%.1f CPU-hours", cpuHours)) + } + + return StatsAppTableRow( + appKey: app.appKey, + displayName: app.displayName, + share: share, + columns: .battery(showsLiveWatts: true), + liveWattsText: liveText, + energyText: energyWh.map { String(format: "%.1f Wh", $0) }, + costText: cost, + detailText: cpuHours.map { String(format: "%.1f h", $0) }, + accessibilityValue: accessibilityValues.joined(separator: ", "), + onTap: { + showAppDetail( + appKey: app.appKey, + displayName: app.displayName) + }) + } + + private func showAppDetail(appKey: String, displayName: String) { + AppDetailPresenter.shared.show( + appKey: appKey, + displayName: displayName, + range: range, + origin: origin, + session: range == .session ? batterySession.result?.session : nil) + } - Divider() - footer + private var energyContext: String { + switch range { + case .session: return "for this session" + case .today: return "today" + case .threeDays: return "over three days" + case .week: return "over the last week" + case .allTime: return "over all recorded time" } - .frame( - minWidth: Self.minimumContentWidth, - minHeight: Self.minimumContentHeight - ) + } + + var body: some View { + StatsDashboardLayout( + minimumContentWidth: Self.minimumContentWidth, + minimumAppPaneWidth: Self.minimumAppTableWidth, + minimumDetailPaneWidth: Self.minimumTimelineWidth, + minimumContentHeight: Self.minimumContentHeight, + header: { header }, + appPane: { appTable }, + detailPane: { timelinePane }, + footer: { footer }) .task(id: AppsLoadRequest( range: range, refreshGeneration: appsRefreshGeneration) @@ -193,16 +315,10 @@ struct StatsView: View { // MARK: - Header private var header: some View { - VStack(alignment: .leading, spacing: 6) { - HStack(alignment: .firstTextBaseline) { - VStack(alignment: .leading, spacing: 2) { - Text("Juice Stats") - .font(.title2.weight(.semibold)) - Text(rangeSubtitle) - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() + StatsDashboardHeader( + title: "Juice Stats", + subtitle: rangeSubtitle, + actions: { Button { withAnimation(replacementAnimation) { isCustomizingRanges.toggle() @@ -216,29 +332,30 @@ struct StatsView: View { } .buttonStyle(.bordered) .controlSize(.small) - .help(isCustomizingRanges ? "Finish customizing tabs" : "Choose which tabs appear") - } - - if isCustomizingRanges { - rangeSettings - .transition(.move(edge: .top).combined(with: .opacity)) - } + .help(isCustomizingRanges + ? "Finish customizing tabs" + : "Choose which tabs appear") + }, + controls: { + if isCustomizingRanges { + rangeSettings + .transition(.move(edge: .top).combined(with: .opacity)) + } - HStack(spacing: 16) { - Picker("Range", selection: $range) { - ForEach(visibleRanges, id: \.self) { range in - Text(range.pickerLabel).tag(range) + HStack(spacing: 16) { + Picker("Range", selection: $range) { + ForEach(visibleRanges, id: \.self) { range in + Text(range.pickerLabel).tag(range) + } } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 380) + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 380) - Spacer(minLength: 0) - ElectricityRateControl() - } - } - .padding(16) + Spacer(minLength: 0) + ElectricityRateControl() + } + }) } private var rangeSettings: some View { @@ -303,42 +420,35 @@ struct StatsView: View { // MARK: - App table private var appTable: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 6) { - Text("Apps by energy") - .font(.caption) - .foregroundStyle(.secondary) - if showsLivePower, - live.status == .sampling || live.status == .warmingUp { - LiveHint() + StatsAppTablePane( + title: showsLivePower ? "Apps using power" : "Apps by energy", + showsLiveActivity: showsLivePower + && (live.status == .sampling || live.status == .warmingUp), + columns: .battery(showsLiveWatts: showsLivePower), + content: { + if range == .today, let hybrid = live.hybrid, !hybrid.active.isEmpty { + hybridAppTable(hybrid) + } else if range == .session, + showsLivePower, + let hybrid = live.hybrid, + !hybrid.active.isEmpty { + liveSessionAppTable(hybrid) + } else { + historicalAppTable } - Spacer() - } - - if range == .today, let hybrid = live.hybrid, !hybrid.active.isEmpty { - hybridAppTable(hybrid) - } else if range == .session, - showsLivePower, - let hybrid = live.hybrid, - !hybrid.active.isEmpty { - liveSessionAppTable(hybrid) - } else { - historicalAppTable - } - // Today's query status renders here, outside the hybrid-vs-history - // branch, so a failed or outdated-helper Today fetch is surfaced - // even while live rows are showing in the hybrid table (which never - // includes these banners). Mirrors the popover, whose banner sits - // unconditionally below the app list. - if range == .today { - todayStatusBanner - } else if range == .session { - sessionStatusBanner - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) + // Today's query status renders here, outside the hybrid-vs-history + // branch, so a failed or outdated-helper Today fetch is surfaced + // even while live rows are showing in the hybrid table (which never + // includes these banners). Mirrors the popover, whose banner sits + // unconditionally below the app list. + if range == .today { + todayStatusBanner + } else if range == .session { + sessionStatusBanner + } + }, + summary: { EmptyView() }) } @ViewBuilder @@ -421,19 +531,9 @@ struct StatsView: View { ScrollView { VStack(spacing: 8) { ForEach(apps) { app in - StatsAppRow( - app: app, - share: app.energyWh / totalEnergy, - costText: costText(app.energyWh), - onTap: { - AppDetailPresenter.shared.show( - appKey: app.bundleId, - displayName: app.displayName, - range: range, - origin: origin, - session: range == .session ? batterySession.result?.session : nil - ) - }) + historicalAppRow( + app, + share: app.energyWh / totalEnergy) } } .padding(.trailing, 4) @@ -482,20 +582,11 @@ struct StatsView: View { .foregroundStyle(.secondary) } ForEach(hybrid.active) { app in - StatsActiveAppRow( - app: app, + activeAppRow( + app, energyWh: app.todayWh, cpuHours: app.todayCpuHours, - range: .today, - share: app.watts / maxWatts, - costText: costText(app.todayWh), - onTap: { - AppDetailPresenter.shared.show( - appKey: app.appKey, - displayName: app.displayName, - range: range, - origin: origin) - }) + share: app.watts / maxWatts) } } @@ -505,17 +596,9 @@ struct StatsView: View { .font(.caption2) .foregroundStyle(.secondary) ForEach(hybrid.earlier) { app in - StatsAppRow( - app: app, - share: app.energyWh / earlierTotal, - costText: costText(app.energyWh), - onTap: { - AppDetailPresenter.shared.show( - appKey: app.bundleId, - displayName: app.displayName, - range: range, - origin: origin) - }) + historicalAppRow( + app, + share: app.energyWh / earlierTotal) } } } @@ -558,21 +641,11 @@ struct StatsView: View { } ForEach(hybrid.active) { app in let sessionEnergy = sessionByKey[app.appKey] - StatsActiveAppRow( - app: app, + activeAppRow( + app, energyWh: sessionEnergy?.energyWh, cpuHours: sessionEnergy?.cpuHours, - range: .session, - share: app.watts / maxWatts, - costText: costText(sessionEnergy?.energyWh), - onTap: { - AppDetailPresenter.shared.show( - appKey: app.appKey, - displayName: app.displayName, - range: .session, - origin: origin, - session: batterySession.result?.session) - }) + share: app.watts / maxWatts) } } @@ -582,18 +655,9 @@ struct StatsView: View { .font(.caption2) .foregroundStyle(.secondary) ForEach(earlier) { app in - StatsAppRow( - app: app, - share: app.energyWh / earlierTotal, - costText: costText(app.energyWh), - onTap: { - AppDetailPresenter.shared.show( - appKey: app.bundleId, - displayName: app.displayName, - range: .session, - origin: origin, - session: batterySession.result?.session) - }) + historicalAppRow( + app, + share: app.energyWh / earlierTotal) } } } @@ -748,214 +812,3 @@ struct StatsView: View { appsRefreshGeneration &+= 1 } } - -/// One row in the full app-energy table: icon, name, Wh, CPU hours, and a -/// share-of-total bar. Tapping opens the per-app detail window; a chevron -/// appears on hover to hint at the interaction. -private struct StatsAppRow: View { - let app: AppEnergy - let share: Double - let costText: String? - let onTap: () -> Void - - @State private var hovering = false - - var body: some View { - Button(action: onTap) { - HStack(spacing: 10) { - StatsAppIconView(bundleId: app.bundleId, displayName: app.displayName) - .frame(width: 20, height: 20) - - Text(app.displayName) - .font(.callout) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.15)) - Capsule() - .fill(Color.accentColor) - .frame(width: geo.size.width * CGFloat(max(0, min(1, share)))) - } - } - .frame(width: 60, height: 5) - - VStack(alignment: .trailing, spacing: 1) { - Text(String(format: "%.1f Wh", app.energyWh)) - .font(.callout) - if let costText { - Text(costText) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) - } - } - .monospacedDigit() - .frame(width: 72, alignment: .trailing) - - Text(String(format: "%.1f h CPU", app.cpuHours)) - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - .frame(width: 72, alignment: .trailing) - - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) - .opacity(hovering ? 1 : 0) - } - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .accessibilityElement(children: .ignore) - .accessibilityLabel(app.displayName) - .accessibilityValue(accessibilityValue) - .accessibilityHint("Opens energy details") - } - - private var accessibilityValue: String { - let energy = String( - format: "%.1f watt-hours, %.1f CPU-hours", app.energyWh, app.cpuHours) - guard let costText else { return energy } - return "\(energy), estimated cost \(costText)" - } -} - -/// One active-power row in the hybrid Today table, mirroring ``StatsAppRow`` -/// anatomy with an additional green watts column before the Wh column. Apps -/// with no today history yet show "-" for Wh and CPU. Clickable: bundle ids are -/// real, so the per-app detail window opens. -private struct StatsActiveAppRow: View { - let app: HybridTodayList.ActiveApp - let energyWh: Double? - let cpuHours: Double? - let range: EnergyRange - let share: Double - let costText: String? - let onTap: () -> Void - - @State private var hovering = false - - var body: some View { - Button(action: onTap) { - HStack(spacing: 10) { - // appKey is the bundle id when resolvable, so it doubles as the - // icon lookup key with the display name as fallback. - StatsAppIconView(bundleId: app.appKey, displayName: app.displayName) - .frame(width: 20, height: 20) - - Text(app.displayName) - .font(.callout) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule() - .fill(Color.secondary.opacity(0.15)) - Capsule() - .fill(Color.accentColor) - .frame(width: geo.size.width * CGFloat(max(0, min(1, share)))) - } - } - .frame(width: 60, height: 5) - - Text(liveWattsText(app.watts)) - .font(.callout.weight(.semibold)) - .foregroundStyle(.green) - .monospacedDigit() - .frame(width: 60, alignment: .trailing) - - VStack(alignment: .trailing, spacing: 1) { - Text(energyWh.map { String(format: "%.1f Wh", $0) } ?? "-") - .font(.callout) - if let costText { - Text(costText) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - .minimumScaleFactor(0.75) - } - } - .monospacedDigit() - .frame(width: 72, alignment: .trailing) - - Text(cpuHours.map { String(format: "%.1f h CPU", $0) } ?? "-") - .font(.caption) - .foregroundStyle(.secondary) - .monospacedDigit() - .frame(width: 72, alignment: .trailing) - - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.tertiary) - .opacity(hovering ? 1 : 0) - } - } - .buttonStyle(.plain) - .onHover { hovering = $0 } - .accessibilityElement(children: .ignore) - .accessibilityLabel(app.displayName) - .accessibilityValue(accessibilityValue) - .accessibilityHint("Opens energy details") - } - - private var accessibilityValue: String { - var values = [liveWattsText(app.watts)] - if let energyWh { - values.append( - String(format: "%.1f watt-hours", energyWh) - + " \(energyContext)") - } - if let costText { - values.append("estimated cost \(costText) \(energyContext)") - } - if let cpuHours { - values.append(String(format: "%.1f CPU-hours", cpuHours)) - } - return values.joined(separator: ", ") - } - - private var energyContext: String { - switch range { - case .session: return "for this session" - case .today: return "today" - case .threeDays: return "over three days" - case .week: return "over the last week" - case .allTime: return "over all recorded time" - } - } -} - -/// The app's real icon when the bundle id resolves, otherwise a lettered -/// placeholder. Mirrors the icon-loading approach in ``TopAppsView``. -private struct StatsAppIconView: View { - let bundleId: String - let displayName: String - - var body: some View { - if let icon = Self.icon(for: bundleId) { - Image(nsImage: icon) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - RoundedRectangle(cornerRadius: 4) - .fill(Color.secondary.opacity(0.25)) - .overlay( - Text(String(displayName.prefix(1))) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - ) - } - } - - private static func icon(for bundleId: String) -> NSImage? { - guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) else { - return nil - } - return NSWorkspace.shared.icon(forFile: url.path) - } -} diff --git a/Sources/Juice/Stats/StatsWindow.swift b/Sources/Juice/Stats/StatsWindow.swift index 395e2ae..873b908 100644 --- a/Sources/Juice/Stats/StatsWindow.swift +++ b/Sources/Juice/Stats/StatsWindow.swift @@ -102,8 +102,8 @@ final class StatsWindowPresenter: NSObject, NSWindowDelegate { root, title: "Juice - Stats", minimumContentSize: NSSize( - width: StatsView.minimumContentWidth, - height: StatsView.minimumContentHeight), + width: StatsView.batteryMinimumContentWidth, + height: StatsView.batteryMinimumContentHeight), contentSize: NSSize(width: 760, height: 480)) } @@ -111,11 +111,11 @@ final class StatsWindowPresenter: NSObject, NSWindowDelegate { NSApp.activate(ignoringOtherApps: true) present( - MacMiniStatsView(store: store), + StatsView(serverStore: store), title: "Juice - Mac mini Stats", minimumContentSize: NSSize( - width: MacMiniStatsView.minimumContentWidth, - height: MacMiniStatsView.minimumContentHeight), + width: StatsView.serverMinimumContentWidth, + height: StatsView.serverMinimumContentHeight), contentSize: NSSize(width: 940, height: 600)) } diff --git a/Tests/JuiceTests/MacMiniPowerRangeTests.swift b/Tests/JuiceTests/MacMiniPowerRangeTests.swift index c668e0b..dbc261f 100644 --- a/Tests/JuiceTests/MacMiniPowerRangeTests.swift +++ b/Tests/JuiceTests/MacMiniPowerRangeTests.swift @@ -19,7 +19,7 @@ struct MacMiniPowerRangeTests { @Test("Server Stats minimum height keeps its footer visible") func statsMinimumHeightIncludesFooter() { - #expect(MacMiniStatsView.minimumContentHeight >= 560) + #expect(StatsView.serverMinimumContentHeight >= 560) } @Test("All starts at the first recorded power sample") diff --git a/Tests/JuiceTests/StatsDashboardComponentsTests.swift b/Tests/JuiceTests/StatsDashboardComponentsTests.swift new file mode 100644 index 0000000..b53cc87 --- /dev/null +++ b/Tests/JuiceTests/StatsDashboardComponentsTests.swift @@ -0,0 +1,102 @@ +import AppKit +import SwiftUI +import Testing +@testable import Juice + +@MainActor +@Suite("Shared Stats dashboard components") +struct StatsDashboardComponentsTests { + @Test("Battery columns add live watts only for live ranges") + func batteryColumnsFollowLiveState() { + let historical = StatsAppTableColumns.battery(showsLiveWatts: false) + let live = StatsAppTableColumns.battery(showsLiveWatts: true) + + #expect(!historical.showsLiveWatts) + #expect(live.showsLiveWatts) + #expect(historical.detailTitle == "CPU TIME") + #expect(live.detailTitle == historical.detailTitle) + } + + @Test("Server columns always expose live watts and peak power") + func serverColumnsExposeServerMetrics() { + #expect(StatsAppTableColumns.server.showsLiveWatts) + #expect(StatsAppTableColumns.server.detailTitle == "PEAK W") + } + + @Test("Unified Stats root retains mode-specific minimum sizes") + func unifiedStatsMinimumSizes() { + #expect(StatsView.batteryMinimumContentWidth >= 743) + #expect(StatsView.batteryMinimumContentHeight >= 420) + #expect(StatsView.serverMinimumContentWidth >= 860) + #expect(StatsView.serverMinimumContentHeight >= 560) + } + + @Test("Shared battery and server tables render with one row component") + func sharedTablePreviewRenders() throws { + let size = NSSize(width: 1040, height: 190) + let controller = NSHostingController( + rootView: StatsAppTablePreview() + .environment(\.colorScheme, .dark) + .frame(width: size.width, height: size.height) + ) + controller.view.frame = NSRect(origin: .zero, size: size) + controller.view.layoutSubtreeIfNeeded() + + let bitmap = try #require(controller.view.bitmapImageRepForCachingDisplay( + in: controller.view.bounds)) + controller.view.cacheDisplay(in: controller.view.bounds, to: bitmap) + #expect(bitmap.pixelsWide >= Int(size.width)) + #expect(bitmap.pixelsHigh >= Int(size.height)) + + if let outputPath = ProcessInfo.processInfo.environment["JUICE_STATS_PREVIEW_PATH"] { + let png = try #require(bitmap.representation(using: .png, properties: [:])) + try png.write(to: URL(fileURLWithPath: outputPath), options: .atomic) + } + } +} +private struct StatsAppTablePreview: View { + var body: some View { + HStack(alignment: .top, spacing: 24) { + table( + title: "Battery", + columns: .battery(showsLiveWatts: true), + liveWattsText: "4.8 W", + energyText: "1.3 Wh", + detailText: "0.7 h") + table( + title: "Mac mini", + columns: .server, + liveWattsText: "2.5 W", + energyText: "23 Wh", + detailText: "17.2 W") + } + .padding(16) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private func table( + title: String, + columns: StatsAppTableColumns, + liveWattsText: String, + energyText: String, + detailText: String + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.headline) + StatsAppTableHeader(columns: columns) + StatsAppTableRow( + appKey: "preview.app", + displayName: "Example App", + share: 0.72, + columns: columns, + liveWattsText: liveWattsText, + energyText: energyText, + costText: "$0.01", + detailText: detailText, + accessibilityValue: "Preview", + onTap: {}) + } + .frame(maxWidth: .infinity) + } +}