From 48e8a798b355693a7caf453a4ead0918ddb51448 Mon Sep 17 00:00:00 2001 From: Rasmus Schlunsen Date: Thu, 2 Apr 2026 23:30:08 +0200 Subject: [PATCH] Add persistent daily stats with weekly/monthly breakdowns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mac app now accumulates connection stats over time so users can see interesting trends weekly, monthly, and all-time. Stats are written to the shared history.db on every poll cycle (~5s). New: - StatsStore: SQLite daily_stats table + per-IP history writer - StatsView: bar chart (30-day), summary cards, weekly/monthly periods - Stats tab in globe window with expandable details panel - "This week" summary line in menu bar popover The mac app also writes to the Rust CLI's connections/seen_ports/ seen_processes tables, so history grows even when only the mac app runs. Storage: ~22 KB/year for daily_stats — negligible. Co-Authored-By: Claude Opus 4.6 --- .../Maperick/Models/ConnectionState.swift | 35 ++ .../Maperick/Models/NetworkStats.swift | 6 + .../Maperick/Maperick/Models/StatsStore.swift | 534 ++++++++++++++++++ .../Maperick/Views/GlobeWindowView.swift | 46 +- .../Maperick/Views/MenuBarPopoverView.swift | 16 + .../Maperick/Maperick/Views/StatsView.swift | 257 +++++++++ 6 files changed, 893 insertions(+), 1 deletion(-) create mode 100644 mac_app/Maperick/Maperick/Models/StatsStore.swift create mode 100644 mac_app/Maperick/Maperick/Views/StatsView.swift diff --git a/mac_app/Maperick/Maperick/Models/ConnectionState.swift b/mac_app/Maperick/Maperick/Models/ConnectionState.swift index 465d51d..4c984ee 100644 --- a/mac_app/Maperick/Maperick/Models/ConnectionState.swift +++ b/mac_app/Maperick/Maperick/Models/ConnectionState.swift @@ -6,14 +6,24 @@ import SwiftUI class ConnectionState { let monitor = NetworkMonitor() let history = HistoryReader() + let statsStore = StatsStore() var totalUniqueIPsEverSeen: Int = 0 var topHistoricalServers: [AllTimeServerStats] = [] + // Stats for UI + var dailyStats: [DailyStats] = [] + var weeklySummaries: [PeriodSummary] = [] + var monthlySummaries: [PeriodSummary] = [] + var allTimeSummary: AllTimeSummary? + var thisWeekSummary: (uniqueIPs: Int, totalConnections: Int) = (0, 0) + private var historyTimer: Timer? + private var statsTimer: Timer? init() { refreshHistoricalData() + refreshStats() startMonitoring() historyTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in @@ -21,6 +31,13 @@ class ConnectionState { self?.refreshHistoricalData() } } + + // Refresh stats display every 60 seconds (data is written every 5s via poll) + statsTimer = Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refreshStats() + } + } } func refreshHistoricalData() { @@ -28,7 +45,23 @@ class ConnectionState { topHistoricalServers = history.getTopServers(limit: 5) } + func refreshStats() { + dailyStats = statsStore.getDailyStats(days: 30) + weeklySummaries = statsStore.getWeeklySummaries(weeks: 8) + monthlySummaries = statsStore.getMonthlySummaries(months: 6) + allTimeSummary = statsStore.getAllTimeSummary() + thisWeekSummary = statsStore.getThisWeekSummary() + } + + /// Called after each network poll to record stats + func recordPollStats() { + statsStore.recordPoll(servers: monitor.servers, totalConnections: monitor.totalConnections) + } + func startMonitoring() { + monitor.onPollComplete = { [weak self] in + self?.recordPollStats() + } monitor.start() } @@ -36,5 +69,7 @@ class ConnectionState { monitor.stop() historyTimer?.invalidate() historyTimer = nil + statsTimer?.invalidate() + statsTimer = nil } } diff --git a/mac_app/Maperick/Maperick/Models/NetworkStats.swift b/mac_app/Maperick/Maperick/Models/NetworkStats.swift index 1338826..dbff0f2 100644 --- a/mac_app/Maperick/Maperick/Models/NetworkStats.swift +++ b/mac_app/Maperick/Maperick/Models/NetworkStats.swift @@ -81,6 +81,9 @@ class NetworkMonitor { /// Process filter — when set, only show connections from this process on the globe var processFilter: String? = nil + /// Called after each poll completes — used by ConnectionState to record stats + var onPollComplete: (() -> Void)? + private var timer: Timer? private var geoIPService: GeoIPService? private var userLocation: (latitude: Double, longitude: Double)? @@ -261,6 +264,9 @@ class NetworkMonitor { } else { self.globeScene.updateConnections(servers: sortedServers) } + + // Notify ConnectionState to record stats + self.onPollComplete?() } } } diff --git a/mac_app/Maperick/Maperick/Models/StatsStore.swift b/mac_app/Maperick/Maperick/Models/StatsStore.swift new file mode 100644 index 0000000..e6c11aa --- /dev/null +++ b/mac_app/Maperick/Maperick/Models/StatsStore.swift @@ -0,0 +1,534 @@ +import Foundation +import SQLite3 + +// MARK: - Data Models + +struct DailyStats: Identifiable { + var id: String { date } + let date: String // YYYY-MM-DD + let totalConnections: Int + let uniqueIPs: Int + let peakConnections: Int + let newIPs: Int + let topCountry: String + let topProcess: String +} + +struct PeriodSummary: Identifiable { + var id: String { label } + let label: String // e.g. "Mar 2026" or "Mar 9–15" + let days: Int + let totalConnections: Int + let uniqueIPs: Int + let peakConnections: Int + let peakDate: String + let newIPs: Int + let topCountry: String + let topProcess: String +} + +struct AllTimeSummary { + let totalDays: Int + let totalConnections: Int + let uniqueIPs: Int + let peakConnections: Int + let peakDate: String + let currentStreak: Int +} + +// MARK: - StatsStore + +/// Writes daily stats and per-IP history to the shared `history.db`. +/// The mac app writes to the same tables the Rust CLI uses (`connections`, `seen_ports`, `seen_processes`) +/// plus a new `daily_stats` table for time-series queries. +class StatsStore { + private var db: OpaquePointer? + + init() { + openDatabase() + } + + deinit { + if db != nil { + sqlite3_close(db) + } + } + + // MARK: - Database Setup + + private func openDatabase() { + let dir = AppConstants.dataDir + // Ensure directory exists + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let path = AppConstants.historyDBPath.path + + // Open read-write, create if needed + if sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, nil) != SQLITE_OK { + print("[Maperick] StatsStore: failed to open/create history database") + db = nil + return + } + + // WAL mode for concurrent reads (HistoryReader may be reading) + sqlite3_exec(db, "PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;", nil, nil, nil) + + // Create daily_stats table + sqlite3_exec(db, """ + CREATE TABLE IF NOT EXISTS daily_stats ( + date TEXT PRIMARY KEY, + total_connections INTEGER NOT NULL DEFAULT 0, + unique_ips INTEGER NOT NULL DEFAULT 0, + peak_connections INTEGER NOT NULL DEFAULT 0, + new_ips INTEGER NOT NULL DEFAULT 0, + top_country TEXT NOT NULL DEFAULT '', + top_process TEXT NOT NULL DEFAULT '' + ); + """, nil, nil, nil) + + // Ensure the Rust CLI tables also exist (in case CLI has never run) + sqlite3_exec(db, """ + CREATE TABLE IF NOT EXISTS connections ( + ip TEXT PRIMARY KEY, + location TEXT NOT NULL DEFAULT '', + country TEXT NOT NULL DEFAULT '', + first_seen INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + times_detected INTEGER NOT NULL DEFAULT 0, + total_connections INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS seen_ports ( + ip TEXT NOT NULL, + port INTEGER NOT NULL, + PRIMARY KEY (ip, port) + ); + CREATE TABLE IF NOT EXISTS seen_processes ( + ip TEXT NOT NULL, + name TEXT NOT NULL, + PRIMARY KEY (ip, name) + ); + """, nil, nil, nil) + } + + // MARK: - Write (called each poll ~5s) + + /// Record a poll's worth of data into the daily_stats and connections tables. + func recordPoll(servers: [ServerInfo], totalConnections: Int) { + guard let db = db else { return } + + let today = Self.todayString() + let now = Self.nowEpoch() + + // --- Update daily_stats for today --- + // Compute top country and top process from this poll + var countryCounts: [String: Int] = [:] + var processCounts: [String: Int] = [:] + for server in servers { + if !server.country.isEmpty { + countryCounts[server.country, default: 0] += server.connectionCount + } + for proc in server.processes { + processCounts[proc, default: 0] += server.connectionCount + } + } + let topCountry = countryCounts.sorted { $0.value > $1.value }.first?.key ?? "" + let topProcess = processCounts.sorted { $0.value > $1.value }.first?.key ?? "" + + let uniqueIPs = servers.count + + // Count new IPs (not yet in connections table) + var newIPs = 0 + for server in servers { + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, "SELECT 1 FROM connections WHERE ip = ?", -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, server.ip, -1, nil) + if sqlite3_step(stmt) != SQLITE_ROW { + newIPs += 1 + } + sqlite3_finalize(stmt) + } + } + + // UPSERT daily_stats + var stmt: OpaquePointer? + let upsertSQL = """ + INSERT INTO daily_stats (date, total_connections, unique_ips, peak_connections, new_ips, top_country, top_process) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(date) DO UPDATE SET + total_connections = total_connections + ?, + unique_ips = MAX(unique_ips, ?), + peak_connections = MAX(peak_connections, ?), + new_ips = new_ips + ?, + top_country = CASE WHEN ? != '' THEN ? ELSE daily_stats.top_country END, + top_process = CASE WHEN ? != '' THEN ? ELSE daily_stats.top_process END + """ + if sqlite3_prepare_v2(db, upsertSQL, -1, &stmt, nil) == SQLITE_OK { + // INSERT values + sqlite3_bind_text(stmt, 1, today, -1, nil) // date + sqlite3_bind_int(stmt, 2, Int32(totalConnections)) // total_connections (insert) + sqlite3_bind_int(stmt, 3, Int32(uniqueIPs)) // unique_ips (insert) + sqlite3_bind_int(stmt, 4, Int32(totalConnections)) // peak_connections (insert) + sqlite3_bind_int(stmt, 5, Int32(newIPs)) // new_ips (insert) + sqlite3_bind_text(stmt, 6, topCountry, -1, nil) // top_country (insert) + sqlite3_bind_text(stmt, 7, topProcess, -1, nil) // top_process (insert) + // UPDATE values + sqlite3_bind_int(stmt, 8, Int32(totalConnections)) // total_connections += ? + sqlite3_bind_int(stmt, 9, Int32(uniqueIPs)) // unique_ips = MAX(...) + sqlite3_bind_int(stmt, 10, Int32(totalConnections)) // peak_connections = MAX(...) + sqlite3_bind_int(stmt, 11, Int32(newIPs)) // new_ips += ? + sqlite3_bind_text(stmt, 12, topCountry, -1, nil) // top_country (update) + sqlite3_bind_text(stmt, 13, topCountry, -1, nil) + sqlite3_bind_text(stmt, 14, topProcess, -1, nil) // top_process (update) + sqlite3_bind_text(stmt, 15, topProcess, -1, nil) + sqlite3_step(stmt) + sqlite3_finalize(stmt) + } + + // --- Also UPSERT per-IP connections (same as Rust CLI) --- + for server in servers { + let ip = server.ip + let location = server.city + let country = server.country + + // UPSERT connection + let connSQL = """ + INSERT INTO connections (ip, location, country, first_seen, last_seen, times_detected, total_connections) + VALUES (?, ?, ?, ?, ?, 1, ?) + ON CONFLICT(ip) DO UPDATE SET + location = ?, + country = ?, + first_seen = MIN(connections.first_seen, ?), + last_seen = MAX(connections.last_seen, ?), + times_detected = connections.times_detected + 1, + total_connections = connections.total_connections + ? + """ + if sqlite3_prepare_v2(db, connSQL, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, ip, -1, nil) + sqlite3_bind_text(stmt, 2, location, -1, nil) + sqlite3_bind_text(stmt, 3, country, -1, nil) + sqlite3_bind_int64(stmt, 4, Int64(now)) + sqlite3_bind_int64(stmt, 5, Int64(now)) + sqlite3_bind_int(stmt, 6, Int32(server.connectionCount)) + // UPDATE values + sqlite3_bind_text(stmt, 7, location, -1, nil) + sqlite3_bind_text(stmt, 8, country, -1, nil) + sqlite3_bind_int64(stmt, 9, Int64(now)) + sqlite3_bind_int64(stmt, 10, Int64(now)) + sqlite3_bind_int(stmt, 11, Int32(server.connectionCount)) + sqlite3_step(stmt) + sqlite3_finalize(stmt) + } + + // INSERT OR IGNORE ports + for port in server.ports { + if sqlite3_prepare_v2(db, "INSERT OR IGNORE INTO seen_ports (ip, port) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, ip, -1, nil) + sqlite3_bind_int(stmt, 2, Int32(port)) + sqlite3_step(stmt) + sqlite3_finalize(stmt) + } + } + + // INSERT OR IGNORE processes + for proc in server.processes { + if sqlite3_prepare_v2(db, "INSERT OR IGNORE INTO seen_processes (ip, name) VALUES (?, ?)", -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, ip, -1, nil) + sqlite3_bind_text(stmt, 2, proc, -1, nil) + sqlite3_step(stmt) + sqlite3_finalize(stmt) + } + } + } + } + + // MARK: - Query Methods + + /// Get the last N days of stats (for bar chart) + func getDailyStats(days: Int = 30) -> [DailyStats] { + guard let db = db else { return [] } + + var stmt: OpaquePointer? + let sql = """ + SELECT date, total_connections, unique_ips, peak_connections, new_ips, top_country, top_process + FROM daily_stats + ORDER BY date DESC + LIMIT ? + """ + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(days)) + + var results: [DailyStats] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + results.append(DailyStats( + date: String(cString: sqlite3_column_text(stmt, 0)), + totalConnections: Int(sqlite3_column_int(stmt, 1)), + uniqueIPs: Int(sqlite3_column_int(stmt, 2)), + peakConnections: Int(sqlite3_column_int(stmt, 3)), + newIPs: Int(sqlite3_column_int(stmt, 4)), + topCountry: String(cString: sqlite3_column_text(stmt, 5)), + topProcess: String(cString: sqlite3_column_text(stmt, 6)) + )) + } + return results.reversed() // chronological order + } + + /// Get monthly summaries (last N months) + func getMonthlySummaries(months: Int = 6) -> [PeriodSummary] { + guard let db = db else { return [] } + + let sql = """ + SELECT + SUBSTR(date, 1, 7) AS month, + COUNT(*) AS days, + SUM(total_connections) AS total_connections, + MAX(unique_ips) AS max_unique_ips, + MAX(peak_connections) AS peak_connections, + SUM(new_ips) AS new_ips, + (SELECT d2.date FROM daily_stats d2 WHERE SUBSTR(d2.date, 1, 7) = month ORDER BY d2.peak_connections DESC LIMIT 1) AS peak_date, + (SELECT d3.top_country FROM daily_stats d3 WHERE SUBSTR(d3.date, 1, 7) = month AND d3.top_country != '' ORDER BY d3.total_connections DESC LIMIT 1) AS top_country, + (SELECT d4.top_process FROM daily_stats d4 WHERE SUBSTR(d4.date, 1, 7) = month AND d4.top_process != '' ORDER BY d4.total_connections DESC LIMIT 1) AS top_process + FROM daily_stats + GROUP BY month + ORDER BY month DESC + LIMIT ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(months)) + + var results: [PeriodSummary] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let monthStr = String(cString: sqlite3_column_text(stmt, 0)) + let label = Self.formatMonth(monthStr) + let peakDateStr = sqlite3_column_text(stmt, 5).map { String(cString: $0) } ?? "" + let topCountry = sqlite3_column_text(stmt, 6).map { String(cString: $0) } ?? "" + let topProcess = sqlite3_column_text(stmt, 7).map { String(cString: $0) } ?? "" + + results.append(PeriodSummary( + label: label, + days: Int(sqlite3_column_int(stmt, 1)), + totalConnections: Int(sqlite3_column_int64(stmt, 2)), + uniqueIPs: Int(sqlite3_column_int(stmt, 3)), + peakConnections: Int(sqlite3_column_int(stmt, 4)), + peakDate: Self.formatDateShort(peakDateStr), + newIPs: Int(sqlite3_column_int(stmt, 5)), + topCountry: topCountry, + topProcess: topProcess + )) + } + return results.reversed() + } + + /// Get weekly summaries (last N weeks) + func getWeeklySummaries(weeks: Int = 8) -> [PeriodSummary] { + guard let db = db else { return [] } + + // SQLite: compute ISO week via strftime + let sql = """ + SELECT + STRFTIME('%Y-W%W', date) AS week, + MIN(date) AS week_start, + MAX(date) AS week_end, + COUNT(*) AS days, + SUM(total_connections) AS total_connections, + MAX(unique_ips) AS max_unique_ips, + MAX(peak_connections) AS peak_connections, + SUM(new_ips) AS new_ips, + (SELECT d2.date FROM daily_stats d2 WHERE STRFTIME('%Y-W%W', d2.date) = week ORDER BY d2.peak_connections DESC LIMIT 1) AS peak_date, + (SELECT d3.top_country FROM daily_stats d3 WHERE STRFTIME('%Y-W%W', d3.date) = week AND d3.top_country != '' ORDER BY d3.total_connections DESC LIMIT 1) AS top_country, + (SELECT d4.top_process FROM daily_stats d4 WHERE STRFTIME('%Y-W%W', d4.date) = week AND d4.top_process != '' ORDER BY d4.total_connections DESC LIMIT 1) AS top_process + FROM daily_stats + GROUP BY week + ORDER BY week DESC + LIMIT ? + """ + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else { return [] } + defer { sqlite3_finalize(stmt) } + sqlite3_bind_int(stmt, 1, Int32(weeks)) + + var results: [PeriodSummary] = [] + while sqlite3_step(stmt) == SQLITE_ROW { + let startStr = sqlite3_column_text(stmt, 1).map { String(cString: $0) } ?? "" + let endStr = sqlite3_column_text(stmt, 2).map { String(cString: $0) } ?? "" + let peakDateStr = sqlite3_column_text(stmt, 9).map { String(cString: $0) } ?? "" + let topCountry = sqlite3_column_text(stmt, 10).map { String(cString: $0) } ?? "" + let topProcess = sqlite3_column_text(stmt, 11).map { String(cString: $0) } ?? "" + + results.append(PeriodSummary( + label: "\(Self.formatDateShort(startStr)) – \(Self.formatDateShort(endStr))", + days: Int(sqlite3_column_int(stmt, 3)), + totalConnections: Int(sqlite3_column_int64(stmt, 4)), + uniqueIPs: Int(sqlite3_column_int(stmt, 5)), + peakConnections: Int(sqlite3_column_int(stmt, 6)), + peakDate: Self.formatDateShort(peakDateStr), + newIPs: Int(sqlite3_column_int(stmt, 7)), + topCountry: topCountry, + topProcess: topProcess + )) + } + return results.reversed() + } + + /// Get all-time summary + func getAllTimeSummary() -> AllTimeSummary { + guard let db = db else { + return AllTimeSummary(totalDays: 0, totalConnections: 0, uniqueIPs: 0, peakConnections: 0, peakDate: "", currentStreak: 0) + } + + var totalDays = 0 + var totalConnections = 0 + var peakConnections = 0 + var peakDate = "" + var uniqueIPs = 0 + + // Aggregate from daily_stats + var stmt: OpaquePointer? + if sqlite3_prepare_v2(db, """ + SELECT COUNT(*), COALESCE(SUM(total_connections), 0), COALESCE(MAX(peak_connections), 0), + (SELECT date FROM daily_stats ORDER BY peak_connections DESC LIMIT 1) + FROM daily_stats + """, -1, &stmt, nil) == SQLITE_OK { + if sqlite3_step(stmt) == SQLITE_ROW { + totalDays = Int(sqlite3_column_int(stmt, 0)) + totalConnections = Int(sqlite3_column_int64(stmt, 1)) + peakConnections = Int(sqlite3_column_int(stmt, 2)) + peakDate = sqlite3_column_text(stmt, 3).map { String(cString: $0) } ?? "" + } + sqlite3_finalize(stmt) + } + + // Unique IPs from connections table + if sqlite3_prepare_v2(db, "SELECT COUNT(*) FROM connections", -1, &stmt, nil) == SQLITE_OK { + if sqlite3_step(stmt) == SQLITE_ROW { + uniqueIPs = Int(sqlite3_column_int(stmt, 0)) + } + sqlite3_finalize(stmt) + } + + // Compute current streak (consecutive days with data ending today) + let streak = computeStreak() + + return AllTimeSummary( + totalDays: totalDays, + totalConnections: totalConnections, + uniqueIPs: uniqueIPs, + peakConnections: peakConnections, + peakDate: Self.formatDateShort(peakDate), + currentStreak: streak + ) + } + + /// Get this week's summary for the popover + func getThisWeekSummary() -> (uniqueIPs: Int, totalConnections: Int) { + guard let db = db else { return (0, 0) } + + // Get start of current week (Monday) + let today = Self.todayDate() + let calendar = Calendar(identifier: .iso8601) + guard let weekStart = calendar.date(from: calendar.dateComponents([.yearForWeekOfYear, .weekOfYear], from: today)) else { + return (0, 0) + } + let weekStartStr = Self.dateFormatter().string(from: weekStart) + + var stmt: OpaquePointer? + var uniqueIPs = 0 + var totalConns = 0 + + if sqlite3_prepare_v2(db, """ + SELECT COALESCE(SUM(total_connections), 0), MAX(unique_ips) + FROM daily_stats WHERE date >= ? + """, -1, &stmt, nil) == SQLITE_OK { + sqlite3_bind_text(stmt, 1, weekStartStr, -1, nil) + if sqlite3_step(stmt) == SQLITE_ROW { + totalConns = Int(sqlite3_column_int64(stmt, 0)) + uniqueIPs = Int(sqlite3_column_int(stmt, 1)) + } + sqlite3_finalize(stmt) + } + + return (uniqueIPs, totalConns) + } + + // MARK: - Private Helpers + + private func computeStreak() -> Int { + guard let db = db else { return 0 } + + // Get all dates with data, ordered descending + var stmt: OpaquePointer? + var dates: [String] = [] + if sqlite3_prepare_v2(db, "SELECT date FROM daily_stats ORDER BY date DESC", -1, &stmt, nil) == SQLITE_OK { + while sqlite3_step(stmt) == SQLITE_ROW { + dates.append(String(cString: sqlite3_column_text(stmt, 0))) + } + sqlite3_finalize(stmt) + } + + guard !dates.isEmpty else { return 0 } + + let today = Self.todayString() + let calendar = Calendar(identifier: .iso8601) + let fmt = Self.dateFormatter() + + // Streak must include today or yesterday + guard let firstDate = fmt.date(from: dates[0]), + let todayDate = fmt.date(from: today) else { return 0 } + + let daysDiff = calendar.dateComponents([.day], from: firstDate, to: todayDate).day ?? 0 + guard daysDiff <= 1 else { return 0 } + + var streak = 1 + for i in 1.. String { + dateFormatter().string(from: Date()) + } + + private static func todayDate() -> Date { Date() } + + private static func nowEpoch() -> Int64 { + Int64(Date().timeIntervalSince1970) + } + + private static func dateFormatter() -> DateFormatter { + let f = DateFormatter() + f.dateFormat = "yyyy-MM-dd" + f.locale = Locale(identifier: "en_US_POSIX") + return f + } + + /// Format "2026-03" → "Mar 2026" + private static func formatMonth(_ iso: String) -> String { + let parts = iso.split(separator: "-") + guard parts.count == 2, let month = Int(parts[1]) else { return iso } + let months = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + guard month >= 1 && month <= 12 else { return iso } + return "\(months[month]) \(parts[0])" + } + + /// Format "2026-03-15" → "Mar 15" + private static func formatDateShort(_ iso: String) -> String { + guard iso.count >= 10 else { return iso } + let parts = iso.split(separator: "-") + guard parts.count == 3, let month = Int(parts[1]) else { return iso } + let months = ["", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + guard month >= 1 && month <= 12 else { return iso } + return "\(months[month]) \(parts[2])" + } +} diff --git a/mac_app/Maperick/Maperick/Views/GlobeWindowView.swift b/mac_app/Maperick/Maperick/Views/GlobeWindowView.swift index 539aa76..2766272 100644 --- a/mac_app/Maperick/Maperick/Views/GlobeWindowView.swift +++ b/mac_app/Maperick/Maperick/Views/GlobeWindowView.swift @@ -35,6 +35,7 @@ struct GlobeWindowView: View { @State private var selectedProcess: String? = nil @State private var followMode: Bool = false @State private var isHoveringClose: Bool = false + @State private var showStatsPanel: Bool = false var body: some View { VStack(spacing: 0) { @@ -135,9 +136,10 @@ struct GlobeWindowView: View { Text("Servers").tag(0) Text("Processes").tag(1) Text("History").tag(2) + Text("Stats").tag(3) } .pickerStyle(.segmented) - .frame(width: 280) + .frame(width: 340) Divider() .frame(height: 20) @@ -214,6 +216,40 @@ struct GlobeWindowView: View { } } } + case 3: + HStack(spacing: 8) { + Image(systemName: "chart.bar") + .font(.system(size: 10)) + .foregroundColor(.accentColor) + Text("\(state.allTimeSummary?.totalDays ?? 0) days tracked") + .font(.system(size: 11)) + .foregroundColor(.secondary) + + if let summary = state.allTimeSummary, summary.uniqueIPs > 0 { + Text("·") + .foregroundColor(.secondary) + Text("\(summary.uniqueIPs) unique IPs") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + + Spacer() + + Button(action: { + withAnimation(.easeInOut(duration: 0.25)) { + showStatsPanel.toggle() + } + }) { + HStack(spacing: 3) { + Image(systemName: showStatsPanel ? "chevron.down" : "chevron.up") + .font(.system(size: 8, weight: .bold)) + Text(showStatsPanel ? "Hide" : "Details") + .font(.system(size: 10, weight: .medium)) + } + .foregroundColor(.accentColor) + } + .buttonStyle(.plain) + } default: EmptyView() } @@ -223,6 +259,14 @@ struct GlobeWindowView: View { .padding(.horizontal, 12) .padding(.vertical, 8) .frame(height: 40) + + // Expandable stats panel + if showStatsPanel { + Divider() + StatsView(state: state) + .frame(maxHeight: 300) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } } .frame(minWidth: 700, minHeight: 550) .background(WindowAccessor()) diff --git a/mac_app/Maperick/Maperick/Views/MenuBarPopoverView.swift b/mac_app/Maperick/Maperick/Views/MenuBarPopoverView.swift index 737553a..0b1433a 100644 --- a/mac_app/Maperick/Maperick/Views/MenuBarPopoverView.swift +++ b/mac_app/Maperick/Maperick/Views/MenuBarPopoverView.swift @@ -90,6 +90,22 @@ struct MenuBarPopoverView: View { } .padding(.horizontal, 12) .padding(.vertical, 4) + } + + // Weekly summary + let weekSummary = state.thisWeekSummary + if weekSummary.totalConnections > 0 { + HStack(spacing: 6) { + Image(systemName: "chart.bar.fill") + .font(.system(size: 9)) + .foregroundColor(.accentColor) + Text("This week: \(weekSummary.uniqueIPs) IPs, \(weekSummary.totalConnections) connections") + .font(.system(size: 10)) + .foregroundColor(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) Divider() } diff --git a/mac_app/Maperick/Maperick/Views/StatsView.swift b/mac_app/Maperick/Maperick/Views/StatsView.swift new file mode 100644 index 0000000..8d5ae11 --- /dev/null +++ b/mac_app/Maperick/Maperick/Views/StatsView.swift @@ -0,0 +1,257 @@ +import SwiftUI + +struct StatsView: View { + var state: ConnectionState + @State private var selectedPeriod: Int = 0 // 0 = week, 1 = month + + var body: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + // Summary cards + summaryCards + + Divider() + .overlay(Color.white.opacity(0.1)) + + // Bar chart + barChartSection + + Divider() + .overlay(Color.white.opacity(0.1)) + + // Period breakdown + periodSection + } + .padding(16) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + // MARK: - Summary Cards + + private var summaryCards: some View { + let summary = state.allTimeSummary + + return LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + GridItem(.flexible(), spacing: 8), + ], spacing: 8) { + StatCard( + title: "Days Monitored", + value: "\(summary?.totalDays ?? 0)", + icon: "calendar", + color: .blue + ) + StatCard( + title: "Unique IPs", + value: "\(summary?.uniqueIPs ?? 0)", + icon: "network", + color: .green + ) + StatCard( + title: "Total Connections", + value: formatNumber(summary?.totalConnections ?? 0), + icon: "arrow.up.arrow.down", + color: .orange + ) + StatCard( + title: "Peak", + value: "\(summary?.peakConnections ?? 0)", + subtitle: summary?.peakDate ?? "", + icon: "flame.fill", + color: .red + ) + StatCard( + title: "Current Streak", + value: "\(summary?.currentStreak ?? 0) days", + icon: "bolt.fill", + color: .yellow + ) + } + } + + // MARK: - Bar Chart + + private var barChartSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("LAST 30 DAYS") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(.secondary) + + DailyBarChart(data: state.dailyStats) + .frame(height: 120) + } + } + + // MARK: - Period Breakdown + + private var periodSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("BREAKDOWN") + .font(.system(size: 10, weight: .bold, design: .monospaced)) + .foregroundColor(.secondary) + + Picker("", selection: $selectedPeriod) { + Text("Weekly").tag(0) + Text("Monthly").tag(1) + } + .pickerStyle(.segmented) + .frame(width: 160) + } + + let periods = selectedPeriod == 0 ? state.weeklySummaries : state.monthlySummaries + + if periods.isEmpty { + Text("No data yet — stats accumulate as the app runs") + .font(.system(size: 11)) + .foregroundColor(.secondary) + .padding(.top, 4) + } else { + ForEach(periods) { period in + PeriodRow(period: period) + } + } + } + } + + // MARK: - Helpers + + private func formatNumber(_ n: Int) -> String { + if n >= 1_000_000 { + return String(format: "%.1fM", Double(n) / 1_000_000.0) + } else if n >= 1_000 { + return String(format: "%.1fK", Double(n) / 1_000.0) + } + return "\(n)" + } +} + +// MARK: - Stat Card + +private struct StatCard: View { + let title: String + let value: String + var subtitle: String = "" + let icon: String + let color: Color + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9)) + .foregroundColor(color) + Text(title) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.secondary) + } + + Text(value) + .font(.system(size: 16, weight: .bold, design: .monospaced)) + .foregroundColor(.white.opacity(0.9)) + + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 9)) + .foregroundColor(.secondary) + } + } + .padding(10) + .background(Color.white.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} + +// MARK: - Daily Bar Chart + +private struct DailyBarChart: View { + let data: [DailyStats] + + var body: some View { + Canvas { context, size in + guard !data.isEmpty else { return } + + let maxValue = max(data.map(\.peakConnections).max() ?? 1, 1) + let barWidth = max((size.width - CGFloat(data.count - 1) * 2) / CGFloat(data.count), 3) + let spacing: CGFloat = 2 + + for (index, stat) in data.enumerated() { + let x = CGFloat(index) * (barWidth + spacing) + let barHeight = max(CGFloat(stat.peakConnections) / CGFloat(maxValue) * size.height, 1) + let y = size.height - barHeight + + let rect = CGRect(x: x, y: y, width: barWidth, height: barHeight) + let barColor = chartColor(for: stat.peakConnections, max: maxValue) + + context.fill( + Path(roundedRect: rect, cornerRadius: min(barWidth / 2, 2)), + with: .color(barColor) + ) + } + } + } + + private func chartColor(for value: Int, max: Int) -> Color { + let ratio = Double(value) / Double(max) + if ratio > 0.7 { return .red.opacity(0.8) } + if ratio > 0.3 { return .yellow.opacity(0.8) } + return .green.opacity(0.7) + } +} + +// MARK: - Period Row + +private struct PeriodRow: View { + let period: PeriodSummary + + var body: some View { + HStack(spacing: 12) { + // Period label + Text(period.label) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white.opacity(0.85)) + .frame(width: 120, alignment: .leading) + + // Stats + HStack(spacing: 16) { + statChip(icon: "network", value: "\(period.uniqueIPs) IPs", color: .green) + statChip(icon: "arrow.up.arrow.down", value: formatNumber(period.totalConnections), color: .orange) + statChip(icon: "flame", value: "Peak \(period.peakConnections)", color: .red) + if !period.topCountry.isEmpty { + statChip(icon: "globe", value: period.topCountry, color: .blue) + } + if !period.topProcess.isEmpty { + statChip(icon: "app", value: period.topProcess, color: .purple) + } + } + + Spacer() + } + .padding(.vertical, 6) + .padding(.horizontal, 10) + .background(Color.white.opacity(0.03)) + .clipShape(RoundedRectangle(cornerRadius: 5)) + } + + private func statChip(icon: String, value: String, color: Color) -> some View { + HStack(spacing: 3) { + Image(systemName: icon) + .font(.system(size: 8)) + .foregroundColor(color) + Text(value) + .font(.system(size: 10, weight: .medium, design: .monospaced)) + .foregroundColor(.secondary) + } + } + + private func formatNumber(_ n: Int) -> String { + if n >= 1_000_000 { + return String(format: "%.1fM", Double(n) / 1_000_000.0) + } else if n >= 1_000 { + return String(format: "%.1fK", Double(n) / 1_000.0) + } + return "\(n)" + } +}