Skip to content

Commit 5012332

Browse files
authored
refactor: extract chart trend logic and split updateButton into focused methods (#110)
- Extract ~150 lines of trend computation from ActivityChartView (678→526 lines) into ActivityChartTrend.swift: ActivityTrendComputation enum with pure comparison logic (vs yesterday, vs last week, vs last month) and shared formatting helpers - Split StatusBarManager.updateButton (81 lines) into 6 focused methods: resolveMetricMode, resolveStarColor, updateSparkleState, updateRenderState, updateBreathTimer, resolveDisplayText — each under 10 lines - Make compactCount and formatHourLabel internal for cross-file access Co-authored-by: KyleNesium <22541778+KyleNesium@users.noreply.github.com>
1 parent 0aaee29 commit 5012332

File tree

3 files changed

+210
-205
lines changed

3 files changed

+210
-205
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import SwiftUI
2+
3+
// MARK: - Trend data types
4+
5+
/// Change indicator for vs-yesterday / vs-last-week / vs-last-month comparisons.
6+
struct ActivityChangeInfo {
7+
let symbol: String
8+
let label: String
9+
let color: Color
10+
}
11+
12+
/// Precomputed trend data — avoids duplicate Date()/Calendar/throttleCount calls.
13+
struct ActivityTrendData {
14+
let change: ActivityChangeInfo?
15+
let stat: String?
16+
let throttleCount: Int
17+
let peak: String?
18+
let throttleDays: Int
19+
}
20+
21+
// MARK: - Trend computation (pure logic, no view code)
22+
23+
@MainActor
24+
enum ActivityTrendComputation {
25+
26+
static func compute(
27+
mode: ActivityChartMode,
28+
snapshot: UsageSnapshot,
29+
monthTotals: [String: Int]
30+
) -> ActivityTrendData {
31+
let cal = Calendar.current
32+
let now = Date()
33+
34+
switch mode {
35+
case .hourly:
36+
return ActivityTrendData(
37+
change: changeVsYesterday(snapshot, cal: cal, now: now),
38+
stat: "\(snapshot.todayMessages) msgs today",
39+
throttleCount: UsageViewModel.throttleCount(days: 1),
40+
peak: snapshot.peakHour.map { "Peak: \(ActivityChartView.formatHourLabel($0)):00" },
41+
throttleDays: 1
42+
)
43+
case .daily:
44+
return ActivityTrendData(
45+
change: changeVsLastWeek(snapshot, cal: cal, now: now),
46+
stat: snapshot.dailyAverage > 0 ? "\(snapshot.dailyAverage) avg/day" : nil,
47+
throttleCount: UsageViewModel.throttleCount(days: 7),
48+
peak: snapshot.busiestDayOfWeek.map { "Peak: \($0.name)s" },
49+
throttleDays: 7
50+
)
51+
case .monthly:
52+
let nowComps = cal.dateComponents([.year, .month], from: now)
53+
let thisMonthKey = nowComps.year.flatMap { y in nowComps.month.map { m in String(format: "%04d-%02d", y, m) } }
54+
let lastMonthKey: String? = cal.date(byAdding: .month, value: -1, to: now).flatMap { d in
55+
let c = cal.dateComponents([.year, .month], from: d)
56+
return c.year.flatMap { y in c.month.map { m in String(format: "%04d-%02d", y, m) } }
57+
}
58+
let thisMonth = thisMonthKey.flatMap { monthTotals[$0] } ?? 0
59+
let lastMonth = lastMonthKey.flatMap { monthTotals[$0] } ?? 0
60+
let busiestLabel: String? = {
61+
guard let peak = monthTotals.max(by: { $0.value < $1.value }),
62+
let date = DateFormatters.dateKey.date(from: peak.key + "-01") else { return nil }
63+
return ActivityChartView.monthAbbrev(date)
64+
}()
65+
return ActivityTrendData(
66+
change: monthChangeInfo(thisMonth: thisMonth, lastMonth: lastMonth, cal: cal, now: now),
67+
stat: thisMonth > 0 ? "\(ActivityChartView.compactCount(thisMonth)) this month" : nil,
68+
throttleCount: UsageViewModel.throttleCount(days: 30),
69+
peak: busiestLabel.map { "Peak: \($0)" },
70+
throttleDays: 30
71+
)
72+
}
73+
}
74+
75+
static func copyText(_ data: ActivityTrendData) -> String {
76+
var lines: [String] = []
77+
if let change = data.change { lines.append("\(change.symbol) \(change.label)") }
78+
if let stat = data.stat { lines.append(stat) }
79+
lines.append("Throttled: \(data.throttleCount > 0 ? "\(data.throttleCount)×" : "0")")
80+
if let peak = data.peak { lines.append(peak) }
81+
return lines.joined(separator: " · ")
82+
}
83+
84+
// MARK: - Comparison helpers
85+
86+
static func changeVsYesterday(_ snapshot: UsageSnapshot, cal: Calendar = .current, now: Date = .init()) -> ActivityChangeInfo? {
87+
let yesterdayStr = DateFormatters.dateKey.string(
88+
from: cal.date(byAdding: .day, value: -1, to: now) ?? now
89+
)
90+
91+
guard let yesterday = snapshot.dailyActivity.first(where: { $0.date == yesterdayStr }) else {
92+
return nil
93+
}
94+
95+
let diff = snapshot.todayMessages - yesterday.messageCount
96+
return changeInfo(diff: diff, suffix: "vs yesterday")
97+
}
98+
99+
static func changeVsLastWeek(_ snapshot: UsageSnapshot, cal: Calendar = .current, now: Date = .init()) -> ActivityChangeInfo? {
100+
let today = cal.startOfDay(for: now)
101+
let weekday = cal.component(.weekday, from: today)
102+
let daysSinceMonday = (weekday + 5) % 7
103+
guard let thisWeekStart = cal.date(byAdding: .day, value: -daysSinceMonday, to: today),
104+
let lastWeekStart = cal.date(byAdding: .day, value: -7, to: thisWeekStart),
105+
let lastWeekSameDay = cal.date(byAdding: .day, value: daysSinceMonday, to: lastWeekStart) else {
106+
return nil
107+
}
108+
109+
let thisWeekRange = DateFormatters.dateKey.string(from: thisWeekStart)...DateFormatters.dateKey.string(from: today)
110+
let lastWeekRange = DateFormatters.dateKey.string(from: lastWeekStart)...DateFormatters.dateKey.string(from: lastWeekSameDay)
111+
112+
let thisWeekTotal = snapshot.dailyActivity
113+
.filter { thisWeekRange.contains($0.date) }
114+
.reduce(0) { $0 + $1.messageCount }
115+
let lastWeekTotal = snapshot.dailyActivity
116+
.filter { lastWeekRange.contains($0.date) }
117+
.reduce(0) { $0 + $1.messageCount }
118+
119+
guard lastWeekTotal > 0 else { return nil }
120+
return percentChangeInfo(current: thisWeekTotal, previous: lastWeekTotal, suffix: "vs last week")
121+
}
122+
123+
static func monthChangeInfo(thisMonth: Int, lastMonth: Int, cal: Calendar = .current, now: Date = .init()) -> ActivityChangeInfo? {
124+
guard lastMonth > 0 else { return nil }
125+
126+
let dayOfMonth = cal.component(.day, from: now)
127+
guard dayOfMonth >= 4,
128+
let daysInMonth = cal.range(of: .day, in: .month, for: now)?.count else { return nil }
129+
let projected = thisMonth * daysInMonth / dayOfMonth
130+
return percentChangeInfo(current: projected, previous: lastMonth, suffix: "vs last month")
131+
}
132+
133+
// MARK: - Shared formatting
134+
135+
private static func changeInfo(diff: Int, suffix: String) -> ActivityChangeInfo {
136+
if diff > 0 {
137+
return ActivityChangeInfo(symbol: "", label: "+\(diff) \(suffix)", color: ThemeColors.trendColor(.up))
138+
} else if diff < 0 {
139+
return ActivityChangeInfo(symbol: "", label: "\(diff) \(suffix)", color: ThemeColors.trendColor(.down))
140+
} else {
141+
return ActivityChangeInfo(symbol: "", label: "same as \(suffix.replacingOccurrences(of: "vs ", with: ""))", color: ThemeColors.secondaryLabel)
142+
}
143+
}
144+
145+
private static func percentChangeInfo(current: Int, previous: Int, suffix: String) -> ActivityChangeInfo {
146+
let diff = current - previous
147+
let pct = Int(round(Double(diff) / Double(previous) * 100))
148+
if pct > 10 {
149+
return ActivityChangeInfo(symbol: "", label: "+\(pct)% \(suffix)", color: ThemeColors.trendColor(.up))
150+
} else if pct < -10 {
151+
return ActivityChangeInfo(symbol: "", label: "\(pct)% \(suffix)", color: ThemeColors.trendColor(.down))
152+
} else {
153+
return ActivityChangeInfo(symbol: "", label: "~same as \(suffix.replacingOccurrences(of: "vs ", with: ""))", color: ThemeColors.secondaryLabel)
154+
}
155+
}
156+
}

AIBattery/Views/ActivityChartView.swift

Lines changed: 6 additions & 157 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ struct ActivityChartView: View {
8787
tooltip: "Message activity over time"
8888
)
8989
Spacer()
90-
if collapsed, let snapshot, let change = changeVsYesterday(snapshot) {
90+
if collapsed, let snapshot, let change = ActivityTrendComputation.changeVsYesterday(snapshot) {
9191
Text(change.symbol)
9292
.font(.system(size: 10, weight: .semibold, design: .monospaced))
9393
.foregroundStyle(change.color)
@@ -409,86 +409,19 @@ struct ActivityChartView: View {
409409

410410
// MARK: - Trend
411411

412-
/// Precomputed trend data — avoids duplicate Date()/Calendar/throttleCount calls.
413-
private struct TrendData {
414-
let change: ChangeInfo?
415-
let stat: String?
416-
let throttleCount: Int
417-
let peak: String?
418-
let throttleDays: Int
419-
}
420-
421412
private func trendSummary(_ snapshot: UsageSnapshot) -> some View {
422-
let data = computeTrendData(snapshot)
413+
let data = ActivityTrendComputation.compute(mode: mode, snapshot: snapshot, monthTotals: cachedMonthTotals)
423414
return VStack(spacing: 4) {
424415
trendRowTop(change: data.change, stat: data.stat)
425416
trendRowBottom(throttleCount: data.throttleCount, peak: data.peak)
426417
}
427418
.padding(.top, 4)
428-
.copyable(trendCopyText(data))
429-
}
430-
431-
/// Compute all trend values once per render — shared by display and copy text.
432-
private func computeTrendData(_ snapshot: UsageSnapshot) -> TrendData {
433-
let cal = Calendar.current
434-
let now = Date()
435-
436-
switch mode {
437-
case .hourly:
438-
return TrendData(
439-
change: changeVsYesterday(snapshot, cal: cal, now: now),
440-
stat: "\(snapshot.todayMessages) msgs today",
441-
throttleCount: UsageViewModel.throttleCount(days: 1),
442-
peak: snapshot.peakHour.map { "Peak: \(Self.formatHourLabel($0)):00" },
443-
throttleDays: 1
444-
)
445-
case .daily:
446-
return TrendData(
447-
change: changeVsLastWeek(snapshot, cal: cal, now: now),
448-
stat: snapshot.dailyAverage > 0 ? "\(snapshot.dailyAverage) avg/day" : nil,
449-
throttleCount: UsageViewModel.throttleCount(days: 7),
450-
peak: snapshot.busiestDayOfWeek.map { "Peak: \($0.name)s" },
451-
throttleDays: 7
452-
)
453-
case .monthly:
454-
let totals = cachedMonthTotals
455-
let nowComps = cal.dateComponents([.year, .month], from: now)
456-
let thisMonthKey = nowComps.year.flatMap { y in nowComps.month.map { m in String(format: "%04d-%02d", y, m) } }
457-
let lastMonthKey: String? = cal.date(byAdding: .month, value: -1, to: now).flatMap { d in
458-
let c = cal.dateComponents([.year, .month], from: d)
459-
return c.year.flatMap { y in c.month.map { m in String(format: "%04d-%02d", y, m) } }
460-
}
461-
let thisMonth = thisMonthKey.flatMap { totals[$0] } ?? 0
462-
let lastMonth = lastMonthKey.flatMap { totals[$0] } ?? 0
463-
let busiestLabel: String? = {
464-
guard let peak = totals.max(by: { $0.value < $1.value }),
465-
let date = DateFormatters.dateKey.date(from: peak.key + "-01") else { return nil }
466-
return Self.monthAbbrev(date)
467-
}()
468-
return TrendData(
469-
change: monthChangeInfo(thisMonth: thisMonth, lastMonth: lastMonth, cal: cal, now: now),
470-
stat: thisMonth > 0 ? "\(Self.compactCount(thisMonth)) this month" : nil,
471-
throttleCount: UsageViewModel.throttleCount(days: 30),
472-
peak: busiestLabel.map { "Peak: \($0)" },
473-
throttleDays: 30
474-
)
475-
}
476-
}
477-
478-
/// Build copy text from precomputed trend data.
479-
private func trendCopyText(_ data: TrendData) -> String {
480-
var lines: [String] = []
481-
if let change = data.change { lines.append("\(change.symbol) \(change.label)") }
482-
if let stat = data.stat { lines.append(stat) }
483-
lines.append("Throttled: \(data.throttleCount > 0 ? "\(data.throttleCount)×" : "0")")
484-
if let peak = data.peak { lines.append(peak) }
485-
return lines.joined(separator: " · ")
419+
.copyable(ActivityTrendComputation.copyText(data))
486420
}
487421

488422
// MARK: - Shared trend row builders
489423

490-
/// Row 1: change indicator (left) + summary stat (right).
491-
private func trendRowTop(change: ChangeInfo?, stat: String?) -> some View {
424+
private func trendRowTop(change: ActivityChangeInfo?, stat: String?) -> some View {
492425
HStack(spacing: 6) {
493426
if let change {
494427
Text(change.symbol)
@@ -507,7 +440,6 @@ struct ActivityChartView: View {
507440
}
508441
}
509442

510-
/// Row 2: throttle count (left) + peak label (right).
511443
private func trendRowBottom(throttleCount: Int, peak: String?) -> some View {
512444
HStack(spacing: 6) {
513445
if throttleCount > 0 {
@@ -528,89 +460,6 @@ struct ActivityChartView: View {
528460
}
529461
}
530462

531-
private struct ChangeInfo {
532-
let symbol: String
533-
let label: String
534-
let color: Color
535-
}
536-
537-
private func changeVsYesterday(_ snapshot: UsageSnapshot, cal: Calendar = .current, now: Date = .init()) -> ChangeInfo? {
538-
let yesterdayStr = DateFormatters.dateKey.string(
539-
from: cal.date(byAdding: .day, value: -1, to: now) ?? now
540-
)
541-
542-
guard let yesterday = snapshot.dailyActivity.first(where: { $0.date == yesterdayStr }) else {
543-
return nil
544-
}
545-
546-
let diff = snapshot.todayMessages - yesterday.messageCount
547-
548-
if diff > 0 {
549-
return ChangeInfo(symbol: "", label: "+\(diff) vs yesterday", color: ThemeColors.trendColor(.up))
550-
} else if diff < 0 {
551-
return ChangeInfo(symbol: "", label: "\(diff) vs yesterday", color: ThemeColors.trendColor(.down))
552-
} else {
553-
return ChangeInfo(symbol: "", label: "same as yesterday", color: ThemeColors.secondaryLabel)
554-
}
555-
}
556-
557-
/// Compare this week's messages vs the same days last week for a fair comparison.
558-
/// e.g. on Tuesday, compares Mon–Tue this week vs Mon–Tue last week.
559-
private func changeVsLastWeek(_ snapshot: UsageSnapshot, cal: Calendar = .current, now: Date = .init()) -> ChangeInfo? {
560-
let today = cal.startOfDay(for: now)
561-
let weekday = cal.component(.weekday, from: today)
562-
// Days since Monday (weekday 2 = Monday in Gregorian), 0 = Monday
563-
let daysSinceMonday = (weekday + 5) % 7
564-
guard let thisWeekStart = cal.date(byAdding: .day, value: -daysSinceMonday, to: today),
565-
let lastWeekStart = cal.date(byAdding: .day, value: -7, to: thisWeekStart),
566-
// Compare same span: Mon–today this week vs Mon–same day last week
567-
let lastWeekSameDay = cal.date(byAdding: .day, value: daysSinceMonday, to: lastWeekStart) else {
568-
return nil
569-
}
570-
571-
let thisWeekRange = DateFormatters.dateKey.string(from: thisWeekStart)...DateFormatters.dateKey.string(from: today)
572-
let lastWeekRange = DateFormatters.dateKey.string(from: lastWeekStart)...DateFormatters.dateKey.string(from: lastWeekSameDay)
573-
574-
let thisWeekTotal = snapshot.dailyActivity
575-
.filter { thisWeekRange.contains($0.date) }
576-
.reduce(0) { $0 + $1.messageCount }
577-
let lastWeekTotal = snapshot.dailyActivity
578-
.filter { lastWeekRange.contains($0.date) }
579-
.reduce(0) { $0 + $1.messageCount }
580-
581-
guard lastWeekTotal > 0 else { return nil }
582-
583-
let diff = thisWeekTotal - lastWeekTotal
584-
let pct = Int(round(Double(diff) / Double(lastWeekTotal) * 100))
585-
586-
if pct > 10 {
587-
return ChangeInfo(symbol: "", label: "+\(pct)% vs last week", color: ThemeColors.trendColor(.up))
588-
} else if pct < -10 {
589-
return ChangeInfo(symbol: "", label: "\(pct)% vs last week", color: ThemeColors.trendColor(.down))
590-
} else {
591-
return ChangeInfo(symbol: "", label: "~same as last week", color: ThemeColors.secondaryLabel)
592-
}
593-
}
594-
595-
private func monthChangeInfo(thisMonth: Int, lastMonth: Int, cal: Calendar = .current, now: Date = .init()) -> ChangeInfo? {
596-
guard lastMonth > 0 else { return nil }
597-
598-
let dayOfMonth = cal.component(.day, from: now)
599-
guard dayOfMonth >= 4,
600-
let daysInMonth = cal.range(of: .day, in: .month, for: now)?.count else { return nil }
601-
let projected = thisMonth * daysInMonth / dayOfMonth
602-
let diff = projected - lastMonth
603-
let pct = Int(round(Double(diff) / Double(lastMonth) * 100))
604-
605-
if pct > 10 {
606-
return ChangeInfo(symbol: "", label: "+\(pct)% vs last month", color: ThemeColors.trendColor(.up))
607-
} else if pct < -10 {
608-
return ChangeInfo(symbol: "", label: "\(pct)% vs last month", color: ThemeColors.trendColor(.down))
609-
} else {
610-
return ChangeInfo(symbol: "", label: "~same as last month", color: ThemeColors.secondaryLabel)
611-
}
612-
}
613-
614463
// MARK: - Tooltip annotation
615464

616465
/// Shared tooltip label styling used by all chart hover annotations.
@@ -655,7 +504,7 @@ struct ActivityChartView: View {
655504

656505
/// Compact integer counts for chart axes (e.g. 1500 → "2K").
657506
/// Differs from TokenFormatter which formats fractional token values (e.g. 1500 → "1.5K").
658-
private static func compactCount(_ value: Int) -> String {
507+
static func compactCount(_ value: Int) -> String {
659508
if value >= 1_000_000 {
660509
let m = Double(value) / 1_000_000
661510
return m == m.rounded() ? "\(Int(m))M" : String(format: "%.1fM", m)
@@ -670,7 +519,7 @@ struct ActivityChartView: View {
670519

671520
private static let hourLabels: [String] = (0..<24).map { String(format: "%02d", $0) }
672521

673-
private static func formatHourLabel(_ hour: Int) -> String {
522+
static func formatHourLabel(_ hour: Int) -> String {
674523
guard hour >= 0 && hour < 24 else { return String(format: "%02d", hour) }
675524
return hourLabels[hour]
676525
}

0 commit comments

Comments
 (0)