From d258aefbef910e6c449ba769a552a8f1a05f2a10 Mon Sep 17 00:00:00 2001 From: Luis Martins Date: Wed, 10 Jun 2026 11:57:55 +0100 Subject: [PATCH] Add home dashboard with usage stats and activity charts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track word counts by day and hour in UsageStats, persist alongside transcription history, and surface today/week totals, estimated time saved, a 7×24 activity heatmap, and a rolling 7-day bar chart on Home. --- Wave/Services/HistoryManager.swift | 97 +++++++++-- Wave/Services/UsageStats.swift | 37 ++++ Wave/Views/HomePageView.swift | 266 +++++++++++++++++++++++------ 3 files changed, 339 insertions(+), 61 deletions(-) create mode 100644 Wave/Services/UsageStats.swift diff --git a/Wave/Services/HistoryManager.swift b/Wave/Services/HistoryManager.swift index 3d0ce3a..c1052a4 100644 --- a/Wave/Services/HistoryManager.swift +++ b/Wave/Services/HistoryManager.swift @@ -3,14 +3,20 @@ import Foundation @Observable final class HistoryManager { private(set) var records: [TranscriptionRecord] = [] + private var usageStats = UsageStats() - init() { load() } + init() { + load() + backfillUsageFromRecords() + } func add(_ text: String) { let record = TranscriptionRecord(id: UUID(), text: text, date: Date()) records.insert(record, at: 0) if records.count > 50 { records = Array(records.prefix(50)) } + usageStats.record(words: record.wordCount, at: record.date) save() + saveUsage() } func remove(_ id: UUID) { @@ -24,14 +30,75 @@ final class HistoryManager { } var wordsToday: Int { - records.filter { Calendar.current.isDateInToday($0.date) } + let key = UsageStats.dayKey(for: Date()) + return usageStats.dailyWords[key] ?? records.filter { Calendar.current.isDateInToday($0.date) } .reduce(0) { $0 + $1.wordCount } } var wordsThisWeek: Int { - let start = Calendar.current.date(byAdding: .day, value: -7, to: Date())! - return records.filter { $0.date >= start } - .reduce(0) { $0 + $1.wordCount } + let calendar = Calendar.current + let start = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: Date()))! + return usageStats.dailyWords.reduce(0) { total, entry in + guard let date = Self.date(fromDayKey: entry.key), date >= start else { return total } + return total + entry.value + } + } + + var totalWords: Int { usageStats.totalWords } + + var estimatedTimeSavedSeconds: Int { usageStats.estimatedTimeSavedSeconds } + + var activityHeatmap: [[Int]] { + var grid = Array(repeating: Array(repeating: 0, count: 24), count: 7) + for weekday in 1...7 { + for hour in 0..<24 { + let key = UsageStats.gridKey(weekday: weekday, hour: hour) + grid[weekday - 1][hour] = usageStats.hourGrid[key] ?? 0 + } + } + return grid + } + + var activityHeatmapMax: Int { + activityHeatmap.flatMap { $0 }.max() ?? 0 + } + + var wordsLast7Days: [Int] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + return (0..<7).reversed().map { offset in + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return 0 } + return usageStats.dailyWords[UsageStats.dayKey(for: date)] ?? 0 + } + } + + var wordsLast7DayLabels: [String] { + let calendar = Calendar.current + let today = calendar.startOfDay(for: Date()) + let formatter = DateFormatter() + formatter.dateFormat = "EEE" + return (0..<7).reversed().map { offset in + guard let date = calendar.date(byAdding: .day, value: -offset, to: today) else { return "" } + return formatter.string(from: date) + } + } + + var wordsLast7DaysMax: Int { + wordsLast7Days.max() ?? 0 + } + + private func backfillUsageFromRecords() { + guard usageStats.totalWords == 0, !records.isEmpty else { return } + for record in records { + usageStats.record(words: record.wordCount, at: record.date) + } + saveUsage() + } + + private static func date(fromDayKey key: String) -> Date? { + let parts = key.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3 else { return nil } + return Calendar.current.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])) } private func save() { @@ -41,9 +108,19 @@ final class HistoryManager { } private func load() { - guard let data = UserDefaults.standard.data(forKey: "transcriptionHistory"), - let decoded = try? JSONDecoder().decode([TranscriptionRecord].self, from: data) - else { return } - records = decoded + if let data = UserDefaults.standard.data(forKey: "transcriptionHistory"), + let decoded = try? JSONDecoder().decode([TranscriptionRecord].self, from: data) { + records = decoded + } + if let data = UserDefaults.standard.data(forKey: "usageStats"), + let decoded = try? JSONDecoder().decode(UsageStats.self, from: data) { + usageStats = decoded + } + } + + private func saveUsage() { + if let data = try? JSONEncoder().encode(usageStats) { + UserDefaults.standard.set(data, forKey: "usageStats") + } } -} +} \ No newline at end of file diff --git a/Wave/Services/UsageStats.swift b/Wave/Services/UsageStats.swift new file mode 100644 index 0000000..1876fd7 --- /dev/null +++ b/Wave/Services/UsageStats.swift @@ -0,0 +1,37 @@ +import Foundation + +struct UsageStats: Codable { + var hourGrid: [String: Int] = [:] + var dailyWords: [String: Int] = [:] + var totalWords: Int = 0 + + static let typingWPM = 40.0 + static let dictationWPM = 140.0 + + static func gridKey(weekday: Int, hour: Int) -> String { + "\(weekday)-\(hour)" + } + + static func dayKey(for date: Date, calendar: Calendar = .current) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year!, components.month!, components.day!) + } + + var estimatedTimeSavedSeconds: Int { + let typingSecondsPerWord = 60.0 / Self.typingWPM + let dictationSecondsPerWord = 60.0 / Self.dictationWPM + let saved = Double(totalWords) * (typingSecondsPerWord - dictationSecondsPerWord) + return max(0, Int(saved.rounded())) + } + + mutating func record(words: Int, at date: Date, calendar: Calendar = .current) { + guard words > 0 else { return } + let weekday = calendar.component(.weekday, from: date) + let hour = calendar.component(.hour, from: date) + let gridKey = Self.gridKey(weekday: weekday, hour: hour) + hourGrid[gridKey, default: 0] += words + let dayKey = Self.dayKey(for: date, calendar: calendar) + dailyWords[dayKey, default: 0] += words + totalWords += words + } +} \ No newline at end of file diff --git a/Wave/Views/HomePageView.swift b/Wave/Views/HomePageView.swift index 573a188..bb6129f 100644 --- a/Wave/Views/HomePageView.swift +++ b/Wave/Views/HomePageView.swift @@ -6,78 +6,220 @@ struct HomePageView: View { @Environment(AppState.self) private var appState var body: some View { - VStack(alignment: .leading, spacing: 0) { - // Stats - HStack(spacing: 10) { - StatCard(value: appState.historyManager.wordsToday, label: "Today") - StatCard(value: appState.historyManager.wordsThisWeek, label: "This week") - } - .padding(16) - - // Recent transcriptions - if appState.historyManager.records.isEmpty { - Spacer() - VStack(spacing: 6) { - Image(systemName: "waveform") - .font(.system(size: 24)) - .foregroundStyle(.quaternary) - Text("No transcriptions yet") - .font(.system(size: 12)) - .foregroundStyle(.tertiary) + ScrollView { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 10) { + StatCard( + value: formatWordCount(appState.historyManager.wordsToday), + label: "Today · words" + ) + StatCard( + value: formatWordCount(appState.historyManager.wordsThisWeek), + label: "This week · words" + ) + StatCard( + value: formatDuration(appState.historyManager.estimatedTimeSavedSeconds), + label: "Time saved · est." + ) } - .frame(maxWidth: .infinity) - Spacer() - } else { - HStack { - Text("Recent") - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - Spacer() + + if appState.historyManager.totalWords > 0 { + section("Activity") { + ActivityHeatmapView( + grid: appState.historyManager.activityHeatmap, + maxValue: appState.historyManager.activityHeatmapMax + ) + Text("Darker cells mean more words dictated at that day and hour.") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + + section("This Week") { + WeekBarChartView( + counts: appState.historyManager.wordsLast7Days, + labels: appState.historyManager.wordsLast7DayLabels, + maxValue: appState.historyManager.wordsLast7DaysMax + ) + } } - .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 4) - - ScrollView { - LazyVStack(spacing: 0) { - ForEach(appState.historyManager.records.prefix(10)) { record in - TranscriptionRow(record: record) { - appState.historyManager.remove(record.id) + + if appState.historyManager.records.isEmpty { + emptyState + } else { + section("Recent") { + LazyVStack(spacing: 0) { + ForEach(appState.historyManager.records.prefix(5)) { record in + TranscriptionRow(record: record) { + appState.historyManager.remove(record.id) + } } } + Text("Right-click for more options") + .font(.system(size: 10)) + .foregroundStyle(.quaternary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) } } - - Text("Right-click for more options") - .font(.system(size: 10)) - .foregroundStyle(.quaternary) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 6) } + .padding(16) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } + + private var emptyState: some View { + VStack(spacing: 6) { + Image(systemName: "waveform") + .font(.system(size: 24)) + .foregroundStyle(.quaternary) + Text("No transcriptions yet") + .font(.system(size: 12)) + .foregroundStyle(.tertiary) + Text("Your activity chart and time-saved estimate will appear here.") + .font(.system(size: 11)) + .foregroundStyle(.quaternary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + } + + @ViewBuilder + private func section(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + content() + } + } } // MARK: - Stat Card private struct StatCard: View { - let value: Int + let value: String let label: String var body: some View { VStack(alignment: .leading, spacing: 2) { - Text("\(value)") - .font(.system(size: 22, weight: .semibold, design: .rounded)) - Text("\(label) \u{00B7} words") - .font(.system(size: 11)) + Text(value) + .font(.system(size: 20, weight: .semibold, design: .rounded)) + .minimumScaleFactor(0.7) + .lineLimit(1) + Text(label) + .font(.system(size: 10)) .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) } .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } +} + +// MARK: - Activity Heatmap + +private struct ActivityHeatmapView: View { + let grid: [[Int]] + let maxValue: Int + + private let weekdays = ["S", "M", "T", "W", "T", "F", "S"] + private let hourMarkers = [0, 6, 12, 18] + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 2) { + Text(" ") + .font(.system(size: 9, design: .monospaced)) + .frame(width: 12) + ForEach(hourMarkers, id: \.self) { hour in + Text(hourLabel(hour)) + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + ForEach(0..<7, id: \.self) { day in + HStack(spacing: 2) { + Text(weekdays[day]) + .font(.system(size: 9, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 12, alignment: .leading) + + HStack(spacing: 2) { + ForEach(0..<24, id: \.self) { hour in + let value = grid[day][hour] + RoundedRectangle(cornerRadius: 2) + .fill(cellColor(value)) + .frame(maxWidth: .infinity) + .frame(height: 10) + } + } + } + } + } + .padding(12) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) + } + + private func cellColor(_ value: Int) -> Color { + guard value > 0, maxValue > 0 else { + return Color.primary.opacity(0.06) + } + let intensity = sqrt(Double(value) / Double(maxValue)) + return Color.brand.opacity(0.15 + intensity * 0.85) + } + + private func hourLabel(_ hour: Int) -> String { + switch hour { + case 0: return "12a" + case 6: return "6a" + case 12: return "12p" + case 18: return "6p" + default: return "" + } + } +} + +// MARK: - Week Bar Chart + +private struct WeekBarChartView: View { + let counts: [Int] + let labels: [String] + let maxValue: Int + + var body: some View { + HStack(alignment: .bottom, spacing: 8) { + ForEach(counts.indices, id: \.self) { index in + VStack(spacing: 4) { + ZStack(alignment: .bottom) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.primary.opacity(0.06)) + .frame(height: 72) + RoundedRectangle(cornerRadius: 4) + .fill(Color.brand.opacity(counts[index] > 0 ? 0.85 : 0.15)) + .frame(height: barHeight(counts[index])) + } + Text(labels[index]) + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + } .padding(12) .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8)) } + + private func barHeight(_ count: Int) -> CGFloat { + guard count > 0 else { return 4 } + let peak = Swift.max(maxValue, 1) + return Swift.max(8, CGFloat(count) / CGFloat(peak) * 72) + } } // MARK: - Transcription Row @@ -93,7 +235,7 @@ private struct TranscriptionRow: View { VStack(alignment: .leading, spacing: 2) { Text(record.text) .font(.system(size: 12)) - .lineLimit(3) + .lineLimit(2) Text(timeLabel) .font(.system(size: 10)) .foregroundStyle(.tertiary) @@ -102,8 +244,9 @@ private struct TranscriptionRow: View { } Spacer(minLength: 4) } - .padding(.horizontal, 16) - .padding(.vertical, 8) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 6)) .contentShape(Rectangle()) .contextMenu { Button("Copy") { @@ -116,10 +259,31 @@ private struct TranscriptionRow: View { } } +// MARK: - Formatting + +private func formatWordCount(_ count: Int) -> String { + if count >= 1000 { + return String(format: "%.1fk", Double(count) / 1000) + } + return "\(count)" +} + +private func formatDuration(_ seconds: Int) -> String { + guard seconds > 0 else { return "0m" } + if seconds < 3600 { + let minutes = max(1, seconds / 60) + return "\(minutes)m" + } + let hours = seconds / 3600 + let minutes = (seconds % 3600) / 60 + if minutes == 0 { return "\(hours)h" } + return "\(hours)h \(minutes)m" +} + private func relativeTime(_ date: Date) -> String { let seconds = Int(-date.timeIntervalSinceNow) if seconds < 60 { return "just now" } if seconds < 3600 { return "\(seconds / 60)m ago" } if seconds < 86400 { return "\(seconds / 3600)h ago" } return "\(seconds / 86400)d ago" -} +} \ No newline at end of file