From 825ef07aaa5ecd2a87c0e70b24636d49718da91d Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:48:24 +0900 Subject: [PATCH 01/14] feat(history): add hourly_work field to DailySummary --- internal/ai/ai_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++ internal/ai/history.go | 11 +++++---- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/internal/ai/ai_test.go b/internal/ai/ai_test.go index 78e4ef6..d1d0c0a 100644 --- a/internal/ai/ai_test.go +++ b/internal/ai/ai_test.go @@ -130,3 +130,57 @@ func TestHistoryFileFormat(t *testing.T) { t.Fatalf("history file should be valid JSON: %v", err) } } + +func TestDailySummaryHourlyWorkPersists(t *testing.T) { + origPath := historyPathOverride + defer func() { historyPathOverride = origPath }() + historyPathOverride = filepath.Join(t.TempDir(), "history.json") + + summary := DailySummary{ + Date: "2026-04-17", + WorkMin: 280, + BreakMin: 60, + Sessions: 7, + Activities: 3, + HourlyWork: [24]int{0, 0, 0, 0, 0, 0, 0, 0, 0, 45, 55, 50, 10, 40, 50, 35, 20, 0, 0, 0, 0, 0, 0, 0}, + } + if err := AppendHistory(summary); err != nil { + t.Fatalf("AppendHistory: %v", err) + } + + history, err := LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 entry, got %d", len(history)) + } + if history[0].HourlyWork[10] != 55 { + t.Errorf("HourlyWork[10] = %d, want 55", history[0].HourlyWork[10]) + } +} + +func TestDailySummaryBackwardCompatMissingHourly(t *testing.T) { + origPath := historyPathOverride + defer func() { historyPathOverride = origPath }() + historyPathOverride = filepath.Join(t.TempDir(), "history.json") + + // Write legacy JSON without hourly_work field + legacy := `[{"date":"2026-04-16","work_min":200,"break_min":40,"sessions":4,"activities":2}]` + if err := os.WriteFile(historyPathOverride, []byte(legacy), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + history, err := LoadHistory() + if err != nil { + t.Fatalf("LoadHistory: %v", err) + } + if len(history) != 1 { + t.Fatalf("expected 1 entry, got %d", len(history)) + } + for i, v := range history[0].HourlyWork { + if v != 0 { + t.Errorf("HourlyWork[%d] = %d, want 0 for legacy data", i, v) + } + } +} diff --git a/internal/ai/history.go b/internal/ai/history.go index 9a3cc23..11f364d 100644 --- a/internal/ai/history.go +++ b/internal/ai/history.go @@ -8,11 +8,12 @@ import ( // DailySummary represents one day's usage statistics. type DailySummary struct { - Date string `json:"date"` - WorkMin int `json:"work_min"` - BreakMin int `json:"break_min"` - Sessions int `json:"sessions"` - Activities int `json:"activities"` + Date string `json:"date"` + WorkMin int `json:"work_min"` + BreakMin int `json:"break_min"` + Sessions int `json:"sessions"` + Activities int `json:"activities"` + HourlyWork [24]int `json:"hourly_work"` } // historyPathOverride allows tests to redirect the history file. From eed97e9d28669e8e0faae7321758bec77712de3f Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:49:57 +0900 Subject: [PATCH 02/14] feat(state): add hourly_work tracking to State Add HourlyWork [24]int field to State struct, with HOURLY_WORK comma-separated serialization in serialize() and a matching parser case in Load(). Includes round-trip and backward-compat tests. --- internal/state/state.go | 43 +++++++++++++++++++++---------- internal/state/state_test.go | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/internal/state/state.go b/internal/state/state.go index 9eaa3a1..bb36711 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -31,6 +31,7 @@ type State struct { TodayBreakSeconds int `json:"today_break_seconds"` LastUpdateDate string `json:"last_update_date"` LastBreakWarningBucket int `json:"last_break_warning_bucket"` + HourlyWork [24]int `json:"hourly_work"` } // DefaultStatePath returns ~/.break-reminder-state @@ -267,6 +268,15 @@ func Load(path string) (State, error) { if v, err := strconv.Atoi(value); err == nil { s.LastBreakWarningBucket = v } + case "HOURLY_WORK": + parts := strings.Split(value, ",") + if len(parts) == 24 { + for i, p := range parts { + if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + s.HourlyWork[i] = n + } + } + } } } @@ -274,19 +284,26 @@ func Load(path string) (State, error) { } func serialize(s State) string { - return fmt.Sprintf(`WORK_SECONDS=%d -MODE=%s -LAST_CHECK=%d -BREAK_START=%d -SNOOZE_UNTIL=%d -PAUSED=%t -PAUSED_AT=%d -TODAY_WORK_SECONDS=%d -TODAY_BREAK_SECONDS=%d -LAST_UPDATE_DATE=%s -LAST_BREAK_WARNING_BUCKET=%d -`, s.WorkSeconds, s.Mode, s.LastCheck, s.BreakStart, - s.SnoozeUntil, s.Paused, s.PausedAt, s.TodayWorkSeconds, s.TodayBreakSeconds, s.LastUpdateDate, s.LastBreakWarningBucket) + var b strings.Builder + fmt.Fprintf(&b, "WORK_SECONDS=%d\n", s.WorkSeconds) + fmt.Fprintf(&b, "MODE=%s\n", s.Mode) + fmt.Fprintf(&b, "LAST_CHECK=%d\n", s.LastCheck) + fmt.Fprintf(&b, "BREAK_START=%d\n", s.BreakStart) + fmt.Fprintf(&b, "SNOOZE_UNTIL=%d\n", s.SnoozeUntil) + fmt.Fprintf(&b, "PAUSED=%t\n", s.Paused) + fmt.Fprintf(&b, "PAUSED_AT=%d\n", s.PausedAt) + fmt.Fprintf(&b, "TODAY_WORK_SECONDS=%d\n", s.TodayWorkSeconds) + fmt.Fprintf(&b, "TODAY_BREAK_SECONDS=%d\n", s.TodayBreakSeconds) + fmt.Fprintf(&b, "LAST_UPDATE_DATE=%s\n", s.LastUpdateDate) + fmt.Fprintf(&b, "LAST_BREAK_WARNING_BUCKET=%d\n", s.LastBreakWarningBucket) + + hourlyParts := make([]string, 24) + for i, v := range s.HourlyWork { + hourlyParts[i] = strconv.Itoa(v) + } + fmt.Fprintf(&b, "HOURLY_WORK=%s\n", strings.Join(hourlyParts, ",")) + + return b.String() } func saveUnlocked(path string, s State) error { diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 8ccb2d5..4a67c6b 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -380,3 +380,52 @@ func TestSnoozeBreakRejectsInvalidModes(t *testing.T) { }) } } + +func TestStateHourlyWorkRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state") + + s := New() + s.HourlyWork[9] = 600 + s.HourlyWork[14] = 1200 + + if err := Save(path, s); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded.HourlyWork[9] != 600 { + t.Errorf("HourlyWork[9] = %d, want 600", loaded.HourlyWork[9]) + } + if loaded.HourlyWork[14] != 1200 { + t.Errorf("HourlyWork[14] = %d, want 1200", loaded.HourlyWork[14]) + } +} + +func TestStateHourlyWorkBackwardCompat(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "state") + + // Write state without HOURLY_WORK line + legacy := `WORK_SECONDS=100 +MODE=work +LAST_CHECK=1700000000 +LAST_UPDATE_DATE=2026-04-17 +` + if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + loaded, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + for i, v := range loaded.HourlyWork { + if v != 0 { + t.Errorf("HourlyWork[%d] = %d, want 0 for legacy state", i, v) + } + } +} From b68b5f86c74182c246f49740aaa3f1abecc4841b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:51:43 +0900 Subject: [PATCH 03/14] feat(timer): accumulate hourly work seconds per hour bucket Add HourlyWork [24]int to DayEndSummary, reset the bucket array on daily rollover, and increment the current-hour bucket in tickWork() whenever the user is active. --- internal/timer/timer.go | 8 ++++ internal/timer/timer_test.go | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/internal/timer/timer.go b/internal/timer/timer.go index 8cf2fde..f06eeff 100644 --- a/internal/timer/timer.go +++ b/internal/timer/timer.go @@ -26,6 +26,7 @@ type DayEndSummary struct { Date string WorkSeconds int BreakSeconds int + HourlyWork [24]int } // TickResult is the outcome of a single timer tick. @@ -50,12 +51,14 @@ func Tick(cfg config.Config, s state.State, now time.Time, idleSec int) TickResu Date: s.LastUpdateDate, WorkSeconds: s.TodayWorkSeconds, BreakSeconds: s.TodayBreakSeconds, + HourlyWork: s.HourlyWork, } result.Actions = append(result.Actions, ActionSaveDailyHistory) } result.State.TodayWorkSeconds = 0 result.State.TodayBreakSeconds = 0 result.State.LastUpdateDate = today + result.State.HourlyWork = [24]int{} result.LogMsg = "New day detected! Resetting daily stats." } else if s.LastUpdateDate == "" { result.State.LastUpdateDate = today @@ -109,6 +112,11 @@ func tickWork(cfg config.Config, r TickResult, elapsed, idleSec int, unix int64) r.State.WorkSeconds += elapsed r.State.TodayWorkSeconds += elapsed + hour := time.Unix(unix, 0).Hour() + if hour >= 0 && hour < 24 { + r.State.HourlyWork[hour] += elapsed + } + workMin := r.State.WorkSeconds / 60 remainMin := (workDur - r.State.WorkSeconds) / 60 r.LogMsg = "Working... " + itoa(workMin) + "min elapsed (" + itoa(remainMin) + "min remaining)" diff --git a/internal/timer/timer_test.go b/internal/timer/timer_test.go index 23316c5..a5df13d 100644 --- a/internal/timer/timer_test.go +++ b/internal/timer/timer_test.go @@ -561,6 +561,84 @@ func TestDailyResetWhileStillOnBreak(t *testing.T) { } } +func TestTickWorkAccumulatesHourlyWork(t *testing.T) { + cfg := config.Default() + s := state.New() + s.Mode = "work" + // Simulate 10:30 AM local time + now := time.Date(2026, 4, 17, 10, 30, 0, 0, time.Local) + s.LastCheck = now.Add(-60 * time.Second).Unix() + s.LastUpdateDate = now.Format("2006-01-02") + + result := Tick(cfg, s, now, 0) // idle = 0 → user active + if result.State.HourlyWork[10] < 60 { + t.Errorf("HourlyWork[10] = %d, want >= 60 after 60s active work", result.State.HourlyWork[10]) + } + // Other hours should be untouched + for i, v := range result.State.HourlyWork { + if i == 10 { + continue + } + if v != 0 { + t.Errorf("HourlyWork[%d] = %d, want 0 (only hour 10 should accumulate)", i, v) + } + } +} + +func TestTickIdleDoesNotAccumulateHourly(t *testing.T) { + cfg := config.Default() + s := state.New() + s.Mode = "work" + now := time.Date(2026, 4, 17, 10, 30, 0, 0, time.Local) + s.LastCheck = now.Add(-60 * time.Second).Unix() + s.LastUpdateDate = now.Format("2006-01-02") + + // idle >= threshold → user inactive → no hourly accum + result := Tick(cfg, s, now, cfg.IdleThresholdSec+1) + for i, v := range result.State.HourlyWork { + if v != 0 { + t.Errorf("HourlyWork[%d] = %d, want 0 when user idle", i, v) + } + } +} + +func TestTickDailyResetClearsHourlyAndPreservesInSummary(t *testing.T) { + cfg := config.Default() + s := state.New() + s.Mode = "work" + s.LastUpdateDate = "2025-01-14" + s.TodayWorkSeconds = 1800 + s.TodayBreakSeconds = 600 + s.HourlyWork[10] = 600 + s.HourlyWork[14] = 1200 + + // Next day at 9:00 AM + now := time.Date(2025, 1, 15, 9, 0, 0, 0, time.Local) + s.LastCheck = now.Add(-60 * time.Second).Unix() + + result := Tick(cfg, s, now, 0) + + if result.DayEndSummary == nil { + t.Fatal("expected DayEndSummary on daily reset") + } + if result.DayEndSummary.HourlyWork[10] != 600 { + t.Errorf("DayEndSummary.HourlyWork[10] = %d, want 600", result.DayEndSummary.HourlyWork[10]) + } + if result.DayEndSummary.HourlyWork[14] != 1200 { + t.Errorf("DayEndSummary.HourlyWork[14] = %d, want 1200", result.DayEndSummary.HourlyWork[14]) + } + // State should be reset + for i, v := range result.State.HourlyWork { + // The tick may have accumulated into the new day's hour[9] bucket + if i == 9 { + continue + } + if v != 0 { + t.Errorf("result.State.HourlyWork[%d] = %d after reset, want 0", i, v) + } + } +} + func TestTickPausedOverMidnightOnlyRollsDailyTotals(t *testing.T) { cfg := config.Default() now := time.Date(2025, 1, 16, 0, 5, 0, 0, time.Local) From b1c66f9ab2388569539e0af27e4ad44890632446 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:52:44 +0900 Subject: [PATCH 04/14] feat(history): persist hourly work minutes when saving daily summary --- cmd/break-reminder/check.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/cmd/break-reminder/check.go b/cmd/break-reminder/check.go index 750e09e..2497279 100644 --- a/cmd/break-reminder/check.go +++ b/cmd/break-reminder/check.go @@ -95,10 +95,15 @@ func executeActions(actions []timer.Action, s state.State, daySummary *timer.Day } case timer.ActionSaveDailyHistory: if daySummary != nil { + var hourlyMin [24]int + for i, s := range daySummary.HourlyWork { + hourlyMin[i] = s / 60 + } _ = ai.AppendHistory(ai.DailySummary{ - Date: daySummary.Date, - WorkMin: daySummary.WorkSeconds / 60, - BreakMin: daySummary.BreakSeconds / 60, + Date: daySummary.Date, + WorkMin: daySummary.WorkSeconds / 60, + BreakMin: daySummary.BreakSeconds / 60, + HourlyWork: hourlyMin, }) } } From fd97be66c6c3bbcc65dadb5b6dc4612a7c44016a Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:53:53 +0900 Subject: [PATCH 05/14] feat(helpercore): add HistoryParser for DailySummary JSON --- .../Sources/HelperCore/HistoryParser.swift | 46 ++++++++++++++++++ .../HelperCoreTests/HistoryParserTests.swift | 47 +++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 helpers/Sources/HelperCore/HistoryParser.swift create mode 100644 helpers/Tests/HelperCoreTests/HistoryParserTests.swift diff --git a/helpers/Sources/HelperCore/HistoryParser.swift b/helpers/Sources/HelperCore/HistoryParser.swift new file mode 100644 index 0000000..0091610 --- /dev/null +++ b/helpers/Sources/HelperCore/HistoryParser.swift @@ -0,0 +1,46 @@ +import Foundation + +public struct HistoryEntry: Equatable { + public let date: String + public let workMin: Int + public let breakMin: Int + public let sessions: Int + public let activities: Int + public let hourlyWork: [Int] // Always 24 elements + + public init(date: String, workMin: Int, breakMin: Int, sessions: Int, activities: Int, hourlyWork: [Int]) { + self.date = date + self.workMin = workMin + self.breakMin = breakMin + self.sessions = sessions + self.activities = activities + self.hourlyWork = hourlyWork + } +} + +public func parseHistory(from json: String) -> [HistoryEntry] { + guard let data = json.data(using: .utf8), + let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [] + } + + return array.map { dict in + let hourly = dict["hourly_work"] as? [Int] ?? Array(repeating: 0, count: 24) + let normalized = hourly.count == 24 ? hourly : Array(repeating: 0, count: 24) + return HistoryEntry( + date: dict["date"] as? String ?? "", + workMin: dict["work_min"] as? Int ?? 0, + breakMin: dict["break_min"] as? Int ?? 0, + sessions: dict["sessions"] as? Int ?? 0, + activities: dict["activities"] as? Int ?? 0, + hourlyWork: normalized + ) + } +} + +public func loadHistoryFromDisk() -> [HistoryEntry] { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-history.json") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return [] } + return parseHistory(from: content) +} diff --git a/helpers/Tests/HelperCoreTests/HistoryParserTests.swift b/helpers/Tests/HelperCoreTests/HistoryParserTests.swift new file mode 100644 index 0000000..386f879 --- /dev/null +++ b/helpers/Tests/HelperCoreTests/HistoryParserTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import HelperCore + +final class HistoryParserTests: XCTestCase { + func testParseFullEntry() { + let json = """ + [{ + "date": "2026-04-17", + "work_min": 280, + "break_min": 60, + "sessions": 7, + "activities": 3, + "hourly_work": [0,0,0,0,0,0,0,0,0,45,55,50,10,40,50,35,20,0,0,0,0,0,0,0] + }] + """ + let entries = parseHistory(from: json) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].date, "2026-04-17") + XCTAssertEqual(entries[0].workMin, 280) + XCTAssertEqual(entries[0].hourlyWork[10], 55) + } + + func testParseLegacyEntryWithoutHourly() { + let json = """ + [{ + "date": "2026-04-16", + "work_min": 200, + "break_min": 40, + "sessions": 4, + "activities": 2 + }] + """ + let entries = parseHistory(from: json) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].hourlyWork, Array(repeating: 0, count: 24)) + } + + func testParseEmptyArray() { + let entries = parseHistory(from: "[]") + XCTAssertEqual(entries.count, 0) + } + + func testParseMalformedReturnsEmpty() { + let entries = parseHistory(from: "{not json") + XCTAssertEqual(entries.count, 0) + } +} From 741c3377610e32c91b4bd71e14e31f778dc3f3da Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:54:54 +0900 Subject: [PATCH 06/14] feat(dashboard): add DashboardTab enum and tab state --- .../DashboardApp/DashboardViewModel.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/helpers/Sources/DashboardApp/DashboardViewModel.swift b/helpers/Sources/DashboardApp/DashboardViewModel.swift index c837e04..8759287 100644 --- a/helpers/Sources/DashboardApp/DashboardViewModel.swift +++ b/helpers/Sources/DashboardApp/DashboardViewModel.swift @@ -2,12 +2,22 @@ import Foundation import SwiftUI import HelperCore +enum DashboardTab: String, CaseIterable, Identifiable { + case timer = "타이머" + case stats = "통계" + case insights = "인사이트" + + var id: String { rawValue } +} + @MainActor final class DashboardViewModel: ObservableObject { @Published var state: AppState = AppState() @Published var config: AppConfig = AppConfig() @Published var idleSeconds: Int = 0 @Published var launchdStatusText: String = "Unknown" + @Published var selectedTab: DashboardTab = .timer + @Published var history: [HistoryEntry] = [] private var timer: Timer? @@ -50,6 +60,7 @@ final class DashboardViewModel: ObservableObject { func start() { refresh() + loadHistory() timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in Task { @MainActor in self?.refresh() @@ -67,6 +78,11 @@ final class DashboardViewModel: ObservableObject { config = loadConfigFromDisk() idleSeconds = getIdleSecondsFromSystem() launchdStatusText = queryLaunchdStatus() + loadHistory() + } + + func loadHistory() { + history = loadHistoryFromDisk() } func resetTimer() { From 3b3d8da89d6e298e7c0b20de9af07d4a779a446f Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:55:04 +0900 Subject: [PATCH 07/14] feat(dashboard): add TabBarView component --- helpers/Sources/DashboardApp/TabBarView.swift | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 helpers/Sources/DashboardApp/TabBarView.swift diff --git a/helpers/Sources/DashboardApp/TabBarView.swift b/helpers/Sources/DashboardApp/TabBarView.swift new file mode 100644 index 0000000..1d38d3a --- /dev/null +++ b/helpers/Sources/DashboardApp/TabBarView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct TabBarView: View { + @Binding var selectedTab: DashboardTab + let accentColor: Color + + var body: some View { + HStack(spacing: 0) { + ForEach(DashboardTab.allCases) { tab in + Button(action: { selectedTab = tab }) { + VStack(spacing: 6) { + Text(tab.rawValue) + .font(.system(size: 13, weight: selectedTab == tab ? .semibold : .regular)) + .foregroundColor(selectedTab == tab ? accentColor : .gray) + Rectangle() + .fill(selectedTab == tab ? accentColor : Color.clear) + .frame(height: 2) + } + .frame(maxWidth: .infinity) + .padding(.top, 10) + } + .buttonStyle(.plain) + } + } + .background(Color(red: 0.1, green: 0.1, blue: 0.12)) + .overlay( + Rectangle() + .fill(Color(white: 0.2)) + .frame(height: 1), + alignment: .bottom + ) + } +} From 8dc3a87f914daa653f9d5e1a31849e9929071952 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:56:36 +0900 Subject: [PATCH 08/14] feat(dashboard): add StatsTabView scaffold with period selector --- .../Sources/DashboardApp/StatsTabView.swift | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 helpers/Sources/DashboardApp/StatsTabView.swift diff --git a/helpers/Sources/DashboardApp/StatsTabView.swift b/helpers/Sources/DashboardApp/StatsTabView.swift new file mode 100644 index 0000000..aeb46c0 --- /dev/null +++ b/helpers/Sources/DashboardApp/StatsTabView.swift @@ -0,0 +1,54 @@ +import SwiftUI +import Charts +import HelperCore + +enum StatsPeriod: String, CaseIterable, Identifiable { + case week = "주간" + case month = "월간" + case all = "전체" + + var id: String { rawValue } + + var days: Int { + switch self { + case .week: return 7 + case .month: return 30 + case .all: return 365 + } + } +} + +struct StatsTabView: View { + @ObservedObject var vm: DashboardViewModel + @State private var period: StatsPeriod = .week + + private var filteredHistory: [HistoryEntry] { + let cutoff = Calendar.current.date(byAdding: .day, value: -period.days, to: Date()) ?? Date() + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + let cutoffStr = formatter.string(from: cutoff) + return vm.history.filter { $0.date >= cutoffStr } + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + periodSelector + Text("Loading charts...") + .foregroundColor(.gray) + .font(.system(size: 12)) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + + private var periodSelector: some View { + Picker("Period", selection: $period) { + ForEach(StatsPeriod.allCases) { p in + Text(p.rawValue).tag(p) + } + } + .pickerStyle(.segmented) + } +} From 8f01e04dbbc6ec2804a8e4e051faecf0c78c648b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:56:51 +0900 Subject: [PATCH 09/14] feat(dashboard): add stacked bar chart for daily work/break --- .../Sources/DashboardApp/StatsTabView.swift | 53 +++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/helpers/Sources/DashboardApp/StatsTabView.swift b/helpers/Sources/DashboardApp/StatsTabView.swift index aeb46c0..aa90b7e 100644 --- a/helpers/Sources/DashboardApp/StatsTabView.swift +++ b/helpers/Sources/DashboardApp/StatsTabView.swift @@ -34,9 +34,7 @@ struct StatsTabView: View { ScrollView { VStack(alignment: .leading, spacing: 16) { periodSelector - Text("Loading charts...") - .foregroundColor(.gray) - .font(.system(size: 12)) + workBreakChart } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -51,4 +49,53 @@ struct StatsTabView: View { } .pickerStyle(.segmented) } + + private var workBreakChart: some View { + let workColor = Color(red: 0.3, green: 0.8, blue: 0.5) + let breakColor = Color(red: 0.4, green: 0.7, blue: 1.0) + + return VStack(alignment: .leading, spacing: 8) { + Text("작업 / 휴식 시간") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + + Chart { + ForEach(filteredHistory, id: \.date) { entry in + BarMark( + x: .value("날짜", shortDate(entry.date)), + y: .value("분", entry.workMin) + ) + .foregroundStyle(workColor) + + BarMark( + x: .value("날짜", shortDate(entry.date)), + y: .value("분", entry.breakMin) + ) + .foregroundStyle(breakColor) + } + } + .frame(height: 140) + + HStack(spacing: 16) { + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 2) + .fill(workColor) + .frame(width: 8, height: 8) + Text("작업").font(.system(size: 10)).foregroundColor(.gray) + } + HStack(spacing: 4) { + RoundedRectangle(cornerRadius: 2) + .fill(breakColor) + .frame(width: 8, height: 8) + Text("휴식").font(.system(size: 10)).foregroundColor(.gray) + } + } + } + } + + private func shortDate(_ iso: String) -> String { + let parts = iso.split(separator: "-") + guard parts.count == 3 else { return iso } + return "\(Int(parts[1]) ?? 0)/\(Int(parts[2]) ?? 0)" + } } From e87b880746fa996c49fa4a777417b72d5d4c0bfb Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:57:13 +0900 Subject: [PATCH 10/14] feat(dashboard): add hourly focus heatmap to stats tab --- .../Sources/DashboardApp/StatsTabView.swift | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/helpers/Sources/DashboardApp/StatsTabView.swift b/helpers/Sources/DashboardApp/StatsTabView.swift index aa90b7e..7062702 100644 --- a/helpers/Sources/DashboardApp/StatsTabView.swift +++ b/helpers/Sources/DashboardApp/StatsTabView.swift @@ -35,6 +35,8 @@ struct StatsTabView: View { VStack(alignment: .leading, spacing: 16) { periodSelector workBreakChart + Divider().background(Color(white: 0.2)) + heatmapView } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -98,4 +100,79 @@ struct StatsTabView: View { guard parts.count == 3 else { return iso } return "\(Int(parts[1]) ?? 0)/\(Int(parts[2]) ?? 0)" } + + private var heatmapView: some View { + VStack(alignment: .leading, spacing: 6) { + Text("시간대별 집중도") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + + heatmapGrid + heatmapLegend + } + } + + private var heatmapGrid: some View { + let hours = Array(9...18) + let entries = Array(filteredHistory.suffix(7)) + + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 2) { + Text("").frame(width: 28) + ForEach(hours, id: \.self) { hour in + Text("\(hour)") + .font(.system(size: 9)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + } + } + + ForEach(entries, id: \.date) { entry in + HStack(spacing: 2) { + Text(dayLabel(entry.date)) + .font(.system(size: 9)) + .foregroundColor(.gray) + .frame(width: 28, alignment: .leading) + + ForEach(hours, id: \.self) { hour in + RoundedRectangle(cornerRadius: 2) + .fill(heatColor(for: entry.hourlyWork[hour])) + .frame(height: 14) + } + } + } + } + } + + private var heatmapLegend: some View { + HStack(spacing: 4) { + Text("낮음").font(.system(size: 9)).foregroundColor(.gray) + ForEach([0, 15, 35, 55], id: \.self) { v in + RoundedRectangle(cornerRadius: 2) + .fill(heatColor(for: v)) + .frame(width: 12, height: 8) + } + Text("높음").font(.system(size: 9)).foregroundColor(.gray) + } + } + + private func heatColor(for minutes: Int) -> Color { + switch minutes { + case 0: return Color(red: 0.145, green: 0.145, blue: 0.157) + case 1..<20: return Color(red: 0.102, green: 0.290, blue: 0.180) + case 20..<45: return Color(red: 0.176, green: 0.478, blue: 0.290) + default: return Color(red: 0.302, green: 0.800, blue: 0.502) + } + } + + private func dayLabel(_ iso: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + guard let date = formatter.date(from: iso) else { return "" } + + let weekdayFormatter = DateFormatter() + weekdayFormatter.locale = Locale(identifier: "ko_KR") + weekdayFormatter.dateFormat = "E" + return weekdayFormatter.string(from: date) + } } From 76835b09bca0fbdb90cfc5fd618f7d7d82a44e9b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:57:31 +0900 Subject: [PATCH 11/14] feat(dashboard): add weekly summary cards to stats tab --- .../Sources/DashboardApp/StatsTabView.swift | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/helpers/Sources/DashboardApp/StatsTabView.swift b/helpers/Sources/DashboardApp/StatsTabView.swift index 7062702..fec09ca 100644 --- a/helpers/Sources/DashboardApp/StatsTabView.swift +++ b/helpers/Sources/DashboardApp/StatsTabView.swift @@ -37,6 +37,8 @@ struct StatsTabView: View { workBreakChart Divider().background(Color(white: 0.2)) heatmapView + Divider().background(Color(white: 0.2)) + summaryCards } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -175,4 +177,32 @@ struct StatsTabView: View { weekdayFormatter.dateFormat = "E" return weekdayFormatter.string(from: date) } + + private var summaryCards: some View { + let totalWork = filteredHistory.reduce(0) { $0 + $1.workMin } + let totalBreak = filteredHistory.reduce(0) { $0 + $1.breakMin } + let total = totalWork + totalBreak + let ratio = total > 0 ? (totalWork * 100) / total : 0 + + return HStack(spacing: 8) { + summaryCard(label: "\(period.rawValue) 작업", value: formatMinutes(totalWork), color: Color(red: 0.3, green: 0.8, blue: 0.5)) + summaryCard(label: "\(period.rawValue) 휴식", value: formatMinutes(totalBreak), color: Color(red: 0.4, green: 0.7, blue: 1.0)) + summaryCard(label: "작업 비율", value: "\(ratio)%", color: Color(red: 1.0, green: 0.8, blue: 0.4)) + } + } + + private func summaryCard(label: String, value: String, color: Color) -> some View { + VStack(spacing: 4) { + Text(value) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(color) + Text(label) + .font(.system(size: 9)) + .foregroundColor(.gray) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background(Color(white: 0.15)) + .cornerRadius(8) + } } From 49c52d72d972fd69ed3fa9c7ca2b1e615c8e7096 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:58:39 +0900 Subject: [PATCH 12/14] feat(dashboard): add placeholder InsightsTabView --- .../Sources/DashboardApp/InsightsTabView.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 helpers/Sources/DashboardApp/InsightsTabView.swift diff --git a/helpers/Sources/DashboardApp/InsightsTabView.swift b/helpers/Sources/DashboardApp/InsightsTabView.swift new file mode 100644 index 0000000..a72f5c1 --- /dev/null +++ b/helpers/Sources/DashboardApp/InsightsTabView.swift @@ -0,0 +1,17 @@ +import SwiftUI + +struct InsightsTabView: View { + var body: some View { + VStack(spacing: 12) { + Spacer() + Image(systemName: "sparkles") + .font(.system(size: 40)) + .foregroundColor(.gray) + Text("인사이트는 Phase 3에서 제공됩니다") + .font(.system(size: 12)) + .foregroundColor(.gray) + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} From ec5c49cff47e434b52fef9724a918586a531f34d Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:58:50 +0900 Subject: [PATCH 13/14] feat(dashboard): wire tabs into DashboardContentView --- .../DashboardApp/DashboardAppMain.swift | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 466e844..68f4676 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -52,11 +52,27 @@ struct DashboardContentView: View { controlActiveState == .key || controlActiveState == .active } + private var accentColor: Color { + if vm.isPaused { return .yellow } + return vm.isWork ? Color(red: 0.3, green: 0.8, blue: 0.5) : Color(red: 0.4, green: 0.7, blue: 1.0) + } + var body: some View { VStack(spacing: 0) { StatusHeaderView(vm: vm) Divider().background(Color(white: 0.2)) - TimerTabView(vm: vm) + TabBarView(selectedTab: $vm.selectedTab, accentColor: accentColor) + + Group { + switch vm.selectedTab { + case .timer: + TimerTabView(vm: vm) + case .stats: + StatsTabView(vm: vm) + case .insights: + InsightsTabView() + } + } } .opacity(isWindowActive ? 1.0 : 0.55) .animation(.easeInOut(duration: 0.2), value: isWindowActive) From ad2e4221ee3e94d75f065f78c26353b92596f412 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 11:20:56 +0900 Subject: [PATCH 14/14] fix(dashboard): improve StatsTabView visibility in dark mode + scroll indicator --- .../Sources/DashboardApp/StatsTabView.swift | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/helpers/Sources/DashboardApp/StatsTabView.swift b/helpers/Sources/DashboardApp/StatsTabView.swift index fec09ca..cb795f9 100644 --- a/helpers/Sources/DashboardApp/StatsTabView.swift +++ b/helpers/Sources/DashboardApp/StatsTabView.swift @@ -43,6 +43,7 @@ struct StatsTabView: View { .padding(.horizontal, 20) .padding(.vertical, 12) } + .scrollIndicators(.visible) } private var periodSelector: some View { @@ -52,6 +53,7 @@ struct StatsTabView: View { } } .pickerStyle(.segmented) + .labelsHidden() } private var workBreakChart: some View { @@ -61,7 +63,7 @@ struct StatsTabView: View { return VStack(alignment: .leading, spacing: 8) { Text("작업 / 휴식 시간") .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Color(white: 0.9)) + .foregroundColor(.primary) Chart { ForEach(filteredHistory, id: \.date) { entry in @@ -79,6 +81,22 @@ struct StatsTabView: View { } } .frame(height: 140) + .chartXAxis { + AxisMarks { _ in + AxisValueLabel() + .foregroundStyle(Color.secondary) + AxisGridLine() + .foregroundStyle(Color.gray.opacity(0.2)) + } + } + .chartYAxis { + AxisMarks { _ in + AxisValueLabel() + .foregroundStyle(Color.secondary) + AxisGridLine() + .foregroundStyle(Color.gray.opacity(0.2)) + } + } HStack(spacing: 16) { HStack(spacing: 4) { @@ -107,7 +125,7 @@ struct StatsTabView: View { VStack(alignment: .leading, spacing: 6) { Text("시간대별 집중도") .font(.system(size: 13, weight: .semibold)) - .foregroundColor(Color(white: 0.9)) + .foregroundColor(.primary) heatmapGrid heatmapLegend