@@ -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