From 22db25c8ed5b9f006682f24b996722afa0818039 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 18:02:06 +0900 Subject: [PATCH 01/20] docs: add DashboardApp v2 design spec Brainstormed and designed SwiftUI migration plan for the native macOS dashboard with AI Summary, data visualization, mascot system, animations, and theme support across 4 phases. --- .gitignore | 3 + .../specs/2026-04-17-dashboard-v2-design.md | 325 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-17-dashboard-v2-design.md diff --git a/.gitignore b/.gitignore index f2c9e62..3d474fc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ helpers/.build/ # Source Analyzer .analysis/sessions/ + +# Superpowers brainstorm sessions +.superpowers/ diff --git a/docs/superpowers/specs/2026-04-17-dashboard-v2-design.md b/docs/superpowers/specs/2026-04-17-dashboard-v2-design.md new file mode 100644 index 0000000..d20962e --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-dashboard-v2-design.md @@ -0,0 +1,325 @@ +# DashboardApp v2 — Design Spec + +> Date: 2026-04-17 +> Status: Approved +> Target: Swift DashboardApp (native macOS GUI) + +## Overview + +Break Reminder의 Swift DashboardApp을 SwiftUI로 전면 마이그레이션하고, AI Summary, 데이터 시각화, 비주얼 강화 기능을 추가하여 사용 재미를 향상시킨다. + +## Implementation Strategy + +단계적 구현 (Phase별 독립 릴리즈 가능): + +1. **Phase 1:** SwiftUI 마이그레이션 — 현재 AppKit 기능을 SwiftUI로 1:1 포팅 +2. **Phase 2:** 탭 레이아웃 + 데이터 시각화 (Swift Charts) +3. **Phase 3:** AI Summary 연동 (일일 리포트, 패턴 인사이트, 코칭) +4. **Phase 4:** 비주얼 강화 (마스코트, 애니메이션, 테마) + +## Architecture + +### 전체 레이아웃 + +360×600 고정 크기 플로팅 윈도우. 상단 고정 영역 + 하단 탭 전환 구조. + +``` +┌──────────────────────────────────┐ +│ 상단 고정 영역 │ +│ ┌─ 상태 dot + 라벨 ──────────┐ │ +│ │ ● WORKING 18/25 min │ │ +│ └────────────────────────────┘ │ +│ ┌──────────┐ │ +│ │ 07:00 │ 타이머 링 │ +│ │until break│ │ +│ └──────────┘ │ +│ 🐹 집중 잘 하고 있어요! │ +├──────────────────────────────────┤ +│ [타이머] [통계] [인사이트] 탭 바 │ +├──────────────────────────────────┤ +│ │ +│ 탭 콘텐츠 영역 │ +│ │ +└──────────────────────────────────┘ +``` + +### 상단 고정 영역 (모든 탭에서 보임) + +- 상태 표시: dot(녹색/파랑/노랑) + 라벨(WORKING/ON BREAK/PAUSED) + 모드 정보 +- 원형 프로그레스 링: 현재 세션 진행률, 남은 시간 표시 +- 마스코트 + 코칭 메시지: 이모지 + 말풍선 + +### 탭 구조 + +3개 탭: 타이머 / 통계 / 인사이트 + +## Phase 1: SwiftUI 마이그레이션 + +현재 AppKit `DashboardApp/main.swift`의 기능을 SwiftUI로 1:1 포팅. + +### 마이그레이션 대상 + +| AppKit (현재) | SwiftUI (신규) | +|---|---| +| `CircularProgressView` (NSView) | SwiftUI `Canvas` 또는 `Shape` | +| `StatBarView` (NSView) | `ProgressView` 또는 커스텀 `Shape` | +| `DashboardApp` (NSApplicationDelegate) | SwiftUI `App` + `@main` | +| 수동 프레임 레이아웃 | SwiftUI 선언적 레이아웃 | +| `NSTextField` 라벨들 | SwiftUI `Text` | +| `NSButton` | SwiftUI `Button` | +| `Timer.scheduledTimer` 1초 갱신 | SwiftUI `TimelineView` 또는 `.onReceive(Timer)` | + +### 데이터 흐름 + +현재 패턴 유지: 1초마다 `~/.break-reminder-state` 파일을 읽어서 UI 갱신. + +``` +~/.break-reminder-state (Go가 작성) → Swift가 1초마다 읽기 → @State 업데이트 → UI 갱신 +~/.config/break-reminder/config.yaml → Swift가 1초마다 읽기 → 설정 반영 +``` + +### 윈도우 속성 유지 + +- 크기: 360×600 (520에서 확장) +- 위치: 화면 우상단 +- 스타일: 투명 타이틀바, floating level, 배경 드래그 가능 +- 키보드: q(종료), r(리셋), b(강제 휴식) + +### 기존 HelperCore 의존성 + +`StateParser`, `ConfigParser`, `ProgressCalc`, `TimeFormatter` 등 HelperCore 라이브러리는 그대로 활용. + +## Phase 2: 탭 레이아웃 + 데이터 시각화 + +### 타이머 탭 (기본 탭) + +현재 DashboardApp의 하단 영역을 이동: +- 일일 통계: 작업 시간, 휴식 시간, 비율 바 +- 시스템 정보: launchd 상태, idle 시간 +- 액션 버튼: Reset, Force Break + +### 통계 탭 + +Swift Charts 프레임워크 사용 (macOS 13+). + +**구성 요소:** + +1. **기간 선택 세그먼트:** 주간 / 월간 / 전체 +2. **작업/휴식 스택 바 차트:** `BarMark` — 요일별 작업(녹색) + 휴식(파랑) 스택 +3. **시간대별 집중도 히트맵:** SwiftUI `Grid` + `RoundedRectangle` — GitHub 스타일. 행=요일, 열=시간(9~18시) +4. **요약 카드:** 3열 그리드 — 주간 작업 총량, 휴식 총량, 작업 비율 + +**데이터 소스:** + +- 기존 `~/.break-reminder-history.json` (`DailySummary`: date, work_min, break_min, sessions, activities) +- 히트맵용 시간대별 데이터: `DailySummary`에 `hourly_work` 필드 추가 (배열: 24개 분 값) + - Go `internal/ai/history.go`의 `DailySummary` 구조체 확장 + - Go 타이머 tick에서 시간대별 누적 로직 추가 + +**Swift Charts 최소 요구사항:** macOS 13 (Ventura) 이상. + +### 히스토리 포맷 확장 + +```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] +} +``` + +기존 필드와 하위 호환 유지. `hourly_work`가 없는 기존 데이터는 히트맵에서 빈칸 처리. + +## Phase 3: AI Summary 연동 + +### AI 호출 흐름 + +``` +Go 메인 프로세스 (launchd tick 또는 CLI 명령) + ↓ +ai.Client.Query(prompt) — claude/codex CLI 호출 + ↓ +결과 파싱 → ~/.break-reminder-insights.json 저장 + ↓ +Swift DashboardApp — 파일 읽기 → 인사이트 탭 렌더링 +``` + +### 인사이트 파일 포맷 + +```json +{ + "generated_at": "2026-04-17T17:30:00+09:00", + "daily_report": "오늘 4시간 20분 작업하고 50분 휴식했어요. 오전에 집중력이 높았고...", + "patterns": [ + { + "type": "warning", + "title": "오후 슬럼프 패턴 감지", + "description": "최근 5일 중 4일, 오후 2시~4시에 평균 작업 시간이 35% 줄었어요.", + "suggestion": "이 시간대에 짧은 산책을 추가하면 효과적일 수 있어요." + }, + { + "type": "positive", + "title": "휴식 습관 개선 중", + "description": "지난주 대비 휴식 건너뛰기가 40% 줄었어요.", + "suggestion": "꾸준히 유지하면 집중력 향상에 도움이 됩니다." + }, + { + "type": "info", + "title": "최적 작업 시간대", + "description": "오전 10시~12시가 가장 집중도가 높은 골든 타임이에요.", + "suggestion": "중요한 작업은 이 시간에 배치하면 좋겠어요." + } + ] +} +``` + +### 인사이트 탭 UI + +1. **오늘의 리포트:** `daily_report` 텍스트를 카드형으로 표시. 녹색 좌측 보더. +2. **패턴 인사이트:** `patterns` 배열을 카드 리스트로 표시. type별 dot 색상 (warning=노랑, positive=녹색, info=파랑). +3. **액션 버튼:** + - "AI 분석 새로고침" — Swift에서 `Process()`로 `break-reminder insights --refresh` CLI 실행 (기존 `findHelper()` 패턴 활용). 실행 중 스피너 표시, 완료 후 파일 재로드. + - "리포트 복사" — `NSPasteboard`로 일일 리포트 텍스트를 클립보드에 복사 + +### AI 프롬프트 설계 + +Go 쪽에서 히스토리 데이터를 수집하여 프롬프트로 구성: + +``` +다음은 사용자의 최근 7일 작업/휴식 기록입니다: +[히스토리 JSON] + +1. 오늘의 요약을 2-3문장으로 작성하세요. +2. 눈에 띄는 패턴 2-3가지를 분석하세요 (각각 type, title, description, suggestion). +3. JSON 형식으로 응답하세요. +``` + +### AI CLI 미설치 시 + +인사이트 탭에 안내 메시지 표시: +> "AI CLI(claude 또는 codex)를 설치하면 일일 리포트와 패턴 분석을 볼 수 있습니다." + +### 실시간 코칭 (상단 고정 영역) + +마스코트 말풍선으로 표시. 규칙 기반 (AI 호출 X): + +| 조건 | 메시지 예시 | +|---|---| +| 작업 시작 | "집중 모드! 화이팅 💪" | +| 남은 시간 5분 | "곧 휴식 시간이에요~ ☕" | +| 연속 2시간 작업 | "쉬어가는 게 어때요? 🙏" | +| 휴식 중 | "푹 쉬고 와요~" | +| 일일 목표 달성 | "오늘도 잘 해냈어요! 🏆" | +| 아침 시작 | "좋은 아침! 오늘도 파이팅 🌅" | + +AI 인사이트에서 핵심 메시지를 추출하여 코칭에 반영하는 것도 가능 (예: "오늘 오후 슬럼프 주의!"). + +### AI 분석 트리거 타이밍 + +- **자동 트리거:** 하루 1회, 근무 종료 시간(config의 working_hours 기준) 즈음에 실행 +- **수동 트리거:** 인사이트 탭의 "새로고침" 버튼 또는 CLI `break-reminder insights --refresh` +- AI CLI 호출은 비동기로 처리하여 메인 타이머 루프를 블로킹하지 않음 + +## Phase 4: 비주얼 강화 + +### 마스코트 시스템 + +시스템 이모지 기반 (v1). 상태와 조건에 따라 이모지 + 메시지 조합. + +**상태 매핑:** + +| 상태 | 이모지 | 말풍선 톤 | +|---|---|---| +| 작업 중 (정상) | 🐹 | 격려, 응원 | +| 휴식 중 | 😴 | 편안함, 휴식 독려 | +| 장시간 연속 작업 | 😰 | 걱정, 휴식 권유 | +| 일일 목표 달성 | 🎉 | 축하, 칭찬 | +| 일시정지 | 😶 | 대기 | +| 근무 시간 외 | 🌙 | 퇴근 축하 | + +**구현:** `MascotEngine` 구조체 — 현재 상태/조건을 받아 (emoji, message) 튜플 반환. + +향후 v2에서 커스텀 일러스트(Lottie 또는 에셋 기반)로 업그레이드 가능. + +### 애니메이션 효과 + +| 효과 | 적용 위치 | SwiftUI 구현 | +|---|---|---| +| 프로그레스 링 글로우 | 타이머 링 끝점 | `.shadow(color:radius:)` + `Animation.easeInOut.repeatForever` | +| 상태 전환 트랜지션 | work↔break | `withAnimation(.easeInOut(duration: 0.5))` 색상 모프 | +| 목표 달성 컨페티 | 전체 화면 오버레이 | `Canvas` + `TimelineView` 파티클 시스템 | +| 탭 전환 | 탭 콘텐츠 | `.transition(.slide)` + `.animation(.easeInOut)` | +| 마스코트 바운스 | 마스코트 이모지 | `.scaleEffect` + `Animation.spring` on state change | + +### 테마 시스템 + +3가지 모드: 다크 (기본) / 라이트 / 자동 (시스템 연동) + +**구현:** + +```swift +// ThemeManager — 컬러 토큰 추상화 +class ThemeManager: ObservableObject { + @Published var mode: ThemeMode = .auto // .dark, .light, .auto + + var background: Color { ... } + var surface: Color { ... } + var textPrimary: Color { ... } + var textSecondary: Color { ... } + var accent: Color { ... } // work color + var accentBreak: Color { ... } // break color + var warning: Color { ... } +} +``` + +- `@Environment(\.colorScheme)`으로 시스템 테마 감지 +- `config.yaml`에 `theme: auto | dark | light` 옵션 추가 +- `ThemeManager`를 `@EnvironmentObject`로 전체 뷰 트리에 주입 + +**컬러 팔레트:** + +| 토큰 | 다크 | 라이트 | +|---|---|---| +| background | #1a1a1e | #f5f5f7 | +| surface | #252528 | #ffffff | +| textPrimary | #e5e5e5 | #1a1a1e | +| textSecondary | #888888 | #666666 | +| accent (work) | #4dcc80 | #34a853 | +| accentBreak | #66b3ff | #4285f4 | +| warning | #ffcc66 | #f9ab00 | + +## 데이터 흐름 요약 + +``` +Go 메인 프로세스 (launchd, 60초 체크) + ├─ ~/.break-reminder-state → 타이머 상태 (1초 갱신 by Go) + ├─ ~/.config/break-reminder/config.yaml → 사용자 설정 + ├─ ~/.break-reminder-history.json → 일간 히스토리 (hourly_work 확장) + └─ ~/.break-reminder-insights.json → AI 인사이트 (하루 1회 갱신) + +Swift DashboardApp (1초 폴링) + ├─ state 파일 → 상단 고정 영역 + 타이머 탭 + ├─ config 파일 → 테마, 설정값 + ├─ history 파일 → 통계 탭 (차트, 히트맵) + └─ insights 파일 → 인사이트 탭 + 코칭 메시지 +``` + +## 호환성 / 제약 사항 + +- **최소 macOS 버전:** macOS 13 (Ventura) — Swift Charts 요구사항 +- **AI CLI 의존성:** 선택적. 미설치 시 인사이트 탭은 안내 메시지만 표시 +- **기존 TUI 대시보드:** 영향 없음. Go TUI(`internal/dashboard`)는 별도로 유지 +- **MenuBarApp:** 영향 없음. 독립 바이너리 +- **HelperCore:** `StateParser`, `ConfigParser`, `ProgressCalc` 등 기존 라이브러리 재사용 +- **히스토리 포맷:** `hourly_work` 필드 추가는 하위 호환. 기존 데이터는 히트맵에서 빈칸 처리 + +## 테스트 전략 + +- **HelperCore:** 기존 단위 테스트 유지 + 신규 파서/모델 테스트 추가 +- **MascotEngine:** 상태→이모지/메시지 매핑 단위 테스트 +- **ThemeManager:** 모드별 컬러 반환 테스트 +- **인사이트 파싱:** JSON 파싱 + 누락 필드 처리 테스트 +- **UI 수동 테스트:** 각 Phase 완료 시 DashboardApp 실행하여 시각적 확인 From f7e8bb1c64d94735cf4632810b30adb8c7403018 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 20:33:13 +0900 Subject: [PATCH 02/20] docs: add DashboardApp v2 implementation plans Add 4 phase plans for the DashboardApp v2 SwiftUI migration: - Phase 1: SwiftUI migration of existing AppKit dashboard - Phase 2: Tabs + data visualization with Swift Charts - Phase 3: AI Summary integration via claude/codex CLI - Phase 4: Visual enhancements (mascot, animations, theme system) Each phase is independently releasable with bite-sized TDD tasks. --- .../plans/2026-04-17-dashboard-v2-phase1.md | 820 +++++++++++ .../plans/2026-04-17-dashboard-v2-phase2.md | 1141 ++++++++++++++++ .../plans/2026-04-17-dashboard-v2-phase3.md | 1195 +++++++++++++++++ .../plans/2026-04-17-dashboard-v2-phase4.md | 1134 ++++++++++++++++ 4 files changed, 4290 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-17-dashboard-v2-phase1.md create mode 100644 docs/superpowers/plans/2026-04-17-dashboard-v2-phase2.md create mode 100644 docs/superpowers/plans/2026-04-17-dashboard-v2-phase3.md create mode 100644 docs/superpowers/plans/2026-04-17-dashboard-v2-phase4.md diff --git a/docs/superpowers/plans/2026-04-17-dashboard-v2-phase1.md b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase1.md new file mode 100644 index 0000000..7db4a8f --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase1.md @@ -0,0 +1,820 @@ +# DashboardApp v2 Phase 1: SwiftUI Migration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Migrate the existing AppKit DashboardApp to SwiftUI while preserving all current functionality (status display, circular progress ring, daily stats, system info, action buttons, keyboard shortcuts). + +**Architecture:** Replace `DashboardApp/main.swift` (single 429-line AppKit file) with a SwiftUI `App` entry point and composable views. HelperCore dependencies (`StateParser`, `ConfigParser`, `ProgressCalc`, `TimeFormatter`) remain unchanged. Data flow stays the same: 1-second polling of `~/.break-reminder-state` and `~/.config/break-reminder/config.yaml`. + +**Tech Stack:** SwiftUI, AppKit (NSWindow configuration only), HelperCore + +--- + +### Task 1: Update Package.swift for macOS 13 minimum + +**Files:** +- Modify: `helpers/Package.swift` + +- [ ] **Step 1: Update platform target** + +```swift +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "BreakReminderHelpers", + platforms: [.macOS(.v13)], + targets: [ + .target( + name: "HelperCore" + ), + .executableTarget( + name: "BreakScreenApp", + dependencies: ["HelperCore"] + ), + .executableTarget( + name: "DashboardApp", + dependencies: ["HelperCore"] + ), + .executableTarget( + name: "MenuBarApp", + dependencies: ["HelperCore"] + ), + .testTarget( + name: "HelperCoreTests", + dependencies: ["HelperCore"] + ), + ] +) +``` + +- [ ] **Step 2: Verify existing tests still pass** + +Run: `cd helpers && swift test` +Expected: All tests pass (platform bump doesn't break existing code) + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Package.swift +git commit -m "chore: bump macOS deployment target to 13 for Swift Charts" +``` + +--- + +### Task 2: Create DashboardViewModel with timer polling + +**Files:** +- Create: `helpers/Sources/DashboardApp/DashboardViewModel.swift` +- Test: `helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift` (logic is in HelperCore, but we test the polling integration) + +- [ ] **Step 1: Write the ViewModel test** + +Create `helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift`: + +```swift +import XCTest +@testable import HelperCore + +final class DashboardViewModelTests: XCTestCase { + func testWorkProgressCalculation() { + let state = AppState() + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let sp = workProgress(state: state, config: config, now: now) + XCTAssertGreaterThanOrEqual(sp.progress, 0.0) + XCTAssertLessThanOrEqual(sp.progress, 1.0) + } + + func testBreakProgressCalculation() { + var state = AppState() + state.mode = "break" + state.breakStart = Int64(Date().timeIntervalSince1970) - 60 + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let sp = breakProgress(state: state, config: config, now: now) + XCTAssertGreaterThan(sp.elapsedSec, 0) + } + + func testLiveDailyTotalsDefaultState() { + let state = AppState() + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let totals = liveDailyTotals(state: state, config: config, now: now) + XCTAssertGreaterThanOrEqual(totals.workSeconds, 0) + XCTAssertGreaterThanOrEqual(totals.breakSeconds, 0) + } +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `cd helpers && swift test --filter DashboardViewModelTests` +Expected: PASS (these test existing HelperCore functions) + +- [ ] **Step 3: Create the ViewModel** + +Create `helpers/Sources/DashboardApp/DashboardViewModel.swift`: + +```swift +import Foundation +import SwiftUI +import HelperCore + +@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" + + private var timer: Timer? + + var isWork: Bool { state.mode == "work" } + var isPaused: Bool { state.paused } + var now: Int64 { Int64(Date().timeIntervalSince1970) } + + var sessionProgress: SessionProgress { + if isWork { + return workProgress(state: state, config: config, now: now) + } else { + return breakProgress(state: state, config: config, now: now) + } + } + + var dailyTotals: LiveDailyTotals { + liveDailyTotals(state: state, config: config, now: now) + } + + var statusText: String { + if isPaused { + return "PAUSED (\(isWork ? "WORK" : "BREAK"))" + } + return isWork ? "WORKING" : "ON BREAK" + } + + var modeDetail: String { + let sp = sessionProgress + if isWork { + return "\(sp.elapsedSec / 60) / \(config.workDurationMin) min" + } else { + return "\(sp.elapsedSec / 60) / \(config.breakDurationMin) min" + } + } + + var sessionSubtitle: String { + if isPaused { return "paused" } + return isWork ? "until break" : "until work" + } + + func start() { + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + func refresh() { + state = loadStateFromDisk() + config = loadConfigFromDisk() + idleSeconds = getIdleSecondsFromSystem() + launchdStatusText = queryLaunchdStatus() + } + + func resetTimer() { + let totals = dailyTotals + var s = AppState() + s.lastCheck = now + s.todayWorkSeconds = totals.workSeconds + s.todayBreakSeconds = totals.breakSeconds + s.lastUpdateDate = totals.date + writeStateToDisk(s) + refresh() + } + + func forceBreak() { + let totals = dailyTotals + var s = AppState() + s.mode = "break" + s.lastCheck = now + s.breakStart = now + s.todayWorkSeconds = totals.workSeconds + s.todayBreakSeconds = totals.breakSeconds + s.lastUpdateDate = totals.date + writeStateToDisk(s) + refresh() + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardViewModel.swift helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift +git commit -m "feat(dashboard): add DashboardViewModel with timer polling" +``` + +--- + +### Task 3: Create system I/O helper functions + +These are the platform-specific functions that the ViewModel calls. They are extracted from the current `main.swift` and placed in a dedicated file. + +**Files:** +- Create: `helpers/Sources/DashboardApp/SystemIO.swift` + +- [ ] **Step 1: Create SystemIO.swift** + +Extract the existing I/O functions from `main.swift` into `helpers/Sources/DashboardApp/SystemIO.swift`: + +```swift +import Foundation +import HelperCore + +func loadStateFromDisk() -> AppState { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-state") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppState() } + return parseState(from: content) +} + +func loadConfigFromDisk() -> AppConfig { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".config/break-reminder/config.yaml") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppConfig() } + return parseConfig(from: content) +} + +func writeStateToDisk(_ s: AppState) { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-state") + try? serializeState(s).data(using: .utf8)?.write(to: path, options: .atomic) +} + +func queryLaunchdStatus() -> String { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["list", "com.devlikebear.break-reminder"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 ? "Running (launchd)" : "Not loaded" + } catch { + return "Unknown" + } +} + +func getIdleSecondsFromSystem() -> Int { + let task = Process() + task.launchPath = "/usr/sbin/ioreg" + task.arguments = ["-c", "IOHIDSystem", "-d", "4"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + do { + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + guard let output = String(data: data, encoding: .utf8) else { return 0 } + for line in output.components(separatedBy: "\n") { + if line.contains("HIDIdleTime") { + let parts = line.components(separatedBy: "=") + if let last = parts.last { + let cleaned = last.trimmingCharacters(in: .whitespacesAndNewlines) + if let ns = Int64(cleaned) { + return Int(ns / 1_000_000_000) + } + } + } + } + } catch {} + return 0 +} + +func findHelper(_ name: String) -> String? { + var candidates: [String] = [] + if let exe = Bundle.main.executablePath { + candidates.append( + URL(fileURLWithPath: exe) + .deletingLastPathComponent() + .appendingPathComponent(name) + .path + ) + } + let home = FileManager.default.homeDirectoryForCurrentUser.path + candidates.append("\(home)/.local/bin/\(name)") + for candidate in candidates { + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -5` +Expected: Build succeeds (may have warnings about unused in main.swift — that's fine, we replace it next) + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/SystemIO.swift +git commit -m "refactor(dashboard): extract system I/O functions to SystemIO.swift" +``` + +--- + +### Task 4: Create CircularProgressRing SwiftUI view + +**Files:** +- Create: `helpers/Sources/DashboardApp/CircularProgressRing.swift` + +- [ ] **Step 1: Create the SwiftUI progress ring** + +Create `helpers/Sources/DashboardApp/CircularProgressRing.swift`: + +```swift +import SwiftUI + +struct CircularProgressRing: View { + let progress: Double + let fillColor: Color + let lineWidth: CGFloat + + init(progress: Double, fillColor: Color, lineWidth: CGFloat = 10) { + self.progress = progress + self.fillColor = fillColor + self.lineWidth = lineWidth + } + + var body: some View { + ZStack { + Circle() + .stroke(Color(white: 0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: CGFloat(min(progress, 1.0))) + .stroke(fillColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/CircularProgressRing.swift +git commit -m "feat(dashboard): add CircularProgressRing SwiftUI view" +``` + +--- + +### Task 5: Create StatusHeaderView (fixed top section) + +**Files:** +- Create: `helpers/Sources/DashboardApp/StatusHeaderView.swift` + +- [ ] **Step 1: Create the status header view** + +Create `helpers/Sources/DashboardApp/StatusHeaderView.swift`: + +```swift +import SwiftUI +import HelperCore + +struct StatusHeaderView: View { + @ObservedObject var vm: DashboardViewModel + + private var statusColor: 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) + } + + private var ringSize: CGFloat { 140 } + + var body: some View { + VStack(spacing: 12) { + HStack { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + Text(vm.statusText) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(statusColor) + Spacer() + Text(vm.modeDetail) + .font(.system(size: 12)) + .foregroundColor(.gray) + } + + ZStack { + CircularProgressRing( + progress: vm.sessionProgress.progress, + fillColor: statusColor, + lineWidth: 10 + ) + .frame(width: ringSize, height: ringSize) + + VStack(spacing: 2) { + Text(vm.sessionProgress.remainingFormatted) + .font(.system(size: 32, weight: .ultraLight).monospacedDigit()) + .foregroundColor(Color(white: 0.9)) + Text(vm.sessionSubtitle) + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatusHeaderView.swift +git commit -m "feat(dashboard): add StatusHeaderView with progress ring" +``` + +--- + +### Task 6: Create TimerTabView (daily stats + system info + buttons) + +**Files:** +- Create: `helpers/Sources/DashboardApp/TimerTabView.swift` + +- [ ] **Step 1: Create the timer tab view** + +Create `helpers/Sources/DashboardApp/TimerTabView.swift`: + +```swift +import SwiftUI +import HelperCore + +struct TimerTabView: View { + @ObservedObject var vm: DashboardViewModel + + private let workColor = Color(red: 0.3, green: 0.8, blue: 0.5) + private let breakColor = Color(red: 0.4, green: 0.7, blue: 1.0) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + dailyStatsSection + Divider().background(Color(white: 0.2)) + systemInfoSection + Spacer() + actionButtons + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private var dailyStatsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Daily Statistics") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + + let totals = vm.dailyTotals + let workMin = totals.workSeconds / 60 + let breakMin = totals.breakSeconds / 60 + let totalMin = workMin + breakMin + + HStack { + Text("Work: \(formatMinutes(workMin))") + .font(.system(size: 13)) + .foregroundColor(Color(white: 0.9)) + Spacer() + Text("Break: \(formatMinutes(breakMin))") + .font(.system(size: 13)) + .foregroundColor(breakColor) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color(white: 0.2)) + .frame(height: 6) + if totalMin > 0 { + RoundedRectangle(cornerRadius: 3) + .fill(workColor) + .frame(width: geo.size.width * CGFloat(workMin) / CGFloat(totalMin), height: 6) + } + } + } + .frame(height: 6) + + if totalMin > 0 { + HStack { + Spacer() + Text("\(workMin * 100 / totalMin)%") + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } + } + } + + private var systemInfoSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("System: \(vm.launchdStatusText)") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text("Idle: \(vm.idleSeconds)s / Threshold: \(vm.config.idleThresholdSec)s") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + + private var actionButtons: some View { + HStack(spacing: 12) { + Button("Reset") { vm.resetTimer() } + .buttonStyle(DashboardButtonStyle()) + Button("Force Break") { vm.forceBreak() } + .buttonStyle(DashboardButtonStyle()) + } + } +} + +struct DashboardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color(white: 0.9)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(white: configuration.isPressed ? 0.28 : 0.22)) + ) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/TimerTabView.swift +git commit -m "feat(dashboard): add TimerTabView with daily stats and action buttons" +``` + +--- + +### Task 7: Create SwiftUI App entry point and replace main.swift + +**Files:** +- Create: `helpers/Sources/DashboardApp/DashboardAppMain.swift` +- Delete: `helpers/Sources/DashboardApp/main.swift` + +- [ ] **Step 1: Create the new SwiftUI entry point** + +Create `helpers/Sources/DashboardApp/DashboardAppMain.swift`: + +```swift +import SwiftUI +import HelperCore + +@main +struct DashboardAppEntry: App { + @StateObject private var vm = DashboardViewModel() + + var body: some Scene { + Window("Break Reminder", id: "dashboard") { + DashboardContentView(vm: vm) + .frame(width: 360, height: 600) + .background(Color(red: 0.1, green: 0.1, blue: 0.12)) + .onAppear { vm.start() } + .onDisappear { vm.stop() } + .onKeyPress("q") { NSApp.terminate(nil); return .handled } + .onKeyPress("r") { vm.resetTimer(); return .handled } + .onKeyPress("b") { vm.forceBreak(); return .handled } + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultPosition(.topTrailing) + } +} + +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + + var body: some View { + VStack(spacing: 0) { + StatusHeaderView(vm: vm) + Divider().background(Color(white: 0.2)) + TimerTabView(vm: vm) + } + } +} +``` + +- [ ] **Step 2: Delete old main.swift** + +Run: `rm helpers/Sources/DashboardApp/main.swift` + +- [ ] **Step 3: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -5` +Expected: Build succeeds. If there are issues with `@main` and the old `main.swift` conflicting, ensure the old file is deleted. + +- [ ] **Step 4: Verify the app launches** + +Run: `cd helpers && swift build -c release && .build/release/DashboardApp` +Expected: A 360×600 dark window appears in the top-right corner with: +- Status dot + "WORKING" label +- Circular progress ring with countdown +- Daily Statistics section +- System info +- Reset / Force Break buttons + +Press `q` to quit. + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardAppMain.swift helpers/Sources/DashboardApp/SystemIO.swift +git rm helpers/Sources/DashboardApp/main.swift +git commit -m "feat(dashboard): migrate DashboardApp to SwiftUI entry point" +``` + +--- + +### Task 8: Verify full build pipeline and window behavior + +**Files:** +- No new files — integration verification + +- [ ] **Step 1: Run all tests** + +Run: `cd helpers && swift test` +Expected: All existing HelperCoreTests pass + new DashboardViewModelTests pass + +- [ ] **Step 2: Run Go tests** + +Run: `go test ./...` +Expected: All Go tests pass (no Go changes in Phase 1, just sanity check) + +- [ ] **Step 3: Run full build via Makefile** + +Run: `make build` +Expected: Both Go binary and Swift helpers build successfully. `bin/break-dashboard` exists. + +- [ ] **Step 4: Test the built binary** + +Run: `bin/break-dashboard` +Expected: Dashboard window appears with correct state from `~/.break-reminder-state`. Verify: +- Status updates every second +- Reset button works (resets work timer) +- Force Break button works (switches to break mode) +- `q` key quits the app +- Window is floating (stays on top) + +- [ ] **Step 5: Test via Go CLI** + +Run: `bin/break-reminder dashboard --gui` +Expected: Same dashboard window launches via the Go CLI wrapper + +- [ ] **Step 6: Commit any fixes** + +If any fixes were needed during verification: +```bash +git add -A +git commit -m "fix(dashboard): address SwiftUI migration issues" +``` + +--- + +### Task 9: Window refinements (floating, position, drag) + +The SwiftUI `Window` API may not support all the AppKit window customizations we need (floating level, movable by background). We use `NSWindow` access to apply these. + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` + +- [ ] **Step 1: Add window configuration via NSApplication delegate** + +Update `DashboardAppMain.swift` — add a window configurator that runs after the window appears: + +```swift +import SwiftUI +import HelperCore +import AppKit + +@main +struct DashboardAppEntry: App { + @StateObject private var vm = DashboardViewModel() + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Window("Break Reminder", id: "dashboard") { + DashboardContentView(vm: vm) + .frame(width: 360, height: 600) + .background(Color(red: 0.1, green: 0.1, blue: 0.12)) + .onAppear { + vm.start() + configureWindow() + } + .onDisappear { vm.stop() } + .onKeyPress("q") { NSApp.terminate(nil); return .handled } + .onKeyPress("r") { vm.resetTimer(); return .handled } + .onKeyPress("b") { vm.forceBreak(); return .handled } + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultPosition(.topTrailing) + } + + private func configureWindow() { + DispatchQueue.main.async { + guard let window = NSApp.windows.first(where: { $0.title == "Break Reminder" }) else { return } + window.level = .floating + window.isMovableByWindowBackground = true + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + } + } +} + +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + + var body: some View { + VStack(spacing: 0) { + StatusHeaderView(vm: vm) + Divider().background(Color(white: 0.2)) + TimerTabView(vm: vm) + } + } +} +``` + +- [ ] **Step 2: Test window behavior** + +Run: `cd helpers && swift build -c release && .build/release/DashboardApp` +Expected: +- Window floats above other windows +- Window is draggable by clicking anywhere on the background +- Title bar is hidden/transparent +- App quits when window is closed + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardAppMain.swift +git commit -m "feat(dashboard): configure floating window with AppKit bridge" +``` + +--- + +## Phase 1 Completion Checklist + +After all tasks are done, verify: + +- [ ] `make build` succeeds +- [ ] `make test` succeeds (both Go and Swift) +- [ ] `bin/break-dashboard` launches and shows correct state +- [ ] `bin/break-reminder dashboard --gui` launches the same dashboard +- [ ] Status updates every second +- [ ] Reset and Force Break buttons work +- [ ] Keyboard shortcuts (q/r/b) work +- [ ] Window is floating, draggable, hidden titlebar +- [ ] Break mode shows blue colors, work mode shows green +- [ ] Paused state shows yellow diff --git a/docs/superpowers/plans/2026-04-17-dashboard-v2-phase2.md b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase2.md new file mode 100644 index 0000000..7951700 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase2.md @@ -0,0 +1,1141 @@ +# DashboardApp v2 Phase 2: Tabs + Data Visualization — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add tabbed navigation (Timer / Stats / Insights-placeholder) to the SwiftUI dashboard, extend history data with hourly work tracking, and build the Stats tab using Swift Charts (stacked bar chart, heatmap, summary cards). + +**Architecture:** Extend `DailySummary` Go struct with an `hourly_work` field (backward compatible — missing values default to zero-array). Add hourly accumulation in the Go timer tick. Parse the extended history JSON in Swift via a new `HistoryParser` in HelperCore. Swift Charts (`BarMark`) renders the bar chart; a custom `Grid` + `RoundedRectangle` renders the heatmap. + +**Tech Stack:** SwiftUI, Swift Charts, HelperCore, Go (timer/history extension) + +**Prerequisites:** Phase 1 complete (SwiftUI migration merged) + +--- + +### Task 1: Extend Go DailySummary with hourly_work field + +**Files:** +- Modify: `internal/ai/history.go:9-16` +- Test: `internal/ai/ai_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/ai/ai_test.go` (append at the end): + +```go +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) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/ai/ -run TestDailySummaryHourlyWork -v` +Expected: FAIL with "unknown field HourlyWork" + +- [ ] **Step 3: Add HourlyWork field to DailySummary** + +Edit `internal/ai/history.go` — change the struct: + +```go +// 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"` + HourlyWork [24]int `json:"hourly_work"` +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/ai/ -v` +Expected: All tests pass including the two new ones + +- [ ] **Step 5: Commit** + +```bash +git add internal/ai/history.go internal/ai/ai_test.go +git commit -m "feat(history): add hourly_work field to DailySummary" +``` + +--- + +### Task 2: Add hourly work tracking to Go State + +**Files:** +- Modify: `internal/state/state.go:22-34` +- Test: `internal/state/state_test.go` (create if needed) + +- [ ] **Step 1: Check if state test file exists** + +Run: `ls internal/state/` + +If `state_test.go` exists, note its contents for patterns. If not, we'll create one. + +- [ ] **Step 2: Add HourlyWork field to State struct** + +Edit `internal/state/state.go` — change the struct: + +```go +// State represents the application's current timer state. +type State struct { + WorkSeconds int `json:"work_seconds"` + Mode string `json:"mode"` // "work" or "break" + LastCheck int64 `json:"last_check"` + BreakStart int64 `json:"break_start"` + SnoozeUntil int64 `json:"snooze_until"` + Paused bool `json:"paused"` + PausedAt int64 `json:"paused_at"` + TodayWorkSeconds int `json:"today_work_seconds"` + 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"` +} +``` + +- [ ] **Step 3: Update state Load/Save for new field** + +Check `internal/state/state.go` for how Load/Save handle fields. If they use key=value parsing (per CLAUDE.md, state uses key=value format), add parse/serialize for HourlyWork as a comma-separated list. + +Find the parse/serialize functions (likely `parseLine` and a Save function). Add: + +In the parser switch statement, add: +```go +case "HOURLY_WORK": + parts := strings.Split(val, ",") + if len(parts) == 24 { + for i, p := range parts { + if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil { + s.HourlyWork[i] = n + } + } + } +``` + +In the Save function, add a line: +```go +hourlyParts := make([]string, 24) +for i, v := range s.HourlyWork { + hourlyParts[i] = strconv.Itoa(v) +} +fmt.Fprintf(w, "HOURLY_WORK=%s\n", strings.Join(hourlyParts, ",")) +``` + +- [ ] **Step 4: Write a round-trip test** + +Add to `internal/state/state_test.go` (create file if needed): + +```go +package state + +import ( + "path/filepath" + "testing" +) + +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]) + } +} +``` + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/state/ -v` +Expected: All tests pass including the round-trip test + +- [ ] **Step 6: Commit** + +```bash +git add internal/state/ +git commit -m "feat(state): add hourly_work tracking to State" +``` + +--- + +### Task 3: Accumulate hourly work in timer tick + +**Files:** +- Modify: `internal/timer/timer.go:104-159` (tickWork function) +- Test: `internal/timer/timer_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/timer/timer_test.go` (append at end): + +```go +func TestTickWorkAccumulatesHourlyWork(t *testing.T) { + cfg := config.Default() + s := state.New() + s.Mode = "work" + // Simulate 10:30 AM + 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]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/timer/ -run TestTickWorkAccumulatesHourlyWork -v` +Expected: FAIL (HourlyWork[10] is 0) + +- [ ] **Step 3: Implement hourly accumulation** + +Edit `internal/timer/timer.go`, in `tickWork`, within the `if idleSec < cfg.IdleThresholdSec` block — add after `r.State.TodayWorkSeconds += elapsed`: + +```go +hour := time.Unix(unix, 0).Hour() +if hour >= 0 && hour < 24 { + r.State.HourlyWork[hour] += elapsed +} +``` + +Also in the daily reset block (where `TodayWorkSeconds = 0` is set), zero out HourlyWork: + +```go +if today != s.LastUpdateDate && s.LastUpdateDate != "" { + if s.TodayWorkSeconds > 0 || s.TodayBreakSeconds > 0 { + result.DayEndSummary = &DayEndSummary{ + Date: s.LastUpdateDate, + WorkSeconds: s.TodayWorkSeconds, + BreakSeconds: s.TodayBreakSeconds, + HourlyWork: s.HourlyWork, // preserve for history save + } + result.Actions = append(result.Actions, ActionSaveDailyHistory) + } + result.State.TodayWorkSeconds = 0 + result.State.TodayBreakSeconds = 0 + result.State.LastUpdateDate = today + result.State.HourlyWork = [24]int{} // reset for new day + result.LogMsg = "New day detected! Resetting daily stats." +} +``` + +- [ ] **Step 4: Update DayEndSummary struct** + +Edit `internal/timer/timer.go:25-29`: + +```go +// DayEndSummary holds the previous day's stats when a daily reset occurs. +type DayEndSummary struct { + Date string + WorkSeconds int + BreakSeconds int + HourlyWork [24]int +} +``` + +- [ ] **Step 5: Run tests** + +Run: `go test ./internal/timer/ -v` +Expected: All tests pass including the new one + +- [ ] **Step 6: Commit** + +```bash +git add internal/timer/ +git commit -m "feat(timer): accumulate hourly work seconds per hour bucket" +``` + +--- + +### Task 4: Persist HourlyWork to history on day-end + +**Files:** +- Find and modify the code that handles `ActionSaveDailyHistory` (search for it) + +- [ ] **Step 1: Locate the history save handler** + +Run: Grep for `ActionSaveDailyHistory` to find where the action is handled: + +Look in `cmd/break-reminder/` and any service/runner files. Example: `cmd/break-reminder/service.go` or similar. + +- [ ] **Step 2: Update the DailySummary construction** + +Wherever `DailySummary{...}` is constructed from `DayEndSummary`, add the `HourlyWork` field: + +```go +summary := ai.DailySummary{ + Date: dayEnd.Date, + WorkMin: dayEnd.WorkSeconds / 60, + BreakMin: dayEnd.BreakSeconds / 60, + // ... existing fields ... + HourlyWork: convertSecondsToMinutes(dayEnd.HourlyWork), +} +``` + +Add a helper in the same file: +```go +func convertSecondsToMinutes(seconds [24]int) [24]int { + var minutes [24]int + for i, s := range seconds { + minutes[i] = s / 60 + } + return minutes +} +``` + +- [ ] **Step 3: Run all Go tests** + +Run: `go test ./...` +Expected: All tests pass + +- [ ] **Step 4: Commit** + +```bash +git add -A +git commit -m "feat(history): persist hourly work when saving daily summary" +``` + +--- + +### Task 5: Create HistoryParser in HelperCore (Swift) + +**Files:** +- Create: `helpers/Sources/HelperCore/HistoryParser.swift` +- Create: `helpers/Tests/HelperCoreTests/HistoryParserTests.swift` + +- [ ] **Step 1: Write the failing test** + +Create `helpers/Tests/HelperCoreTests/HistoryParserTests.swift`: + +```swift +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd helpers && swift test --filter HistoryParserTests 2>&1 | tail -20` +Expected: FAIL with "cannot find parseHistory" + +- [ ] **Step 3: Create HistoryParser.swift** + +Create `helpers/Sources/HelperCore/HistoryParser.swift`: + +```swift +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] // 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) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd helpers && swift test --filter HistoryParserTests` +Expected: All 4 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/HelperCore/HistoryParser.swift helpers/Tests/HelperCoreTests/HistoryParserTests.swift +git commit -m "feat(helpercore): add HistoryParser for DailySummary JSON" +``` + +--- + +### Task 6: Add DashboardTab enum and tab state + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardViewModel.swift` + +- [ ] **Step 1: Add tab enum and state to ViewModel** + +Edit `helpers/Sources/DashboardApp/DashboardViewModel.swift` — add at the top: + +```swift +enum DashboardTab: String, CaseIterable, Identifiable { + case timer = "타이머" + case stats = "통계" + case insights = "인사이트" + + var id: String { rawValue } +} +``` + +Inside the `DashboardViewModel` class, add: + +```swift +@Published var selectedTab: DashboardTab = .timer +@Published var history: [HistoryEntry] = [] + +func loadHistory() { + history = loadHistoryFromDisk() +} +``` + +In `start()`, call `loadHistory()` once, and in `refresh()`, call `loadHistory()` so it stays fresh (history updates on day-end). + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardViewModel.swift +git commit -m "feat(dashboard): add DashboardTab enum and tab state" +``` + +--- + +### Task 7: Create TabBarView component + +**Files:** +- Create: `helpers/Sources/DashboardApp/TabBarView.swift` + +- [ ] **Step 1: Create the tab bar view** + +Create `helpers/Sources/DashboardApp/TabBarView.swift`: + +```swift +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 + ) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/TabBarView.swift +git commit -m "feat(dashboard): add TabBarView component" +``` + +--- + +### Task 8: Create StatsTabView with period selector + +**Files:** +- Create: `helpers/Sources/DashboardApp/StatsTabView.swift` + +- [ ] **Step 1: Create the scaffold with period selector** + +Create `helpers/Sources/DashboardApp/StatsTabView.swift`: + +```swift +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 + // Chart and heatmap added in next tasks + 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) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatsTabView.swift +git commit -m "feat(dashboard): add StatsTabView scaffold with period selector" +``` + +--- + +### Task 9: Add stacked bar chart (Work vs Break per day) + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatsTabView.swift` + +- [ ] **Step 1: Add the bar chart section** + +Edit `StatsTabView.swift` — replace the `"Loading charts..."` placeholder with a new method. Add this method inside `StatsTabView`: + +```swift +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) + } + } + .chartForegroundStyleScale([ + "작업": workColor, + "휴식": 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 { + // Convert "2026-04-17" to "4/17" + let parts = iso.split(separator: "-") + guard parts.count == 3 else { return iso } + return "\(Int(parts[1]) ?? 0)/\(Int(parts[2]) ?? 0)" +} +``` + +Now update the body's VStack to include it: + +```swift +var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + periodSelector + workBreakChart + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatsTabView.swift +git commit -m "feat(dashboard): add stacked bar chart for daily work/break" +``` + +--- + +### Task 10: Add hourly heatmap + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatsTabView.swift` + +- [ ] **Step 1: Add the heatmap component** + +Inside `StatsTabView`, add these methods: + +```swift +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) { + // Header row with hour labels + HStack(spacing: 2) { + Text("").frame(width: 28) + ForEach(hours, id: \.self) { hour in + Text("\(hour)") + .font(.system(size: 9)) + .foregroundColor(.gray) + .frame(maxWidth: .infinity) + } + } + + // One row per day + 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) // #252528 + case 1..<20: return Color(red: 0.102, green: 0.290, blue: 0.180) // #1a4a2e + case 20..<45: return Color(red: 0.176, green: 0.478, blue: 0.290) // #2d7a4a + default: return Color(red: 0.302, green: 0.800, blue: 0.502) // #4dcc80 + } +} + +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) +} +``` + +Add `heatmapView` to the body VStack: + +```swift +var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + periodSelector + workBreakChart + Divider().background(Color(white: 0.2)) + heatmapView + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatsTabView.swift +git commit -m "feat(dashboard): add hourly focus heatmap to stats tab" +``` + +--- + +### Task 11: Add summary cards (totals) + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatsTabView.swift` + +- [ ] **Step 1: Add summary cards component** + +Inside `StatsTabView`, add: + +```swift +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) +} +``` + +Add to body VStack: + +```swift +var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + periodSelector + workBreakChart + Divider().background(Color(white: 0.2)) + heatmapView + Divider().background(Color(white: 0.2)) + summaryCards + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatsTabView.swift +git commit -m "feat(dashboard): add weekly summary cards to stats tab" +``` + +--- + +### Task 12: Create placeholder InsightsTabView + +**Files:** +- Create: `helpers/Sources/DashboardApp/InsightsTabView.swift` + +- [ ] **Step 1: Create placeholder view** + +Create `helpers/Sources/DashboardApp/InsightsTabView.swift`: + +```swift +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) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/InsightsTabView.swift +git commit -m "feat(dashboard): add placeholder InsightsTabView" +``` + +--- + +### Task 13: Wire tabs into DashboardContentView + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` + +- [ ] **Step 1: Update DashboardContentView** + +Edit `helpers/Sources/DashboardApp/DashboardAppMain.swift` — replace the `DashboardContentView` struct: + +```swift +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + + 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)) + TabBarView(selectedTab: $vm.selectedTab, accentColor: accentColor) + + Group { + switch vm.selectedTab { + case .timer: + TimerTabView(vm: vm) + case .stats: + StatsTabView(vm: vm) + case .insights: + InsightsTabView() + } + } + } + } +} +``` + +- [ ] **Step 2: Build and test** + +Run: `cd helpers && swift build -c release && .build/release/DashboardApp` +Expected: Dashboard appears with: +- Status header at top (always visible) +- Tab bar (타이머 / 통계 / 인사이트) +- Clicking tabs switches the content area +- Stats tab shows bar chart + heatmap + summary cards (may be empty if no history exists) + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardAppMain.swift +git commit -m "feat(dashboard): wire tabs into DashboardContentView" +``` + +--- + +### Task 14: Full pipeline verification + +- [ ] **Step 1: Run all tests** + +Run: `go test ./... && cd helpers && swift test` +Expected: All tests pass (Go and Swift) + +- [ ] **Step 2: Build and install** + +Run: `make build` +Expected: Both binaries build + +- [ ] **Step 3: Manual UI test** + +Run: `bin/break-dashboard` + +Verify: +- All three tabs switch correctly +- Bar chart renders if `~/.break-reminder-history.json` has data +- Heatmap renders with dummy or real data +- Summary cards show accurate totals +- Period selector (주간/월간/전체) changes filtered data +- Status header stays visible when switching tabs +- Work/Break/Paused color states persist across tabs + +- [ ] **Step 4: Seed test data (if no history exists)** + +If `~/.break-reminder-history.json` doesn't exist, create test data: + +```bash +cat > ~/.break-reminder-history.json << 'EOF' +[ + {"date":"2026-04-11","work_min":180,"break_min":30,"sessions":4,"activities":2,"hourly_work":[0,0,0,0,0,0,0,0,0,45,55,50,0,35,35,40,20,0,0,0,0,0,0,0]}, + {"date":"2026-04-12","work_min":220,"break_min":40,"sessions":5,"activities":3,"hourly_work":[0,0,0,0,0,0,0,0,0,55,55,50,0,45,55,40,30,0,0,0,0,0,0,0]}, + {"date":"2026-04-13","work_min":150,"break_min":25,"sessions":3,"activities":1,"hourly_work":[0,0,0,0,0,0,0,0,0,30,40,35,0,30,40,25,10,0,0,0,0,0,0,0]}, + {"date":"2026-04-14","work_min":260,"break_min":50,"sessions":6,"activities":3,"hourly_work":[0,0,0,0,0,0,0,0,0,55,55,50,0,50,55,45,30,0,0,0,0,0,0,0]}, + {"date":"2026-04-15","work_min":120,"break_min":20,"sessions":3,"activities":1,"hourly_work":[0,0,0,0,0,0,0,0,0,20,30,25,0,20,30,20,10,0,0,0,0,0,0,0]}, + {"date":"2026-04-16","work_min":60,"break_min":10,"sessions":1,"activities":0,"hourly_work":[0,0,0,0,0,0,0,0,0,0,10,15,0,10,15,10,0,0,0,0,0,0,0,0]}, + {"date":"2026-04-17","work_min":30,"break_min":5,"sessions":1,"activities":0,"hourly_work":[0,0,0,0,0,0,0,0,0,0,0,0,0,15,15,0,0,0,0,0,0,0,0,0]} +] +EOF +``` + +Relaunch and verify charts render. + +- [ ] **Step 5: Final commit for any fixes** + +```bash +git add -A +git commit -m "fix(dashboard): address Phase 2 integration issues" || true +``` + +--- + +## Phase 2 Completion Checklist + +- [ ] `go test ./...` passes +- [ ] `cd helpers && swift test` passes +- [ ] `make build` succeeds +- [ ] 3 tabs (타이머/통계/인사이트) all render +- [ ] Stats tab bar chart shows daily work/break +- [ ] Stats tab heatmap shows hourly intensity (9-18) +- [ ] Stats tab summary cards compute weekly totals +- [ ] Period selector filters history correctly +- [ ] Status header stays fixed above tab bar +- [ ] History JSON with `hourly_work` written on day-end +- [ ] Legacy history entries (no `hourly_work`) don't crash — render as blank diff --git a/docs/superpowers/plans/2026-04-17-dashboard-v2-phase3.md b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase3.md new file mode 100644 index 0000000..9263649 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase3.md @@ -0,0 +1,1195 @@ +# DashboardApp v2 Phase 3: AI Summary — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Generate AI-powered daily reports and pattern insights using the existing `ai.Client` (claude/codex CLI), save to `~/.break-reminder-insights.json`, and render them in the Insights tab with refresh/copy actions. + +**Architecture:** Add an `insights` package in Go that builds prompts from recent history and parses AI responses into structured patterns. Add a CLI subcommand `break-reminder insights --refresh` that forces regeneration. Auto-trigger generation once per day during the tick loop when near work-end time. Swift reads the insights JSON and renders it; a refresh button shells out to `break-reminder insights --refresh`. + +**Tech Stack:** Go (ai/insights packages, cobra CLI), SwiftUI, HelperCore + +**Prerequisites:** Phase 2 complete (tabs + history data) + +--- + +### Task 1: Create Go insights package with data structures + +**Files:** +- Create: `internal/insights/insights.go` +- Create: `internal/insights/insights_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/insights/insights_test.go`: + +```go +package insights + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissingFile(t *testing.T) { + origPath := pathOverride + defer func() { pathOverride = origPath }() + pathOverride = filepath.Join(t.TempDir(), "nope.json") + + result, err := Load() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Errorf("expected nil for missing file, got %v", result) + } +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + origPath := pathOverride + defer func() { pathOverride = origPath }() + pathOverride = filepath.Join(t.TempDir(), "insights.json") + + report := &Report{ + GeneratedAt: "2026-04-17T17:30:00+09:00", + DailyReport: "오늘 4시간 20분 작업", + Patterns: []Pattern{ + {Type: "warning", Title: "오후 슬럼프", Description: "D", Suggestion: "S"}, + }, + } + + if err := Save(report); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if loaded == nil { + t.Fatal("expected non-nil report") + } + if loaded.DailyReport != "오늘 4시간 20분 작업" { + t.Errorf("DailyReport mismatch: %q", loaded.DailyReport) + } + if len(loaded.Patterns) != 1 { + t.Errorf("Patterns count = %d, want 1", len(loaded.Patterns)) + } +} + +func TestSaveCreatesMissingDirectory(t *testing.T) { + origPath := pathOverride + defer func() { pathOverride = origPath }() + dir := filepath.Join(t.TempDir(), "nested", "subdir") + pathOverride = filepath.Join(dir, "insights.json") + + report := &Report{GeneratedAt: "2026-04-17T00:00:00Z"} + if err := Save(report); err != nil { + t.Fatalf("Save: %v", err) + } + if _, err := os.Stat(pathOverride); err != nil { + t.Errorf("file not created: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/insights/ -v` +Expected: FAIL — package doesn't exist + +- [ ] **Step 3: Create insights.go** + +Create `internal/insights/insights.go`: + +```go +package insights + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// Pattern represents a single AI-discovered pattern insight. +type Pattern struct { + Type string `json:"type"` // "warning", "positive", "info" + Title string `json:"title"` + Description string `json:"description"` + Suggestion string `json:"suggestion"` +} + +// Report is the top-level insights document. +type Report struct { + GeneratedAt string `json:"generated_at"` + DailyReport string `json:"daily_report"` + Patterns []Pattern `json:"patterns"` +} + +// pathOverride lets tests redirect the insights file. +var pathOverride string + +// Path returns ~/.break-reminder-insights.json +func Path() string { + if pathOverride != "" { + return pathOverride + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".break-reminder-insights.json") +} + +// Load reads the insights file. Returns (nil, nil) if the file doesn't exist. +func Load() (*Report, error) { + data, err := os.ReadFile(Path()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var r Report + if err := json.Unmarshal(data, &r); err != nil { + return nil, err + } + return &r, nil +} + +// Save writes the report atomically, creating parent directories if needed. +func Save(r *Report) error { + if err := os.MkdirAll(filepath.Dir(Path()), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(r, "", " ") + if err != nil { + return err + } + return os.WriteFile(Path(), data, 0o644) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/insights/ -v` +Expected: All 3 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/insights/ +git commit -m "feat(insights): add Report/Pattern types with Save/Load" +``` + +--- + +### Task 2: Add prompt builder for AI analysis + +**Files:** +- Create: `internal/insights/prompt.go` +- Create: `internal/insights/prompt_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/insights/prompt_test.go`: + +```go +package insights + +import ( + "strings" + "testing" + + "github.com/devlikebear/break-reminder/internal/ai" +) + +func TestBuildPromptIncludesHistory(t *testing.T) { + history := []ai.DailySummary{ + {Date: "2026-04-17", WorkMin: 280, BreakMin: 60, Sessions: 7, Activities: 3}, + {Date: "2026-04-16", WorkMin: 200, BreakMin: 40, Sessions: 4, Activities: 2}, + } + + prompt := BuildPrompt(history) + if !strings.Contains(prompt, "2026-04-17") { + t.Error("prompt missing today's date") + } + if !strings.Contains(prompt, "daily_report") { + t.Error("prompt should request daily_report field") + } + if !strings.Contains(prompt, "patterns") { + t.Error("prompt should request patterns field") + } + if !strings.Contains(prompt, "JSON") { + t.Error("prompt should request JSON format") + } +} + +func TestBuildPromptEmptyHistory(t *testing.T) { + prompt := BuildPrompt(nil) + if prompt == "" { + t.Error("empty prompt for no history") + } + if !strings.Contains(prompt, "[]") { + t.Error("should embed empty history array") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/insights/ -run TestBuildPrompt -v` +Expected: FAIL — BuildPrompt not defined + +- [ ] **Step 3: Create prompt.go** + +Create `internal/insights/prompt.go`: + +```go +package insights + +import ( + "encoding/json" + "fmt" + + "github.com/devlikebear/break-reminder/internal/ai" +) + +const promptTemplate = `다음은 사용자의 최근 작업/휴식 기록입니다: + +%s + +다음 두 가지를 분석하여 JSON으로만 응답하세요 (마크다운 코드 블록 없이): + +1. daily_report: 오늘의 작업/휴식 요약을 한국어 2-3문장으로 작성 +2. patterns: 눈에 띄는 패턴 2-3가지를 배열로 작성 (각 항목은 type, title, description, suggestion 필드 포함) + +type 값은 다음 중 하나: +- "warning": 주의가 필요한 패턴 (예: 슬럼프, 휴식 부족) +- "positive": 긍정적 개선 추세 +- "info": 중립적 관찰 (예: 최적 작업 시간대) + +응답 형식: +{ + "daily_report": "...", + "patterns": [ + {"type": "warning", "title": "...", "description": "...", "suggestion": "..."}, + {"type": "positive", "title": "...", "description": "...", "suggestion": "..."} + ] +}` + +// BuildPrompt constructs the AI prompt from the given history entries. +// Caller should trim history to desired range (e.g., last 7 days). +func BuildPrompt(history []ai.DailySummary) string { + if history == nil { + history = []ai.DailySummary{} + } + data, err := json.MarshalIndent(history, "", " ") + if err != nil { + data = []byte("[]") + } + return fmt.Sprintf(promptTemplate, string(data)) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/insights/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/insights/prompt.go internal/insights/prompt_test.go +git commit -m "feat(insights): add prompt builder for AI analysis" +``` + +--- + +### Task 3: Add response parser + +**Files:** +- Create: `internal/insights/parser.go` +- Create: `internal/insights/parser_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/insights/parser_test.go`: + +```go +package insights + +import ( + "strings" + "testing" +) + +func TestParseResponseValid(t *testing.T) { + response := `{ + "daily_report": "오늘 4시간 작업했어요.", + "patterns": [ + {"type": "warning", "title": "슬럼프", "description": "D1", "suggestion": "S1"}, + {"type": "info", "title": "골든타임", "description": "D2", "suggestion": "S2"} + ] + }` + + report, err := ParseResponse(response) + if err != nil { + t.Fatalf("ParseResponse: %v", err) + } + if report.DailyReport != "오늘 4시간 작업했어요." { + t.Errorf("DailyReport mismatch: %q", report.DailyReport) + } + if len(report.Patterns) != 2 { + t.Errorf("Patterns count = %d, want 2", len(report.Patterns)) + } + if report.Patterns[0].Type != "warning" { + t.Errorf("Patterns[0].Type = %q, want warning", report.Patterns[0].Type) + } +} + +func TestParseResponseStripsCodeFences(t *testing.T) { + response := "```json\n" + `{"daily_report":"ok","patterns":[]}` + "\n```" + report, err := ParseResponse(response) + if err != nil { + t.Fatalf("ParseResponse: %v", err) + } + if report.DailyReport != "ok" { + t.Errorf("DailyReport mismatch: %q", report.DailyReport) + } +} + +func TestParseResponseInvalidJSON(t *testing.T) { + _, err := ParseResponse("not json") + if err == nil { + t.Error("expected error for invalid JSON") + } + if !strings.Contains(err.Error(), "parse") { + t.Errorf("error should mention parse: %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/insights/ -run TestParseResponse -v` +Expected: FAIL — ParseResponse not defined + +- [ ] **Step 3: Create parser.go** + +Create `internal/insights/parser.go`: + +```go +package insights + +import ( + "encoding/json" + "fmt" + "strings" +) + +// ParseResponse parses the AI response into a Report (without GeneratedAt). +// Strips markdown code fences if present. +func ParseResponse(raw string) (*Report, error) { + cleaned := strings.TrimSpace(raw) + cleaned = strings.TrimPrefix(cleaned, "```json") + cleaned = strings.TrimPrefix(cleaned, "```") + cleaned = strings.TrimSuffix(cleaned, "```") + cleaned = strings.TrimSpace(cleaned) + + var r Report + if err := json.Unmarshal([]byte(cleaned), &r); err != nil { + return nil, fmt.Errorf("parse AI response: %w", err) + } + return &r, nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/insights/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/insights/parser.go internal/insights/parser_test.go +git commit -m "feat(insights): add AI response parser" +``` + +--- + +### Task 4: Add Generate orchestration function + +**Files:** +- Create: `internal/insights/generate.go` +- Create: `internal/insights/generate_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/insights/generate_test.go`: + +```go +package insights + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/devlikebear/break-reminder/internal/ai" +) + +type fakeAIClient struct { + response string + err error +} + +func (f *fakeAIClient) Query(ctx context.Context, prompt string) (string, error) { + return f.response, f.err +} + +func TestGenerateSuccess(t *testing.T) { + client := &fakeAIClient{ + response: `{"daily_report":"test report","patterns":[{"type":"info","title":"T","description":"D","suggestion":"S"}]}`, + } + history := []ai.DailySummary{{Date: "2026-04-17", WorkMin: 60}} + + report, err := Generate(context.Background(), client, history, time.Now()) + if err != nil { + t.Fatalf("Generate: %v", err) + } + if report.DailyReport != "test report" { + t.Errorf("DailyReport mismatch: %q", report.DailyReport) + } + if report.GeneratedAt == "" { + t.Error("GeneratedAt should be set") + } +} + +func TestGenerateAIError(t *testing.T) { + client := &fakeAIClient{err: errors.New("CLI not found")} + _, err := Generate(context.Background(), client, nil, time.Now()) + if err == nil { + t.Error("expected error from failed AI call") + } +} + +func TestGenerateInvalidResponse(t *testing.T) { + client := &fakeAIClient{response: "not json"} + _, err := Generate(context.Background(), client, nil, time.Now()) + if err == nil { + t.Error("expected error from invalid AI response") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/insights/ -run TestGenerate -v` +Expected: FAIL — Generate not defined + +- [ ] **Step 3: Create generate.go** + +Create `internal/insights/generate.go`: + +```go +package insights + +import ( + "context" + "time" + + "github.com/devlikebear/break-reminder/internal/ai" +) + +// AIClient is the minimal interface Generate needs. +// The real ai.Client satisfies this automatically. +type AIClient interface { + Query(ctx context.Context, prompt string) (string, error) +} + +// Generate orchestrates building the prompt, calling the AI, and assembling the Report. +// It does NOT save the result — caller should call Save() if persistence is desired. +func Generate(ctx context.Context, client AIClient, history []ai.DailySummary, now time.Time) (*Report, error) { + prompt := BuildPrompt(history) + raw, err := client.Query(ctx, prompt) + if err != nil { + return nil, err + } + report, err := ParseResponse(raw) + if err != nil { + return nil, err + } + report.GeneratedAt = now.Format(time.RFC3339) + return report, nil +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/insights/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/insights/generate.go internal/insights/generate_test.go +git commit -m "feat(insights): add Generate orchestration" +``` + +--- + +### Task 5: Add `insights` CLI subcommand + +**Files:** +- Create: `cmd/break-reminder/insights.go` +- Modify: `cmd/break-reminder/main.go` (register the command) + +- [ ] **Step 1: Find where commands are registered** + +Run: Grep to locate the root command assembly (look for `AddCommand` calls). + +Look for patterns like `rootCmd.AddCommand(newDashboardCmd())` in `main.go` or nearby files. + +- [ ] **Step 2: Create insights.go** + +Create `cmd/break-reminder/insights.go`: + +```go +package main + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" + + "github.com/devlikebear/break-reminder/internal/ai" + "github.com/devlikebear/break-reminder/internal/insights" +) + +func newInsightsCmd() *cobra.Command { + var refresh bool + + cmd := &cobra.Command{ + Use: "insights", + Short: "Show or refresh AI insights", + RunE: func(cmd *cobra.Command, args []string) error { + if refresh { + return refreshInsights() + } + return showInsights() + }, + } + + cmd.Flags().BoolVar(&refresh, "refresh", false, "Force regenerate insights via AI CLI") + return cmd +} + +func refreshInsights() error { + if !cfg.AIEnabled { + return fmt.Errorf("AI is disabled in config (set ai_enabled: true)") + } + + client := ai.NewClient(cfg.AICLI) + if !client.Available() { + return fmt.Errorf("AI CLI %q not found in PATH", cfg.AICLI) + } + + history, err := ai.LoadHistory() + if err != nil { + return fmt.Errorf("load history: %w", err) + } + + recent := trimRecentHistory(history, 7) + + log.Info().Int("entries", len(recent)).Msg("Generating AI insights") + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + report, err := insights.Generate(ctx, client, recent, time.Now()) + if err != nil { + return fmt.Errorf("generate: %w", err) + } + + if err := insights.Save(report); err != nil { + return fmt.Errorf("save: %w", err) + } + + fmt.Println("Insights refreshed:") + fmt.Println(report.DailyReport) + return nil +} + +func showInsights() error { + report, err := insights.Load() + if err != nil { + return err + } + if report == nil { + fmt.Println("No insights yet. Run with --refresh to generate.") + return nil + } + fmt.Printf("Generated: %s\n\n", report.GeneratedAt) + fmt.Println(report.DailyReport) + fmt.Println() + for _, p := range report.Patterns { + fmt.Printf("[%s] %s\n %s\n → %s\n\n", p.Type, p.Title, p.Description, p.Suggestion) + } + return nil +} + +func trimRecentHistory(history []ai.DailySummary, days int) []ai.DailySummary { + if len(history) <= days { + return history + } + return history[len(history)-days:] +} +``` + +- [ ] **Step 3: Register the command** + +Find the location in `cmd/break-reminder/main.go` (or wherever commands are added — likely a function that returns the root `*cobra.Command`). Add to the command registration: + +```go +root.AddCommand(newInsightsCmd()) +``` + +- [ ] **Step 4: Build and verify** + +Run: `go build ./cmd/break-reminder && ./break-reminder insights --help` +Expected: Help text for the `insights` command appears + +- [ ] **Step 5: Commit** + +```bash +git add cmd/break-reminder/insights.go cmd/break-reminder/main.go +git commit -m "feat(cli): add insights subcommand with --refresh" +``` + +--- + +### Task 6: Auto-trigger insight generation on day-end + +**Files:** +- Modify: whichever file handles `ActionSaveDailyHistory` (same file touched in Phase 2 Task 4) + +- [ ] **Step 1: Add auto-trigger logic** + +In the same handler that receives `ActionSaveDailyHistory`, after saving the daily summary, trigger insights generation asynchronously: + +```go +// After saving DailySummary history +if cfg.AIEnabled { + go func() { + client := ai.NewClient(cfg.AICLI) + if !client.Available() { + log.Warn().Str("cli", cfg.AICLI).Msg("AI CLI unavailable, skipping insights") + return + } + history, err := ai.LoadHistory() + if err != nil { + log.Warn().Err(err).Msg("Load history for insights") + return + } + recent := history + if len(recent) > 7 { + recent = recent[len(recent)-7:] + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + report, err := insights.Generate(ctx, client, recent, time.Now()) + if err != nil { + log.Warn().Err(err).Msg("Generate insights") + return + } + if err := insights.Save(report); err != nil { + log.Warn().Err(err).Msg("Save insights") + return + } + log.Info().Msg("Insights auto-generated") + }() +} +``` + +Add the imports: `"context"`, `"time"`, `"github.com/devlikebear/break-reminder/internal/insights"`. + +- [ ] **Step 2: Build and verify** + +Run: `go build ./...` +Expected: Builds cleanly + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "feat(service): auto-generate insights on daily history save" +``` + +--- + +### Task 7: Add InsightsLoader to HelperCore (Swift) + +**Files:** +- Create: `helpers/Sources/HelperCore/InsightsLoader.swift` +- Create: `helpers/Tests/HelperCoreTests/InsightsLoaderTests.swift` + +- [ ] **Step 1: Write the failing test** + +Create `helpers/Tests/HelperCoreTests/InsightsLoaderTests.swift`: + +```swift +import XCTest +@testable import HelperCore + +final class InsightsLoaderTests: XCTestCase { + func testParseValidReport() { + let json = """ + { + "generated_at": "2026-04-17T17:30:00+09:00", + "daily_report": "오늘 요약", + "patterns": [ + {"type":"warning","title":"T1","description":"D1","suggestion":"S1"}, + {"type":"info","title":"T2","description":"D2","suggestion":"S2"} + ] + } + """ + let report = parseInsights(from: json) + XCTAssertNotNil(report) + XCTAssertEqual(report?.dailyReport, "오늘 요약") + XCTAssertEqual(report?.patterns.count, 2) + XCTAssertEqual(report?.patterns[0].type, "warning") + } + + func testParseEmptyPatterns() { + let json = """ + {"generated_at":"x","daily_report":"r","patterns":[]} + """ + let report = parseInsights(from: json) + XCTAssertNotNil(report) + XCTAssertEqual(report?.patterns.count, 0) + } + + func testParseInvalidReturnsNil() { + XCTAssertNil(parseInsights(from: "not json")) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd helpers && swift test --filter InsightsLoaderTests 2>&1 | tail -10` +Expected: FAIL — parseInsights not defined + +- [ ] **Step 3: Create InsightsLoader.swift** + +Create `helpers/Sources/HelperCore/InsightsLoader.swift`: + +```swift +import Foundation + +public struct InsightPattern: Equatable { + public let type: String // "warning", "positive", "info" + public let title: String + public let description: String + public let suggestion: String + + public init(type: String, title: String, description: String, suggestion: String) { + self.type = type + self.title = title + self.description = description + self.suggestion = suggestion + } +} + +public struct InsightsReport: Equatable { + public let generatedAt: String + public let dailyReport: String + public let patterns: [InsightPattern] + + public init(generatedAt: String, dailyReport: String, patterns: [InsightPattern]) { + self.generatedAt = generatedAt + self.dailyReport = dailyReport + self.patterns = patterns + } +} + +public func parseInsights(from json: String) -> InsightsReport? { + guard let data = json.data(using: .utf8), + let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return nil + } + let patternsRaw = dict["patterns"] as? [[String: Any]] ?? [] + let patterns = patternsRaw.map { p in + InsightPattern( + type: p["type"] as? String ?? "info", + title: p["title"] as? String ?? "", + description: p["description"] as? String ?? "", + suggestion: p["suggestion"] as? String ?? "" + ) + } + return InsightsReport( + generatedAt: dict["generated_at"] as? String ?? "", + dailyReport: dict["daily_report"] as? String ?? "", + patterns: patterns + ) +} + +public func loadInsightsFromDisk() -> InsightsReport? { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-insights.json") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return nil } + return parseInsights(from: content) +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd helpers && swift test --filter InsightsLoaderTests` +Expected: All 3 tests pass + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/HelperCore/InsightsLoader.swift helpers/Tests/HelperCoreTests/InsightsLoaderTests.swift +git commit -m "feat(helpercore): add InsightsLoader for parsing insights JSON" +``` + +--- + +### Task 8: Wire insights into DashboardViewModel + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardViewModel.swift` + +- [ ] **Step 1: Add insights state and loading** + +In `DashboardViewModel`, add: + +```swift +@Published var insights: InsightsReport? +@Published var isRefreshingInsights = false + +func loadInsights() { + insights = loadInsightsFromDisk() +} + +func refreshInsights() { + guard !isRefreshingInsights else { return } + isRefreshingInsights = true + + Task.detached { [weak self] in + await self?.runInsightsRefresh() + } +} + +@MainActor +private func runInsightsRefresh() async { + defer { isRefreshingInsights = false } + + guard let cli = findHelper("break-reminder") else { + return + } + + let process = Process() + process.launchPath = cli + process.arguments = ["insights", "--refresh"] + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + + do { + try process.run() + process.waitUntilExit() + } catch { + return + } + + loadInsights() +} +``` + +In `start()`, after `loadHistory()`, also call `loadInsights()`. In `refresh()` (the 1-second tick), also call `loadInsights()` so the file update from the auto-trigger propagates. + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardViewModel.swift +git commit -m "feat(dashboard): wire insights loading and refresh into ViewModel" +``` + +--- + +### Task 9: Build real InsightsTabView + +**Files:** +- Modify: `helpers/Sources/DashboardApp/InsightsTabView.swift` + +- [ ] **Step 1: Replace placeholder with real view** + +Overwrite `helpers/Sources/DashboardApp/InsightsTabView.swift`: + +```swift +import SwiftUI +import AppKit +import HelperCore + +struct InsightsTabView: View { + @ObservedObject var vm: DashboardViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let report = vm.insights { + dailyReportCard(report) + Divider().background(Color(white: 0.2)) + patternsSection(report) + Divider().background(Color(white: 0.2)) + actionButtons(report) + } else { + emptyState + } + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + } + + private var emptyState: some View { + VStack(spacing: 12) { + Image(systemName: "sparkles") + .font(.system(size: 40)) + .foregroundColor(.gray) + Text("아직 인사이트가 없습니다") + .font(.system(size: 13)) + .foregroundColor(Color(white: 0.9)) + Text("AI CLI(claude 또는 codex)가 설치되어 있다면\n아래 버튼을 눌러 생성하세요.") + .font(.system(size: 11)) + .foregroundColor(.gray) + .multilineTextAlignment(.center) + Button(action: { vm.refreshInsights() }) { + if vm.isRefreshingInsights { + ProgressView().scaleEffect(0.7) + } else { + Text("AI 분석 생성") + } + } + .buttonStyle(DashboardButtonStyle()) + .frame(width: 140) + } + .frame(maxWidth: .infinity, minHeight: 200) + .padding(.top, 40) + } + + private func dailyReportCard(_ report: InsightsReport) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("✨ 오늘의 리포트") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + Spacer() + Text(shortTime(report.generatedAt)) + .font(.system(size: 10)) + .foregroundColor(.gray) + } + + HStack { + Rectangle() + .fill(Color(red: 0.3, green: 0.8, blue: 0.5)) + .frame(width: 3) + Text(report.dailyReport) + .font(.system(size: 12)) + .foregroundColor(Color(white: 0.85)) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 8) + } + .padding(10) + .background(Color(white: 0.15)) + .cornerRadius(10) + } + } + + private func patternsSection(_ report: InsightsReport) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text("🔍 패턴 인사이트") + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + + ForEach(Array(report.patterns.enumerated()), id: \.offset) { _, p in + patternCard(p) + } + } + } + + private func patternCard(_ pattern: InsightPattern) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Circle() + .fill(patternColor(pattern.type)) + .frame(width: 6, height: 6) + Text(pattern.title) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(Color(white: 0.9)) + } + Text(pattern.description) + .font(.system(size: 11)) + .foregroundColor(Color(white: 0.7)) + .fixedSize(horizontal: false, vertical: true) + if !pattern.suggestion.isEmpty { + Text("→ \(pattern.suggestion)") + .font(.system(size: 11)) + .foregroundColor(Color(white: 0.6)) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(white: 0.15)) + .cornerRadius(8) + } + + private func actionButtons(_ report: InsightsReport) -> some View { + HStack(spacing: 12) { + Button(action: { vm.refreshInsights() }) { + HStack(spacing: 4) { + if vm.isRefreshingInsights { + ProgressView().scaleEffect(0.6) + } else { + Text("🔄") + } + Text("새로고침") + } + } + .buttonStyle(DashboardButtonStyle()) + + Button(action: { copyReport(report) }) { + Text("📋 리포트 복사") + } + .buttonStyle(DashboardButtonStyle()) + } + } + + private func patternColor(_ type: String) -> Color { + switch type { + case "warning": return Color(red: 1.0, green: 0.8, blue: 0.4) + case "positive": return Color(red: 0.3, green: 0.8, blue: 0.5) + default: return Color(red: 0.4, green: 0.7, blue: 1.0) + } + } + + private func shortTime(_ iso: String) -> String { + let formatter = ISO8601DateFormatter() + guard let date = formatter.date(from: iso) else { return iso } + let display = DateFormatter() + display.dateFormat = "HH:mm" + return "\(display.string(from: date)) 생성" + } + + private func copyReport(_ report: InsightsReport) { + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + pasteboard.setString(report.dailyReport, forType: .string) + } +} +``` + +- [ ] **Step 2: Update call site to pass the ViewModel** + +In `DashboardAppMain.swift`, update the switch: + +```swift +case .insights: + InsightsTabView(vm: vm) +``` + +- [ ] **Step 3: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add helpers/Sources/DashboardApp/InsightsTabView.swift helpers/Sources/DashboardApp/DashboardAppMain.swift +git commit -m "feat(dashboard): build real InsightsTabView with report and patterns" +``` + +--- + +### Task 10: Manual integration test with mock insights + +- [ ] **Step 1: Build everything** + +Run: `make build` +Expected: Success + +- [ ] **Step 2: Seed mock insights for testing** + +```bash +cat > ~/.break-reminder-insights.json << 'EOF' +{ + "generated_at": "2026-04-17T17:30:00+09:00", + "daily_report": "오늘 4시간 20분 작업하고 50분 휴식했어요. 오전에 집중력이 높았고, 오후 3시 이후 휴식 간격이 짧아졌어요.", + "patterns": [ + {"type":"warning","title":"오후 슬럼프 패턴 감지","description":"최근 5일 중 4일, 오후 2시~4시에 평균 작업 시간이 35% 줄었어요.","suggestion":"이 시간대에 짧은 산책을 추가하면 효과적일 수 있어요."}, + {"type":"positive","title":"휴식 습관 개선 중","description":"지난주 대비 휴식 건너뛰기가 40% 줄었어요.","suggestion":"꾸준히 유지하면 집중력 향상에 도움이 됩니다."}, + {"type":"info","title":"최적 작업 시간대","description":"오전 10시~12시가 가장 집중도가 높은 골든 타임이에요.","suggestion":"중요한 작업은 이 시간에 배치하면 좋겠어요."} + ] +} +EOF +``` + +- [ ] **Step 3: Launch dashboard and verify Insights tab** + +Run: `bin/break-dashboard` + +Click the **인사이트** tab. Verify: +- Daily report card shows with green left border +- 3 pattern cards render with correct type colors (warning=yellow dot, positive=green dot, info=blue dot) +- Refresh and Copy buttons appear + +Click **📋 리포트 복사**, then paste elsewhere — the daily report text should be in the clipboard. + +- [ ] **Step 4: Test empty state** + +Delete the insights file: `rm ~/.break-reminder-insights.json` + +Relaunch dashboard, click Insights tab. Verify: +- "아직 인사이트가 없습니다" empty state appears with sparkles icon +- "AI 분석 생성" button is present + +- [ ] **Step 5: Test refresh button (requires ai_enabled + installed CLI)** + +If `ai_enabled: true` in config and claude/codex CLI is installed, clicking the refresh button should run the CLI and repopulate the insights file. This may take 30s-2min. + +If AI isn't available, the button will silently fail — no crash — and the empty state remains. That's expected. + +- [ ] **Step 6: Commit any fixes** + +```bash +git add -A +git commit -m "fix(dashboard): address Phase 3 integration issues" || true +``` + +--- + +## Phase 3 Completion Checklist + +- [ ] `go test ./...` passes (including new insights package tests) +- [ ] `cd helpers && swift test` passes (including InsightsLoaderTests) +- [ ] `make build` succeeds +- [ ] `break-reminder insights --refresh` generates insights when AI CLI available +- [ ] `break-reminder insights` prints current insights +- [ ] Dashboard Insights tab renders daily report and patterns correctly +- [ ] Pattern type colors (warning/positive/info) display correctly +- [ ] Refresh button triggers CLI regeneration +- [ ] Copy button copies daily report to clipboard +- [ ] Empty state shows when no insights file exists +- [ ] Day-end triggers auto-regeneration (verified by log entries) diff --git a/docs/superpowers/plans/2026-04-17-dashboard-v2-phase4.md b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase4.md new file mode 100644 index 0000000..b976e8f --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-dashboard-v2-phase4.md @@ -0,0 +1,1134 @@ +# DashboardApp v2 Phase 4: Visual Enhancements — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add mascot with state-aware messaging, SwiftUI animations (ring glow, state transitions, confetti, tab slide), and a theme system (dark/light/auto) to make the dashboard feel alive. + +**Architecture:** `MascotEngine` (in HelperCore) maps state+config+conditions to `(emoji, message)`. A `ThemeManager` `ObservableObject` holds the current color palette, swappable via the new `theme` config field. Animations use native SwiftUI primitives (`withAnimation`, `.transition`, `Canvas + TimelineView`). + +**Tech Stack:** SwiftUI, HelperCore, Go (config schema extension) + +**Prerequisites:** Phase 3 complete (all tabs, insights) + +--- + +### Task 1: Add theme field to Go Config + +**Files:** +- Modify: `internal/config/types.go:4-27` +- Modify: `internal/config/defaults.go` (find it via grep if needed) +- Modify: `internal/config/load.go` (merge function) +- Test: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/config/config_test.go` (append at end): + +```go +func TestThemeDefaultAuto(t *testing.T) { + cfg := Default() + if cfg.Theme != "auto" { + t.Errorf("Theme default = %q, want 'auto'", cfg.Theme) + } +} + +func TestThemeOverrideFromFile(t *testing.T) { + tmpDir := t.TempDir() + origDir := configDir + defer func() { configDir = origDir }() + + home, _ := os.UserHomeDir() + rel, _ := filepath.Rel(home, tmpDir) + configDir = rel + + path := filepath.Join(tmpDir, configFile) + content := `theme: dark +` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.Theme != "dark" { + t.Errorf("Theme = %q, want 'dark'", cfg.Theme) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestTheme -v` +Expected: FAIL — Theme field doesn't exist + +- [ ] **Step 3: Add Theme field** + +Edit `internal/config/types.go` — add a field to the `Config` struct: + +```go +Theme string `yaml:"theme"` // "auto", "dark", "light" +``` + +Find `Default()` in the config package (grep for `func Default`) and add: + +```go +Theme: "auto", +``` + +Find the `merge()` function in `load.go` and add: + +```go +if src.Theme != "" { + dst.Theme = src.Theme +} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./internal/config/ -v` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/ +git commit -m "feat(config): add theme field (auto/dark/light)" +``` + +--- + +### Task 2: Add theme to Swift AppConfig (HelperCore) + +**Files:** +- Modify: `helpers/Sources/HelperCore/ConfigParser.swift` +- Modify: `helpers/Tests/HelperCoreTests/ConfigParserTests.swift` + +- [ ] **Step 1: Write the failing test** + +Add to `helpers/Tests/HelperCoreTests/ConfigParserTests.swift` (append at end): + +```swift +func testThemeDefaultAuto() { + let cfg = AppConfig() + XCTAssertEqual(cfg.theme, "auto") +} + +func testThemeParseFromYAML() { + let yaml = """ + work_duration_min: 50 + theme: dark + """ + let cfg = parseConfig(from: yaml) + XCTAssertEqual(cfg.theme, "dark") +} + +func testThemeParseLight() { + let cfg = parseConfig(from: "theme: light") + XCTAssertEqual(cfg.theme, "light") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd helpers && swift test --filter ConfigParserTests 2>&1 | tail -10` +Expected: FAIL — theme property doesn't exist + +- [ ] **Step 3: Update ConfigParser** + +Edit `helpers/Sources/HelperCore/ConfigParser.swift`: + +```swift +public struct AppConfig: Equatable { + public var workDurationMin: Int = 50 + public var breakDurationMin: Int = 10 + public var idleThresholdSec: Int = 120 + public var checkIntervalSec: Int = 60 + public var theme: String = "auto" + + public init() {} +} + +public func parseConfig(from content: String) -> AppConfig { + var c = AppConfig() + for line in content.components(separatedBy: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let parts = trimmed.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + let key = String(parts[0]).trimmingCharacters(in: .whitespaces) + let val = String(parts[1]).trimmingCharacters(in: .whitespaces) + switch key { + case "work_duration_min": c.workDurationMin = Int(val) ?? 50 + case "break_duration_min": c.breakDurationMin = Int(val) ?? 10 + case "idle_threshold_sec": c.idleThresholdSec = Int(val) ?? 120 + case "check_interval_sec": c.checkIntervalSec = Int(val) ?? 60 + case "theme": c.theme = val + default: break + } + } + return c +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd helpers && swift test --filter ConfigParserTests` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/HelperCore/ConfigParser.swift helpers/Tests/HelperCoreTests/ConfigParserTests.swift +git commit -m "feat(helpercore): add theme field to AppConfig" +``` + +--- + +### Task 3: Create ThemeManager in DashboardApp + +**Files:** +- Create: `helpers/Sources/DashboardApp/ThemeManager.swift` + +- [ ] **Step 1: Create ThemeManager** + +Create `helpers/Sources/DashboardApp/ThemeManager.swift`: + +```swift +import SwiftUI + +enum ThemeMode: String { + case auto, dark, light + + init(raw: String) { + self = ThemeMode(rawValue: raw) ?? .auto + } +} + +@MainActor +final class ThemeManager: ObservableObject { + @Published var mode: ThemeMode = .auto + @Published var systemIsDark: Bool = true // Updated from Environment + + var isDark: Bool { + switch mode { + case .dark: return true + case .light: return false + case .auto: return systemIsDark + } + } + + // MARK: - Color tokens + + var background: Color { + isDark ? Color(red: 0.102, green: 0.102, blue: 0.118) : Color(red: 0.961, green: 0.961, blue: 0.969) + } + + var surface: Color { + isDark ? Color(red: 0.145, green: 0.145, blue: 0.157) : Color.white + } + + var textPrimary: Color { + isDark ? Color(white: 0.9) : Color(red: 0.102, green: 0.102, blue: 0.118) + } + + var textSecondary: Color { + isDark ? Color(white: 0.5) : Color(white: 0.4) + } + + var accent: Color { + isDark ? Color(red: 0.302, green: 0.800, blue: 0.502) : Color(red: 0.204, green: 0.659, blue: 0.325) + } + + var accentBreak: Color { + isDark ? Color(red: 0.400, green: 0.702, blue: 1.000) : Color(red: 0.259, green: 0.522, blue: 0.957) + } + + var warning: Color { + isDark ? Color(red: 1.0, green: 0.8, blue: 0.4) : Color(red: 0.976, green: 0.671, blue: 0.000) + } + + var divider: Color { + Color(white: isDark ? 0.2 : 0.85) + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/ThemeManager.swift +git commit -m "feat(dashboard): add ThemeManager with color tokens" +``` + +--- + +### Task 4: Inject ThemeManager and sync with config + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` +- Modify: `helpers/Sources/DashboardApp/DashboardViewModel.swift` + +- [ ] **Step 1: Wire ThemeManager into the app** + +In `DashboardAppMain.swift`, add `@StateObject` for the theme manager and inject it into the view tree: + +```swift +@main +struct DashboardAppEntry: App { + @StateObject private var vm = DashboardViewModel() + @StateObject private var theme = ThemeManager() + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Window("Break Reminder", id: "dashboard") { + DashboardContentView(vm: vm) + .environmentObject(theme) + .frame(width: 360, height: 600) + .background(theme.background) + .onAppear { + vm.start() + theme.mode = ThemeMode(raw: vm.config.theme) + configureWindow() + } + .onDisappear { vm.stop() } + .onKeyPress("q") { NSApp.terminate(nil); return .handled } + .onKeyPress("r") { vm.resetTimer(); return .handled } + .onKeyPress("b") { vm.forceBreak(); return .handled } + .onChange(of: vm.config.theme) { _, newValue in + theme.mode = ThemeMode(raw: newValue) + } + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultPosition(.topTrailing) + } + + // (configureWindow unchanged) +} +``` + +In `DashboardContentView`, pick up theme from environment and use it to drive colors. Also detect system color scheme: + +```swift +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + @EnvironmentObject var theme: ThemeManager + @Environment(\.colorScheme) private var systemColorScheme + + private var accentColor: Color { + if vm.isPaused { return theme.warning } + return vm.isWork ? theme.accent : theme.accentBreak + } + + var body: some View { + VStack(spacing: 0) { + StatusHeaderView(vm: vm) + Divider().background(theme.divider) + TabBarView(selectedTab: $vm.selectedTab, accentColor: accentColor) + + Group { + switch vm.selectedTab { + case .timer: + TimerTabView(vm: vm) + case .stats: + StatsTabView(vm: vm) + case .insights: + InsightsTabView(vm: vm) + } + } + } + .onChange(of: systemColorScheme) { _, newValue in + theme.systemIsDark = (newValue == .dark) + } + .onAppear { + theme.systemIsDark = (systemColorScheme == .dark) + } + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardAppMain.swift +git commit -m "feat(dashboard): inject ThemeManager and sync with config" +``` + +--- + +### Task 5: Update views to use ThemeManager colors + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatusHeaderView.swift` +- Modify: `helpers/Sources/DashboardApp/TimerTabView.swift` +- Modify: `helpers/Sources/DashboardApp/StatsTabView.swift` +- Modify: `helpers/Sources/DashboardApp/InsightsTabView.swift` +- Modify: `helpers/Sources/DashboardApp/TabBarView.swift` + +- [ ] **Step 1: Update StatusHeaderView** + +Replace hardcoded colors in `StatusHeaderView.swift`: + +```swift +struct StatusHeaderView: View { + @ObservedObject var vm: DashboardViewModel + @EnvironmentObject var theme: ThemeManager + + private var statusColor: Color { + if vm.isPaused { return theme.warning } + return vm.isWork ? theme.accent : theme.accentBreak + } + + private var ringSize: CGFloat { 140 } + + var body: some View { + VStack(spacing: 12) { + HStack { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + Text(vm.statusText) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(statusColor) + Spacer() + Text(vm.modeDetail) + .font(.system(size: 12)) + .foregroundColor(theme.textSecondary) + } + + ZStack { + CircularProgressRing( + progress: vm.sessionProgress.progress, + fillColor: statusColor, + trackColor: theme.divider, + lineWidth: 10 + ) + .frame(width: ringSize, height: ringSize) + + VStack(spacing: 2) { + Text(vm.sessionProgress.remainingFormatted) + .font(.system(size: 32, weight: .ultraLight).monospacedDigit()) + .foregroundColor(theme.textPrimary) + Text(vm.sessionSubtitle) + .font(.system(size: 11)) + .foregroundColor(theme.textSecondary) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + } +} +``` + +- [ ] **Step 2: Update CircularProgressRing to accept track color** + +Edit `helpers/Sources/DashboardApp/CircularProgressRing.swift`: + +```swift +import SwiftUI + +struct CircularProgressRing: View { + let progress: Double + let fillColor: Color + let trackColor: Color + let lineWidth: CGFloat + + init(progress: Double, fillColor: Color, trackColor: Color = Color(white: 0.2), lineWidth: CGFloat = 10) { + self.progress = progress + self.fillColor = fillColor + self.trackColor = trackColor + self.lineWidth = lineWidth + } + + var body: some View { + ZStack { + Circle() + .stroke(trackColor, lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: CGFloat(min(progress, 1.0))) + .stroke(fillColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } +} +``` + +- [ ] **Step 3: Update TimerTabView** + +Replace colors in `TimerTabView.swift` — use `@EnvironmentObject var theme: ThemeManager` and replace: + +- `Color(red: 0.3, green: 0.8, blue: 0.5)` → `theme.accent` +- `Color(red: 0.4, green: 0.7, blue: 1.0)` → `theme.accentBreak` +- `Color(white: 0.9)` → `theme.textPrimary` +- `.gray` for secondary text → `theme.textSecondary` +- `Color(white: 0.2)` for track → `theme.divider` +- Add `@EnvironmentObject var theme: ThemeManager` to `TimerTabView` struct + +- [ ] **Step 4: Update StatsTabView, InsightsTabView, TabBarView** + +Apply the same pattern: add `@EnvironmentObject var theme: ThemeManager` and swap hardcoded colors for theme tokens. + +For `DashboardButtonStyle`, since it's a ButtonStyle struct (not a View), it can't directly use EnvironmentObject. Add a theme parameter: + +```swift +struct DashboardButtonStyle: ButtonStyle { + let surfaceColor: Color + let textColor: Color + + init(surfaceColor: Color = Color(white: 0.22), textColor: Color = Color(white: 0.9)) { + self.surfaceColor = surfaceColor + self.textColor = textColor + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .medium)) + .foregroundColor(textColor) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(surfaceColor.opacity(configuration.isPressed ? 1.3 : 1.0)) + ) + } +} +``` + +Call sites update to: `.buttonStyle(DashboardButtonStyle(surfaceColor: theme.surface, textColor: theme.textPrimary))` + +- [ ] **Step 5: Verify build and run** + +Run: `cd helpers && swift build -c release && .build/release/DashboardApp` +Expected: +- Dashboard launches in current system theme +- Change system preferences (System Settings → Appearance) to light mode — dashboard should switch colors +- Set `theme: light` in `~/.config/break-reminder/config.yaml` — dashboard should stay light regardless of system theme + +- [ ] **Step 6: Commit** + +```bash +git add helpers/Sources/DashboardApp/ +git commit -m "feat(dashboard): use ThemeManager colors across all views" +``` + +--- + +### Task 6: Create MascotEngine in HelperCore + +**Files:** +- Create: `helpers/Sources/HelperCore/MascotEngine.swift` +- Create: `helpers/Tests/HelperCoreTests/MascotEngineTests.swift` + +- [ ] **Step 1: Write the failing test** + +Create `helpers/Tests/HelperCoreTests/MascotEngineTests.swift`: + +```swift +import XCTest +@testable import HelperCore + +final class MascotEngineTests: XCTestCase { + func testWorkingStateReturnsHamster() { + var state = AppState() + state.mode = "work" + state.workSeconds = 300 // 5 min + let config = AppConfig() + let mascot = mascotFor(state: state, config: config, now: Int64(Date().timeIntervalSince1970)) + + XCTAssertEqual(mascot.emoji, "🐹") + XCTAssertFalse(mascot.message.isEmpty) + } + + func testBreakStateReturnsSleeping() { + var state = AppState() + state.mode = "break" + state.breakStart = Int64(Date().timeIntervalSince1970) - 60 + let config = AppConfig() + let mascot = mascotFor(state: state, config: config, now: Int64(Date().timeIntervalSince1970)) + + XCTAssertEqual(mascot.emoji, "😴") + } + + func testLongWorkReturnsConcerned() { + var state = AppState() + state.mode = "work" + state.workSeconds = 7200 // 2 hours + let config = AppConfig() + config.workDurationMin = 50 // break every 50 min + let mascot = mascotFor(state: state, config: config, now: Int64(Date().timeIntervalSince1970)) + + XCTAssertEqual(mascot.emoji, "😰") + } + + func testPausedReturnsNeutral() { + var state = AppState() + state.mode = "work" + state.paused = true + let config = AppConfig() + let mascot = mascotFor(state: state, config: config, now: Int64(Date().timeIntervalSince1970)) + + XCTAssertEqual(mascot.emoji, "😶") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd helpers && swift test --filter MascotEngineTests 2>&1 | tail -10` +Expected: FAIL — mascotFor not defined + +- [ ] **Step 3: Create MascotEngine.swift** + +Create `helpers/Sources/HelperCore/MascotEngine.swift`: + +```swift +import Foundation + +public struct Mascot: Equatable { + public let emoji: String + public let message: String + + public init(emoji: String, message: String) { + self.emoji = emoji + self.message = message + } +} + +/// Selects a mascot (emoji + message) based on the current state. +public func mascotFor(state: AppState, config: AppConfig, now: Int64) -> Mascot { + // Paused + if state.paused { + return Mascot(emoji: "😶", message: "일시 정지 중이에요") + } + + // Break mode + if state.mode == "break" { + let breakElapsed = state.breakStart > 0 ? Int(now - state.breakStart) : 0 + let breakTotal = config.breakDurationMin * 60 + if breakElapsed > breakTotal - 60 { + return Mascot(emoji: "☕", message: "곧 다시 시작해요~") + } + return Mascot(emoji: "😴", message: "푹 쉬고 와요~ ☕") + } + + // Work mode + let workTotal = config.workDurationMin * 60 + let elapsed = state.workSeconds + + // Long continuous work warning (2x the configured work duration) + if elapsed >= workTotal * 2 { + return Mascot(emoji: "😰", message: "쉬어가는 게 어때요? 🙏") + } + + // Near break time (last 5 minutes) + if workTotal - elapsed <= 300 && workTotal - elapsed > 0 { + return Mascot(emoji: "🐹", message: "곧 휴식 시간이에요~ ☕") + } + + // Default working + return Mascot(emoji: "🐹", message: "집중 모드! 화이팅 💪") +} + +/// Selects a mascot for achievement moments (e.g., daily goal). +public func mascotForAchievement(dailyWorkMinutes: Int, goalMinutes: Int) -> Mascot? { + guard goalMinutes > 0, dailyWorkMinutes >= goalMinutes else { return nil } + return Mascot(emoji: "🎉", message: "오늘도 잘 해냈어요! 🏆") +} +``` + +- [ ] **Step 4: Run tests** + +Run: `cd helpers && swift test --filter MascotEngineTests` +Expected: All tests pass + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/HelperCore/MascotEngine.swift helpers/Tests/HelperCoreTests/MascotEngineTests.swift +git commit -m "feat(helpercore): add MascotEngine with state-based emoji/message mapping" +``` + +--- + +### Task 7: Add mascot to StatusHeaderView + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatusHeaderView.swift` +- Modify: `helpers/Sources/DashboardApp/DashboardViewModel.swift` + +- [ ] **Step 1: Compute mascot in ViewModel** + +Add to `DashboardViewModel.swift`: + +```swift +var currentMascot: Mascot { + mascotFor(state: state, config: config, now: now) +} +``` + +- [ ] **Step 2: Render mascot in StatusHeaderView** + +Edit `helpers/Sources/DashboardApp/StatusHeaderView.swift` — add a mascot row at the bottom of the VStack: + +```swift +var body: some View { + VStack(spacing: 12) { + // ... existing status row ... + // ... existing ZStack with ring ... + + mascotRow + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) +} + +private var mascotRow: some View { + HStack(spacing: 8) { + Text(vm.currentMascot.emoji) + .font(.system(size: 22)) + .id(vm.currentMascot.emoji) // Triggers transition on change + + Text(vm.currentMascot.message) + .font(.system(size: 11)) + .foregroundColor(theme.textSecondary) + .lineLimit(2) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(theme.surface) + ) + .frame(maxWidth: .infinity) +} +``` + +- [ ] **Step 3: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 4: Launch and verify** + +Run: `.build/release/DashboardApp` +Expected: Mascot row appears below the timer ring with 🐹 and a message + +- [ ] **Step 5: Commit** + +```bash +git add helpers/Sources/DashboardApp/ +git commit -m "feat(dashboard): add mascot row to status header" +``` + +--- + +### Task 8: Add state-transition animation (work ↔ break color morph) + +**Files:** +- Modify: `helpers/Sources/DashboardApp/StatusHeaderView.swift` +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` + +- [ ] **Step 1: Animate mode changes** + +In `StatusHeaderView.swift`, wrap the color-dependent views in `.animation()`: + +```swift +var body: some View { + VStack(spacing: 12) { + // status row ... + // ring ZStack ... + mascotRow + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + .animation(.easeInOut(duration: 0.5), value: vm.isWork) + .animation(.easeInOut(duration: 0.3), value: vm.isPaused) +} +``` + +Also add mascot bounce: + +```swift +private var mascotRow: some View { + HStack(spacing: 8) { + Text(vm.currentMascot.emoji) + .font(.system(size: 22)) + .scaleEffect(vm.isPaused ? 0.9 : 1.0) + .animation(.spring(response: 0.4, dampingFraction: 0.6), value: vm.currentMascot.emoji) + // ... + } + // ... +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Launch and verify** + +Run: `.build/release/DashboardApp` + +Click **Force Break** — observe the color morph from green to blue over 0.5s. Click **Reset** — color morphs back. + +- [ ] **Step 4: Commit** + +```bash +git add helpers/Sources/DashboardApp/StatusHeaderView.swift +git commit -m "feat(dashboard): add color morph animation on state transitions" +``` + +--- + +### Task 9: Add tab transition animation + +**Files:** +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` + +- [ ] **Step 1: Add transition to Group** + +Update `DashboardContentView.body`: + +```swift +Group { + switch vm.selectedTab { + case .timer: + TimerTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + case .stats: + StatsTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + case .insights: + InsightsTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + } +} +.animation(.easeInOut(duration: 0.25), value: vm.selectedTab) +``` + +- [ ] **Step 2: Verify build and test** + +Run: `.build/release/DashboardApp` + +Click different tabs — content should slide in/out instead of hard-cutting. + +- [ ] **Step 3: Commit** + +```bash +git add helpers/Sources/DashboardApp/DashboardAppMain.swift +git commit -m "feat(dashboard): add slide transition between tabs" +``` + +--- + +### Task 10: Add progress ring glow effect + +**Files:** +- Modify: `helpers/Sources/DashboardApp/CircularProgressRing.swift` +- Modify: `helpers/Sources/DashboardApp/StatusHeaderView.swift` + +- [ ] **Step 1: Add glow effect** + +Edit `CircularProgressRing.swift`: + +```swift +import SwiftUI + +struct CircularProgressRing: View { + let progress: Double + let fillColor: Color + let trackColor: Color + let lineWidth: CGFloat + + init(progress: Double, fillColor: Color, trackColor: Color = Color(white: 0.2), lineWidth: CGFloat = 10) { + self.progress = progress + self.fillColor = fillColor + self.trackColor = trackColor + self.lineWidth = lineWidth + } + + var body: some View { + ZStack { + Circle() + .stroke(trackColor, lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: CGFloat(min(progress, 1.0))) + .stroke(fillColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .shadow(color: fillColor.opacity(0.6), radius: 4) + .animation(.easeInOut(duration: 1.0), value: progress) + } + } +} +``` + +- [ ] **Step 2: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 3: Visual check** + +Run: `.build/release/DashboardApp` + +The progress ring should have a subtle glow around the filled portion. As the progress increases (once per second), the fill animates smoothly. + +- [ ] **Step 4: Commit** + +```bash +git add helpers/Sources/DashboardApp/CircularProgressRing.swift +git commit -m "feat(dashboard): add glow effect to progress ring" +``` + +--- + +### Task 11: Add confetti for daily goal achievement + +**Files:** +- Create: `helpers/Sources/DashboardApp/ConfettiView.swift` +- Modify: `helpers/Sources/DashboardApp/DashboardAppMain.swift` +- Modify: `helpers/Sources/DashboardApp/DashboardViewModel.swift` + +- [ ] **Step 1: Create ConfettiView using Canvas + TimelineView** + +Create `helpers/Sources/DashboardApp/ConfettiView.swift`: + +```swift +import SwiftUI + +struct ConfettiParticle: Identifiable { + let id = UUID() + let x: CGFloat // Start x position (0...1) + let delay: TimeInterval + let duration: TimeInterval + let color: Color + let rotationSpeed: Double + + static func random(colors: [Color]) -> ConfettiParticle { + ConfettiParticle( + x: .random(in: 0...1), + delay: .random(in: 0...0.5), + duration: .random(in: 1.5...3.0), + color: colors.randomElement() ?? .green, + rotationSpeed: .random(in: 0.5...2.0) + ) + } +} + +struct ConfettiView: View { + let particles: [ConfettiParticle] + let isActive: Bool + + static func generate(count: Int, colors: [Color]) -> [ConfettiParticle] { + (0..= dailyGoalMinutes && lastGoalCheckMinute < dailyGoalMinutes { + showConfetti = true + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in + self?.showConfetti = false + } + } + lastGoalCheckMinute = workMin +} +``` + +Call `checkGoalAchievement()` at the end of `refresh()`. + +- [ ] **Step 3: Overlay confetti on content view** + +In `DashboardAppMain.swift`: + +```swift +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + @EnvironmentObject var theme: ThemeManager + @Environment(\.colorScheme) private var systemColorScheme + + @State private var confettiParticles: [ConfettiParticle] = [] + + private var accentColor: Color { + if vm.isPaused { return theme.warning } + return vm.isWork ? theme.accent : theme.accentBreak + } + + var body: some View { + ZStack { + VStack(spacing: 0) { + StatusHeaderView(vm: vm) + Divider().background(theme.divider) + TabBarView(selectedTab: $vm.selectedTab, accentColor: accentColor) + + Group { + switch vm.selectedTab { + case .timer: + TimerTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + case .stats: + StatsTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + case .insights: + InsightsTabView(vm: vm) + .transition(.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading))) + } + } + .animation(.easeInOut(duration: 0.25), value: vm.selectedTab) + } + + ConfettiView( + particles: confettiParticles, + isActive: vm.showConfetti + ) + .onAppear { + confettiParticles = ConfettiView.generate( + count: 50, + colors: [theme.accent, theme.accentBreak, theme.warning, .pink, .purple] + ) + } + } + .onChange(of: systemColorScheme) { _, newValue in + theme.systemIsDark = (newValue == .dark) + } + .onAppear { + theme.systemIsDark = (systemColorScheme == .dark) + } + } +} +``` + +- [ ] **Step 4: Verify build** + +Run: `cd helpers && swift build 2>&1 | tail -3` +Expected: Build succeeds + +- [ ] **Step 5: Test confetti manually** + +To test, temporarily lower the goal threshold in the ViewModel (e.g., `dailyGoalMinutes = 1`) and reach 1 minute of work. Or seed the state file with `TODAY_WORK_SECONDS=14400` (4 hours × 60 × 60) and launch — confetti should trigger on the first refresh. + +Restore `dailyGoalMinutes = 240` before committing. + +- [ ] **Step 6: Commit** + +```bash +git add helpers/Sources/DashboardApp/ConfettiView.swift helpers/Sources/DashboardApp/DashboardAppMain.swift helpers/Sources/DashboardApp/DashboardViewModel.swift +git commit -m "feat(dashboard): add confetti animation on daily goal achievement" +``` + +--- + +### Task 12: Full Phase 4 integration verification + +- [ ] **Step 1: Run all tests** + +Run: `go test ./... && cd helpers && swift test` +Expected: All tests pass + +- [ ] **Step 2: Run build** + +Run: `make build` +Expected: Success + +- [ ] **Step 3: Launch and verify** + +Run: `bin/break-dashboard` + +Verify: +- Mascot row renders below timer ring +- Force break → colors morph smoothly +- Reset → colors morph back +- Switching tabs → slide animation +- Ring has subtle glow +- Theme config works: + - `theme: auto` → follows system + - `theme: dark` → always dark + - `theme: light` → always light (switch system to light mode to confirm; dashboard stays light on `dark` setting) + +Test from CLI: +Run: `bin/break-reminder dashboard --gui` +Expected: Same dashboard launches + +- [ ] **Step 4: Final commit** + +```bash +git add -A +git commit -m "fix(dashboard): address Phase 4 integration issues" || true +``` + +--- + +## Phase 4 Completion Checklist + +- [ ] `go test ./...` passes +- [ ] `cd helpers && swift test` passes (including MascotEngineTests) +- [ ] `make build` succeeds +- [ ] Mascot row appears below timer ring with state-appropriate emoji/message +- [ ] Color morph animates on work↔break transitions +- [ ] Tab transitions slide smoothly +- [ ] Progress ring has glow effect +- [ ] Theme system works: auto/dark/light all render correctly +- [ ] `theme: light` in config overrides system theme +- [ ] Confetti triggers when daily goal is reached (manual verification) +- [ ] Mascot shows concerned face when working 2x work duration +- [ ] Break mode mascot shows sleeping face with rest message From da913ad375cc11fdba8f608781fa5b4d58881d95 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 20:39:07 +0900 Subject: [PATCH 03/20] chore: bump macOS deployment target to 13 for Swift Charts --- helpers/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/Package.swift b/helpers/Package.swift index 5c16ad5..5d3adcc 100644 --- a/helpers/Package.swift +++ b/helpers/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "BreakReminderHelpers", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v13)], targets: [ .target( name: "HelperCore" From 410432c581f98061f13c52f6c07e981337469a93 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 20:41:52 +0900 Subject: [PATCH 04/20] feat(dashboard): add DashboardViewModel with timer polling Adds ObservableObject ViewModel to back the SwiftUI DashboardApp migration. Tests for pure HelperCore progress functions pass; I/O function stubs resolved in Task 3. --- .../DashboardApp/DashboardViewModel.swift | 95 +++++++++++++++++++ .../DashboardViewModelTests.swift | 35 +++++++ 2 files changed, 130 insertions(+) create mode 100644 helpers/Sources/DashboardApp/DashboardViewModel.swift create mode 100644 helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift diff --git a/helpers/Sources/DashboardApp/DashboardViewModel.swift b/helpers/Sources/DashboardApp/DashboardViewModel.swift new file mode 100644 index 0000000..c837e04 --- /dev/null +++ b/helpers/Sources/DashboardApp/DashboardViewModel.swift @@ -0,0 +1,95 @@ +import Foundation +import SwiftUI +import HelperCore + +@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" + + private var timer: Timer? + + var isWork: Bool { state.mode == "work" } + var isPaused: Bool { state.paused } + var now: Int64 { Int64(Date().timeIntervalSince1970) } + + var sessionProgress: SessionProgress { + if isWork { + return workProgress(state: state, config: config, now: now) + } else { + return breakProgress(state: state, config: config, now: now) + } + } + + var dailyTotals: LiveDailyTotals { + liveDailyTotals(state: state, config: config, now: now) + } + + var statusText: String { + if isPaused { + return "PAUSED (\(isWork ? "WORK" : "BREAK"))" + } + return isWork ? "WORKING" : "ON BREAK" + } + + var modeDetail: String { + let sp = sessionProgress + if isWork { + return "\(sp.elapsedSec / 60) / \(config.workDurationMin) min" + } else { + return "\(sp.elapsedSec / 60) / \(config.breakDurationMin) min" + } + } + + var sessionSubtitle: String { + if isPaused { return "paused" } + return isWork ? "until break" : "until work" + } + + func start() { + refresh() + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + } + + func stop() { + timer?.invalidate() + timer = nil + } + + func refresh() { + state = loadStateFromDisk() + config = loadConfigFromDisk() + idleSeconds = getIdleSecondsFromSystem() + launchdStatusText = queryLaunchdStatus() + } + + func resetTimer() { + let totals = dailyTotals + var s = AppState() + s.lastCheck = now + s.todayWorkSeconds = totals.workSeconds + s.todayBreakSeconds = totals.breakSeconds + s.lastUpdateDate = totals.date + writeStateToDisk(s) + refresh() + } + + func forceBreak() { + let totals = dailyTotals + var s = AppState() + s.mode = "break" + s.lastCheck = now + s.breakStart = now + s.todayWorkSeconds = totals.workSeconds + s.todayBreakSeconds = totals.breakSeconds + s.lastUpdateDate = totals.date + writeStateToDisk(s) + refresh() + } +} diff --git a/helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift b/helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift new file mode 100644 index 0000000..cd3396b --- /dev/null +++ b/helpers/Tests/HelperCoreTests/DashboardViewModelTests.swift @@ -0,0 +1,35 @@ +import XCTest +@testable import HelperCore + +final class DashboardViewModelTests: XCTestCase { + func testWorkProgressCalculation() { + let state = AppState() + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let sp = workProgress(state: state, config: config, now: now) + XCTAssertGreaterThanOrEqual(sp.progress, 0.0) + XCTAssertLessThanOrEqual(sp.progress, 1.0) + } + + func testBreakProgressCalculation() { + var state = AppState() + state.mode = "break" + state.breakStart = Int64(Date().timeIntervalSince1970) - 60 + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let sp = breakProgress(state: state, config: config, now: now) + XCTAssertGreaterThan(sp.elapsedSec, 0) + } + + func testLiveDailyTotalsDefaultState() { + let state = AppState() + let config = AppConfig() + let now = Int64(Date().timeIntervalSince1970) + + let totals = liveDailyTotals(state: state, config: config, now: now) + XCTAssertGreaterThanOrEqual(totals.workSeconds, 0) + XCTAssertGreaterThanOrEqual(totals.breakSeconds, 0) + } +} From ce4cc13054d7faa74e84996b3cbeb49903a3ebe5 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 20:53:33 +0900 Subject: [PATCH 05/20] refactor(dashboard): extract system I/O functions to SystemIO.swift --- helpers/Sources/DashboardApp/SystemIO.swift | 85 +++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 helpers/Sources/DashboardApp/SystemIO.swift diff --git a/helpers/Sources/DashboardApp/SystemIO.swift b/helpers/Sources/DashboardApp/SystemIO.swift new file mode 100644 index 0000000..a998389 --- /dev/null +++ b/helpers/Sources/DashboardApp/SystemIO.swift @@ -0,0 +1,85 @@ +import Foundation +import HelperCore + +func loadStateFromDisk() -> AppState { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-state") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppState() } + return parseState(from: content) +} + +func loadConfigFromDisk() -> AppConfig { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".config/break-reminder/config.yaml") + guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppConfig() } + return parseConfig(from: content) +} + +func writeStateToDisk(_ s: AppState) { + let home = FileManager.default.homeDirectoryForCurrentUser + let path = home.appendingPathComponent(".break-reminder-state") + try? serializeState(s).data(using: .utf8)?.write(to: path, options: .atomic) +} + +func queryLaunchdStatus() -> String { + let task = Process() + task.launchPath = "/bin/launchctl" + task.arguments = ["list", "com.devlikebear.break-reminder"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = pipe + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 ? "Running (launchd)" : "Not loaded" + } catch { + return "Unknown" + } +} + +func getIdleSecondsFromSystem() -> Int { + let task = Process() + task.launchPath = "/usr/sbin/ioreg" + task.arguments = ["-c", "IOHIDSystem", "-d", "4"] + let pipe = Pipe() + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + do { + try task.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + guard let output = String(data: data, encoding: .utf8) else { return 0 } + for line in output.components(separatedBy: "\n") { + if line.contains("HIDIdleTime") { + let parts = line.components(separatedBy: "=") + if let last = parts.last { + let cleaned = last.trimmingCharacters(in: .whitespacesAndNewlines) + if let ns = Int64(cleaned) { + return Int(ns / 1_000_000_000) + } + } + } + } + } catch {} + return 0 +} + +func findHelper(_ name: String) -> String? { + var candidates: [String] = [] + if let exe = Bundle.main.executablePath { + candidates.append( + URL(fileURLWithPath: exe) + .deletingLastPathComponent() + .appendingPathComponent(name) + .path + ) + } + let home = FileManager.default.homeDirectoryForCurrentUser.path + candidates.append("\(home)/.local/bin/\(name)") + for candidate in candidates { + if FileManager.default.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil +} From 7b7c98639bcb6f50ec54355cf593e007df0dcd8b Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:03:51 +0900 Subject: [PATCH 06/20] feat(dashboard): add CircularProgressRing SwiftUI view --- .../DashboardApp/CircularProgressRing.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 helpers/Sources/DashboardApp/CircularProgressRing.swift diff --git a/helpers/Sources/DashboardApp/CircularProgressRing.swift b/helpers/Sources/DashboardApp/CircularProgressRing.swift new file mode 100644 index 0000000..ce78c48 --- /dev/null +++ b/helpers/Sources/DashboardApp/CircularProgressRing.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct CircularProgressRing: View { + let progress: Double + let fillColor: Color + let lineWidth: CGFloat + + init(progress: Double, fillColor: Color, lineWidth: CGFloat = 10) { + self.progress = progress + self.fillColor = fillColor + self.lineWidth = lineWidth + } + + var body: some View { + ZStack { + Circle() + .stroke(Color(white: 0.2), lineWidth: lineWidth) + + Circle() + .trim(from: 0, to: CGFloat(min(progress, 1.0))) + .stroke(fillColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + } +} From d60c4af2e8ceb69f7706df237ab5c21949424ba8 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:04:06 +0900 Subject: [PATCH 07/20] feat(dashboard): add StatusHeaderView with progress ring --- .../DashboardApp/StatusHeaderView.swift | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 helpers/Sources/DashboardApp/StatusHeaderView.swift diff --git a/helpers/Sources/DashboardApp/StatusHeaderView.swift b/helpers/Sources/DashboardApp/StatusHeaderView.swift new file mode 100644 index 0000000..b07b4bc --- /dev/null +++ b/helpers/Sources/DashboardApp/StatusHeaderView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import HelperCore + +struct StatusHeaderView: View { + @ObservedObject var vm: DashboardViewModel + + private var statusColor: 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) + } + + private var ringSize: CGFloat { 140 } + + var body: some View { + VStack(spacing: 12) { + HStack { + Circle() + .fill(statusColor) + .frame(width: 10, height: 10) + Text(vm.statusText) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(statusColor) + Spacer() + Text(vm.modeDetail) + .font(.system(size: 12)) + .foregroundColor(.gray) + } + + ZStack { + CircularProgressRing( + progress: vm.sessionProgress.progress, + fillColor: statusColor, + lineWidth: 10 + ) + .frame(width: ringSize, height: ringSize) + + VStack(spacing: 2) { + Text(vm.sessionProgress.remainingFormatted) + .font(.system(size: 32, weight: .ultraLight).monospacedDigit()) + .foregroundColor(Color(white: 0.9)) + Text(vm.sessionSubtitle) + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 12) + } +} From 31f7cc9d2ad2aad30c3b02d3ec7a95f19304a2a9 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:04:23 +0900 Subject: [PATCH 08/20] feat(dashboard): add TimerTabView with daily stats and action buttons --- .../Sources/DashboardApp/TimerTabView.swift | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 helpers/Sources/DashboardApp/TimerTabView.swift diff --git a/helpers/Sources/DashboardApp/TimerTabView.swift b/helpers/Sources/DashboardApp/TimerTabView.swift new file mode 100644 index 0000000..16ae782 --- /dev/null +++ b/helpers/Sources/DashboardApp/TimerTabView.swift @@ -0,0 +1,101 @@ +import SwiftUI +import HelperCore + +struct TimerTabView: View { + @ObservedObject var vm: DashboardViewModel + + private let workColor = Color(red: 0.3, green: 0.8, blue: 0.5) + private let breakColor = Color(red: 0.4, green: 0.7, blue: 1.0) + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + dailyStatsSection + Divider().background(Color(white: 0.2)) + systemInfoSection + Spacer() + actionButtons + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private var dailyStatsSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Daily Statistics") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(Color(white: 0.9)) + + let totals = vm.dailyTotals + let workMin = totals.workSeconds / 60 + let breakMin = totals.breakSeconds / 60 + let totalMin = workMin + breakMin + + HStack { + Text("Work: \(formatMinutes(workMin))") + .font(.system(size: 13)) + .foregroundColor(Color(white: 0.9)) + Spacer() + Text("Break: \(formatMinutes(breakMin))") + .font(.system(size: 13)) + .foregroundColor(breakColor) + } + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color(white: 0.2)) + .frame(height: 6) + if totalMin > 0 { + RoundedRectangle(cornerRadius: 3) + .fill(workColor) + .frame(width: geo.size.width * CGFloat(workMin) / CGFloat(totalMin), height: 6) + } + } + } + .frame(height: 6) + + if totalMin > 0 { + HStack { + Spacer() + Text("\(workMin * 100 / totalMin)%") + .font(.system(size: 11)) + .foregroundColor(.gray) + } + } + } + } + + private var systemInfoSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text("System: \(vm.launchdStatusText)") + .font(.system(size: 12)) + .foregroundColor(.gray) + Text("Idle: \(vm.idleSeconds)s / Threshold: \(vm.config.idleThresholdSec)s") + .font(.system(size: 12)) + .foregroundColor(.gray) + } + } + + private var actionButtons: some View { + HStack(spacing: 12) { + Button("Reset") { vm.resetTimer() } + .buttonStyle(DashboardButtonStyle()) + Button("Force Break") { vm.forceBreak() } + .buttonStyle(DashboardButtonStyle()) + } + } +} + +struct DashboardButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .medium)) + .foregroundColor(Color(white: 0.9)) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(white: configuration.isPressed ? 0.28 : 0.22)) + ) + } +} From 973cdeb81181fc96769e9948baa56353a6cff1b6 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:11:16 +0900 Subject: [PATCH 09/20] chore: bump macOS deployment target to 14 for SwiftUI onKeyPress --- helpers/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helpers/Package.swift b/helpers/Package.swift index 5d3adcc..2f890c5 100644 --- a/helpers/Package.swift +++ b/helpers/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "BreakReminderHelpers", - platforms: [.macOS(.v13)], + platforms: [.macOS(.v14)], targets: [ .target( name: "HelperCore" From c115b48c79a16d0e91cf62a0067ebec62da84107 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:11:45 +0900 Subject: [PATCH 10/20] feat(dashboard): migrate DashboardApp to SwiftUI entry point --- .../DashboardApp/DashboardAppMain.swift | 35 ++ helpers/Sources/DashboardApp/main.swift | 428 ------------------ 2 files changed, 35 insertions(+), 428 deletions(-) create mode 100644 helpers/Sources/DashboardApp/DashboardAppMain.swift delete mode 100644 helpers/Sources/DashboardApp/main.swift diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift new file mode 100644 index 0000000..3f3dea8 --- /dev/null +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -0,0 +1,35 @@ +import SwiftUI +import HelperCore + +@main +struct DashboardAppEntry: App { + @StateObject private var vm = DashboardViewModel() + + var body: some Scene { + Window("Break Reminder", id: "dashboard") { + DashboardContentView(vm: vm) + .frame(width: 360, height: 600) + .background(Color(red: 0.1, green: 0.1, blue: 0.12)) + .onAppear { vm.start() } + .onDisappear { vm.stop() } + .onKeyPress("q") { NSApp.terminate(nil); return .handled } + .onKeyPress("r") { vm.resetTimer(); return .handled } + .onKeyPress("b") { vm.forceBreak(); return .handled } + } + .windowStyle(.hiddenTitleBar) + .windowResizability(.contentSize) + .defaultPosition(.topTrailing) + } +} + +struct DashboardContentView: View { + @ObservedObject var vm: DashboardViewModel + + var body: some View { + VStack(spacing: 0) { + StatusHeaderView(vm: vm) + Divider().background(Color(white: 0.2)) + TimerTabView(vm: vm) + } + } +} diff --git a/helpers/Sources/DashboardApp/main.swift b/helpers/Sources/DashboardApp/main.swift deleted file mode 100644 index 41f2783..0000000 --- a/helpers/Sources/DashboardApp/main.swift +++ /dev/null @@ -1,428 +0,0 @@ -import AppKit -import Foundation -import HelperCore - -// MARK: - System utilities (not testable — platform-specific) - -func loadStateFromFile() -> AppState { - let home = FileManager.default.homeDirectoryForCurrentUser - let path = home.appendingPathComponent(".break-reminder-state") - guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppState() } - return parseState(from: content) -} - -func loadConfigFromFile() -> AppConfig { - let home = FileManager.default.homeDirectoryForCurrentUser - let path = home.appendingPathComponent(".config/break-reminder/config.yaml") - guard let content = try? String(contentsOf: path, encoding: .utf8) else { return AppConfig() } - return parseConfig(from: content) -} - -func launchdStatus() -> String { - let task = Process() - task.launchPath = "/bin/launchctl" - task.arguments = ["list", "com.devlikebear.break-reminder"] - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = pipe - do { - try task.run() - task.waitUntilExit() - return task.terminationStatus == 0 ? "Running (launchd)" : "Not loaded" - } catch { - return "Unknown" - } -} - -func getIdleSeconds() -> Int { - let task = Process() - task.launchPath = "/usr/sbin/ioreg" - task.arguments = ["-c", "IOHIDSystem", "-d", "4"] - let pipe = Pipe() - task.standardOutput = pipe - task.standardError = FileHandle.nullDevice - do { - try task.run() - let data = pipe.fileHandleForReading.readDataToEndOfFile() - task.waitUntilExit() - guard let output = String(data: data, encoding: .utf8) else { return 0 } - for line in output.components(separatedBy: "\n") { - if line.contains("HIDIdleTime") { - let parts = line.components(separatedBy: "=") - if let last = parts.last { - let cleaned = last.trimmingCharacters(in: .whitespacesAndNewlines) - if let ns = Int64(cleaned) { - return Int(ns / 1_000_000_000) - } - } - } - } - } catch {} - return 0 -} - -// MARK: - Circular Progress View - -class CircularProgressView: NSView { - var progress: CGFloat = 0.0 { - didSet { needsDisplay = true } - } - var trackColor: NSColor = NSColor(white: 0.2, alpha: 1.0) - var fillColor: NSColor = NSColor(red: 0.3, green: 0.8, blue: 0.5, alpha: 1.0) - var lineWidth: CGFloat = 10.0 - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - let center = NSPoint(x: bounds.midX, y: bounds.midY) - let radius = min(bounds.width, bounds.height) / 2 - lineWidth / 2 - let startAngle: CGFloat = 90 - let endAngle: CGFloat = startAngle - 360 - - let trackPath = NSBezierPath() - trackPath.appendArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: true) - trackColor.setStroke() - trackPath.lineWidth = lineWidth - trackPath.lineCapStyle = .round - trackPath.stroke() - - if progress > 0 { - let fillEnd = startAngle - (360 * progress) - let fillPath = NSBezierPath() - fillPath.appendArc(withCenter: center, radius: radius, startAngle: startAngle, endAngle: fillEnd, clockwise: true) - fillColor.setStroke() - fillPath.lineWidth = lineWidth - fillPath.lineCapStyle = .round - fillPath.stroke() - } - } -} - -// MARK: - Stat Bar View - -class StatBarView: NSView { - var value: CGFloat = 0.0 { - didSet { needsDisplay = true } - } - var barColor: NSColor = .systemBlue - - override func draw(_ dirtyRect: NSRect) { - super.draw(dirtyRect) - - let bg = NSBezierPath(roundedRect: bounds, xRadius: 3, yRadius: 3) - NSColor(white: 0.2, alpha: 1.0).setFill() - bg.fill() - - if value > 0 { - let fillWidth = bounds.width * min(value, 1.0) - let fillRect = NSRect(x: 0, y: 0, width: fillWidth, height: bounds.height) - let fill = NSBezierPath(roundedRect: fillRect, xRadius: 3, yRadius: 3) - barColor.setFill() - fill.fill() - } - } -} - -// MARK: - Dashboard Window - -class DashboardApp: NSObject, NSApplicationDelegate { - var window: NSWindow! - var timer: Timer? - - var statusDot: NSView! - var statusLabel: NSTextField! - var modeLabel: NSTextField! - var circularProgress: CircularProgressView! - var timeLabel: NSTextField! - var sessionInfoLabel: NSTextField! - - var dailyWorkLabel: NSTextField! - var dailyBreakLabel: NSTextField! - var dailyRatioBar: StatBarView! - var dailyRatioLabel: NSTextField! - - var systemLabel: NSTextField! - var idleLabel: NSTextField! - - var resetButton: NSButton! - var breakButton: NSButton! - - let workColor = NSColor(red: 0.3, green: 0.8, blue: 0.5, alpha: 1.0) - let breakColor = NSColor(red: 0.4, green: 0.7, blue: 1.0, alpha: 1.0) - let bgColor = NSColor(red: 0.1, green: 0.1, blue: 0.12, alpha: 1.0) - let textColor = NSColor(white: 0.9, alpha: 1.0) - let dimColor = NSColor(white: 0.5, alpha: 1.0) - - func applicationDidFinishLaunching(_ notification: Notification) { - setupWindow() - setupUI() - refresh() - - timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - self?.refresh() - } - } - - func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } - - func setupWindow() { - let width: CGFloat = 360 - let height: CGFloat = 520 - let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 800, height: 600) - let x = screenFrame.maxX - width - 20 - let y = screenFrame.maxY - height - 20 - - window = NSWindow( - contentRect: NSRect(x: x, y: y, width: width, height: height), - styleMask: [.titled, .closable, .miniaturizable], - backing: .buffered, - defer: false - ) - window.title = "Break Reminder" - window.backgroundColor = bgColor - window.titlebarAppearsTransparent = true - window.titleVisibility = .hidden - window.isMovableByWindowBackground = true - window.level = .floating - window.makeKeyAndOrderFront(nil) - - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in - switch event.charactersIgnoringModifiers { - case "q": NSApp.terminate(nil); return nil - case "r": self?.resetTimer(); return nil - case "b": self?.forceBreak(); return nil - default: return event - } - } - } - - func setupUI() { - let content = window.contentView! - let controlsTopSpacing: CGFloat = 40 - var y: CGFloat = 470 - - let title = makeLabel("Break Reminder", size: 20, weight: .bold, color: textColor) - title.frame = NSRect(x: 20, y: y, width: 320, height: 28) - content.addSubview(title) - y -= 8 - - let hint = makeLabel("q: quit r: reset b: break", size: 11, weight: .regular, color: dimColor) - hint.frame = NSRect(x: 20, y: y, width: 320, height: 16) - content.addSubview(hint) - y -= 30 - - statusDot = NSView(frame: NSRect(x: 20, y: y + 4, width: 12, height: 12)) - statusDot.wantsLayer = true - statusDot.layer?.cornerRadius = 6 - statusDot.layer?.backgroundColor = workColor.cgColor - content.addSubview(statusDot) - - statusLabel = makeLabel("WORKING", size: 16, weight: .semibold, color: workColor) - statusLabel.frame = NSRect(x: 40, y: y, width: 120, height: 22) - content.addSubview(statusLabel) - - modeLabel = makeLabel("", size: 12, weight: .regular, color: dimColor) - modeLabel.alignment = .right - modeLabel.frame = NSRect(x: 160, y: y + 2, width: 180, height: 18) - content.addSubview(modeLabel) - y -= 20 - - content.addSubview(makeDivider(y: y)) - y -= 16 - - let ringSize: CGFloat = 160 - let ringX = (360 - ringSize) / 2 - circularProgress = CircularProgressView(frame: NSRect(x: ringX, y: y - ringSize, width: ringSize, height: ringSize)) - circularProgress.fillColor = workColor - content.addSubview(circularProgress) - - timeLabel = makeLabel("00:00", size: 36, weight: .ultraLight, color: textColor) - timeLabel.alignment = .center - timeLabel.font = NSFont.monospacedDigitSystemFont(ofSize: 36, weight: .ultraLight) - timeLabel.frame = NSRect(x: ringX, y: y - ringSize / 2 - 12, width: ringSize, height: 44) - content.addSubview(timeLabel) - - sessionInfoLabel = makeLabel("", size: 12, weight: .regular, color: dimColor) - sessionInfoLabel.alignment = .center - sessionInfoLabel.frame = NSRect(x: ringX, y: y - ringSize / 2 - 32, width: ringSize, height: 16) - content.addSubview(sessionInfoLabel) - y -= ringSize + 20 - - content.addSubview(makeDivider(y: y)) - y -= 20 - - let statsTitle = makeLabel("Daily Statistics", size: 14, weight: .semibold, color: textColor) - statsTitle.frame = NSRect(x: 20, y: y, width: 320, height: 20) - content.addSubview(statsTitle) - y -= 24 - - dailyWorkLabel = makeLabel("Work: 0 min", size: 13, weight: .regular, color: textColor) - dailyWorkLabel.frame = NSRect(x: 20, y: y, width: 150, height: 18) - content.addSubview(dailyWorkLabel) - - dailyBreakLabel = makeLabel("Break: 0 min", size: 13, weight: .regular, color: breakColor) - dailyBreakLabel.alignment = .right - dailyBreakLabel.frame = NSRect(x: 190, y: y, width: 150, height: 18) - content.addSubview(dailyBreakLabel) - y -= 22 - - dailyRatioBar = StatBarView(frame: NSRect(x: 20, y: y, width: 260, height: 8)) - dailyRatioBar.barColor = workColor - content.addSubview(dailyRatioBar) - - dailyRatioLabel = makeLabel("", size: 11, weight: .regular, color: dimColor) - dailyRatioLabel.frame = NSRect(x: 288, y: y - 3, width: 52, height: 14) - content.addSubview(dailyRatioLabel) - y -= 24 - - content.addSubview(makeDivider(y: y)) - y -= 20 - - systemLabel = makeLabel("System: ...", size: 12, weight: .regular, color: dimColor) - systemLabel.frame = NSRect(x: 20, y: y, width: 320, height: 16) - content.addSubview(systemLabel) - y -= 18 - - idleLabel = makeLabel("Idle: 0s", size: 12, weight: .regular, color: dimColor) - idleLabel.frame = NSRect(x: 20, y: y, width: 320, height: 16) - content.addSubview(idleLabel) - y -= controlsTopSpacing - - resetButton = makeStyledButton(title: "Reset", action: #selector(resetTimer)) - resetButton.frame = NSRect(x: 40, y: y, width: 120, height: 32) - content.addSubview(resetButton) - - breakButton = makeStyledButton(title: "Force Break", action: #selector(forceBreak)) - breakButton.frame = NSRect(x: 200, y: y, width: 120, height: 32) - content.addSubview(breakButton) - } - - func refresh() { - let state = loadStateFromFile() - let config = loadConfigFromFile() - let now = Int64(Date().timeIntervalSince1970) - let idleSec = getIdleSeconds() - - let isWork = state.mode == "work" - let isPaused = state.paused - let activeColor = isWork ? workColor : breakColor - - statusDot.layer?.backgroundColor = (isPaused ? NSColor.systemYellow : activeColor).cgColor - if isPaused { - statusLabel.stringValue = "PAUSED (\(isWork ? "WORK" : "BREAK"))" - statusLabel.textColor = NSColor.systemYellow - } else { - statusLabel.stringValue = isWork ? "WORKING" : "ON BREAK" - statusLabel.textColor = activeColor - } - - let sp: SessionProgress - if isWork { - sp = workProgress(state: state, config: config, now: now) - modeLabel.stringValue = "\(sp.elapsedSec / 60) / \(config.workDurationMin) min" - sessionInfoLabel.stringValue = isPaused ? "paused" : "until break" - } else { - sp = breakProgress(state: state, config: config, now: now) - modeLabel.stringValue = "\(sp.elapsedSec / 60) / \(config.breakDurationMin) min" - sessionInfoLabel.stringValue = isPaused ? "paused" : "until work" - } - - circularProgress.progress = CGFloat(sp.progress) - circularProgress.fillColor = isPaused ? NSColor.systemYellow : activeColor - circularProgress.needsDisplay = true - timeLabel.stringValue = sp.remainingFormatted - - let totals = liveDailyTotals(state: state, config: config, now: now) - let workMin = totals.workSeconds / 60 - let breakMin = totals.breakSeconds / 60 - dailyWorkLabel.stringValue = "Work: \(formatMinutes(workMin))" - dailyBreakLabel.stringValue = "Break: \(formatMinutes(breakMin))" - - let total = workMin + breakMin - if total > 0 { - let ratio = CGFloat(workMin) / CGFloat(total) - dailyRatioBar.value = ratio - dailyRatioBar.needsDisplay = true - dailyRatioLabel.stringValue = "\(Int(ratio * 100))%" - } else { - dailyRatioBar.value = 0 - dailyRatioBar.needsDisplay = true - dailyRatioLabel.stringValue = "-" - } - - systemLabel.stringValue = "System: \(launchdStatus())" - idleLabel.stringValue = "Idle: \(idleSec)s / Threshold: \(config.idleThresholdSec)s" - } - - @objc func resetTimer() { - let home = FileManager.default.homeDirectoryForCurrentUser - let path = home.appendingPathComponent(".break-reminder-state") - let priorState = loadStateFromFile() - let config = loadConfigFromFile() - let now = Int64(Date().timeIntervalSince1970) - let totals = liveDailyTotals(state: priorState, config: config, now: now) - var s = AppState() - s.lastCheck = now - s.todayWorkSeconds = totals.workSeconds - s.todayBreakSeconds = totals.breakSeconds - s.lastUpdateDate = totals.date - try? serializeState(s).data(using: .utf8)?.write(to: path, options: .atomic) - refresh() - } - - @objc func forceBreak() { - let home = FileManager.default.homeDirectoryForCurrentUser - let path = home.appendingPathComponent(".break-reminder-state") - let state = loadStateFromFile() - let config = loadConfigFromFile() - let now = Int64(Date().timeIntervalSince1970) - let totals = liveDailyTotals(state: state, config: config, now: now) - var s = AppState() - s.mode = "break" - s.lastCheck = now - s.breakStart = now - s.todayWorkSeconds = totals.workSeconds - s.todayBreakSeconds = totals.breakSeconds - s.lastUpdateDate = totals.date - try? serializeState(s).data(using: .utf8)?.write(to: path, options: .atomic) - refresh() - } - - func makeLabel(_ text: String, size: CGFloat, weight: NSFont.Weight, color: NSColor) -> NSTextField { - let label = NSTextField(labelWithString: text) - label.font = NSFont.systemFont(ofSize: size, weight: weight) - label.textColor = color - label.drawsBackground = false - label.isBezeled = false - label.isEditable = false - return label - } - - func makeStyledButton(title: String, action: Selector) -> NSButton { - let button = NSButton(title: title, target: self, action: action) - button.bezelStyle = .rounded - button.isBordered = false - button.wantsLayer = true - button.layer?.backgroundColor = NSColor(white: 0.22, alpha: 1.0).cgColor - button.layer?.cornerRadius = 8 - button.contentTintColor = NSColor(white: 0.9, alpha: 1.0) - button.font = NSFont.systemFont(ofSize: 14, weight: .medium) - return button - } - - func makeDivider(y: CGFloat) -> NSView { - let div = NSView(frame: NSRect(x: 20, y: y, width: 320, height: 1)) - div.wantsLayer = true - div.layer?.backgroundColor = NSColor(white: 0.2, alpha: 1.0).cgColor - return div - } -} - -// MARK: - Main - -let app = NSApplication.shared -let delegate = DashboardApp() -app.delegate = delegate -app.setActivationPolicy(.regular) -app.activate(ignoringOtherApps: true) -app.run() From 6aad6fddceacbee4e11483fb7c48e8469235cadc Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:15:51 +0900 Subject: [PATCH 11/20] refactor(dashboard): add explicit AppKit import for NSApp --- helpers/Sources/DashboardApp/DashboardAppMain.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 3f3dea8..5e44734 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -1,4 +1,5 @@ import SwiftUI +import AppKit import HelperCore @main From bb63814feac42d509f9095a84537e5af6525ba77 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Fri, 17 Apr 2026 21:17:37 +0900 Subject: [PATCH 12/20] feat(dashboard): configure floating window with AppKit bridge --- .../DashboardApp/DashboardAppMain.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 5e44734..ae4d7d3 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -5,13 +5,17 @@ import HelperCore @main struct DashboardAppEntry: App { @StateObject private var vm = DashboardViewModel() + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { Window("Break Reminder", id: "dashboard") { DashboardContentView(vm: vm) .frame(width: 360, height: 600) .background(Color(red: 0.1, green: 0.1, blue: 0.12)) - .onAppear { vm.start() } + .onAppear { + vm.start() + configureWindow() + } .onDisappear { vm.stop() } .onKeyPress("q") { NSApp.terminate(nil); return .handled } .onKeyPress("r") { vm.resetTimer(); return .handled } @@ -21,6 +25,20 @@ struct DashboardAppEntry: App { .windowResizability(.contentSize) .defaultPosition(.topTrailing) } + + private func configureWindow() { + DispatchQueue.main.async { + guard let window = NSApp.windows.first(where: { $0.title == "Break Reminder" }) else { return } + window.level = .floating + window.isMovableByWindowBackground = true + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + } + } +} + +class AppDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } } struct DashboardContentView: View { From 2f827d287765053c54ea613de6b85f4a66a32406 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:05:16 +0900 Subject: [PATCH 13/20] fix(dashboard): use NSEvent monitor for keyboard shortcuts Replace SwiftUI onKeyPress (requires focus) with NSEvent.addLocalMonitorForEvents so q/r/b shortcuts work reliably regardless of keyboard focus state. --- .../Sources/DashboardApp/DashboardAppMain.swift | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index ae4d7d3..075a42d 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -15,11 +15,9 @@ struct DashboardAppEntry: App { .onAppear { vm.start() configureWindow() + installKeyMonitor() } .onDisappear { vm.stop() } - .onKeyPress("q") { NSApp.terminate(nil); return .handled } - .onKeyPress("r") { vm.resetTimer(); return .handled } - .onKeyPress("b") { vm.forceBreak(); return .handled } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -35,6 +33,17 @@ struct DashboardAppEntry: App { window.titleVisibility = .hidden } } + + private func installKeyMonitor() { + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + switch event.charactersIgnoringModifiers { + case "q": NSApp.terminate(nil); return nil + case "r": vm.resetTimer(); return nil + case "b": vm.forceBreak(); return nil + default: return event + } + } + } } class AppDelegate: NSObject, NSApplicationDelegate { From cf1b474c5d5553895cbe448364efedea20c5d48f Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:06:27 +0900 Subject: [PATCH 14/20] feat(dashboard): add keyboard shortcut hint to timer tab --- helpers/Sources/DashboardApp/TimerTabView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/helpers/Sources/DashboardApp/TimerTabView.swift b/helpers/Sources/DashboardApp/TimerTabView.swift index 16ae782..d7555a4 100644 --- a/helpers/Sources/DashboardApp/TimerTabView.swift +++ b/helpers/Sources/DashboardApp/TimerTabView.swift @@ -14,6 +14,7 @@ struct TimerTabView: View { systemInfoSection Spacer() actionButtons + shortcutHint } .padding(.horizontal, 20) .padding(.vertical, 12) @@ -84,6 +85,16 @@ struct TimerTabView: View { .buttonStyle(DashboardButtonStyle()) } } + + private var shortcutHint: some View { + HStack { + Spacer() + Text("q: quit r: reset b: break") + .font(.system(size: 10)) + .foregroundColor(.gray) + Spacer() + } + } } struct DashboardButtonStyle: ButtonStyle { From e5189bc13d75008dcbb437c6b909313ce1b81ebe Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:10:43 +0900 Subject: [PATCH 15/20] fix(dashboard): use Cmd+R/Cmd+B shortcuts via SwiftUI commands --- .../DashboardApp/DashboardAppMain.swift | 20 ++++++++----------- .../Sources/DashboardApp/TimerTabView.swift | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 075a42d..799ae34 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -15,13 +15,20 @@ struct DashboardAppEntry: App { .onAppear { vm.start() configureWindow() - installKeyMonitor() } .onDisappear { vm.stop() } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) .defaultPosition(.topTrailing) + .commands { + CommandMenu("Timer") { + Button("Reset") { vm.resetTimer() } + .keyboardShortcut("r", modifiers: [.command]) + Button("Force Break") { vm.forceBreak() } + .keyboardShortcut("b", modifiers: [.command]) + } + } } private func configureWindow() { @@ -33,17 +40,6 @@ struct DashboardAppEntry: App { window.titleVisibility = .hidden } } - - private func installKeyMonitor() { - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - switch event.charactersIgnoringModifiers { - case "q": NSApp.terminate(nil); return nil - case "r": vm.resetTimer(); return nil - case "b": vm.forceBreak(); return nil - default: return event - } - } - } } class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/helpers/Sources/DashboardApp/TimerTabView.swift b/helpers/Sources/DashboardApp/TimerTabView.swift index d7555a4..8cace10 100644 --- a/helpers/Sources/DashboardApp/TimerTabView.swift +++ b/helpers/Sources/DashboardApp/TimerTabView.swift @@ -89,7 +89,7 @@ struct TimerTabView: View { private var shortcutHint: some View { HStack { Spacer() - Text("q: quit r: reset b: break") + Text("⌘Q: quit ⌘R: reset ⌘B: break") .font(.system(size: 10)) .foregroundColor(.gray) Spacer() From e067ffaae5b180d69202bb9a1ed467fdc1e52f1e Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:15:06 +0900 Subject: [PATCH 16/20] feat(dashboard): visual feedback for window focus state Track key-window state via NSWindow notifications and dim content to 0.55 opacity when unfocused. Tap anywhere on the surface to force NSApp.activate + makeKeyAndOrderFront, ensuring Cmd+R/Cmd+B shortcuts reliably fire. configureWindow() also calls activate+makeKeyAndOrderFront on initial setup so the window grabs focus immediately on launch. --- .../DashboardApp/DashboardAppMain.swift | 18 ++++++++++++++++++ .../DashboardApp/DashboardViewModel.swift | 1 + 2 files changed, 19 insertions(+) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 799ae34..67494b0 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -17,6 +17,12 @@ struct DashboardAppEntry: App { configureWindow() } .onDisappear { vm.stop() } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + vm.isWindowActive = true + } + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { _ in + vm.isWindowActive = false + } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -38,6 +44,9 @@ struct DashboardAppEntry: App { window.isMovableByWindowBackground = true window.titlebarAppearsTransparent = true window.titleVisibility = .hidden + // Initial focus + NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) } } } @@ -55,5 +64,14 @@ struct DashboardContentView: View { Divider().background(Color(white: 0.2)) TimerTabView(vm: vm) } + .opacity(vm.isWindowActive ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: vm.isWindowActive) + .contentShape(Rectangle()) + .onTapGesture { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.title == "Break Reminder" }) { + window.makeKeyAndOrderFront(nil) + } + } } } diff --git a/helpers/Sources/DashboardApp/DashboardViewModel.swift b/helpers/Sources/DashboardApp/DashboardViewModel.swift index c837e04..9f8a8f3 100644 --- a/helpers/Sources/DashboardApp/DashboardViewModel.swift +++ b/helpers/Sources/DashboardApp/DashboardViewModel.swift @@ -8,6 +8,7 @@ final class DashboardViewModel: ObservableObject { @Published var config: AppConfig = AppConfig() @Published var idleSeconds: Int = 0 @Published var launchdStatusText: String = "Unknown" + @Published var isWindowActive: Bool = true private var timer: Timer? From e2acb8da0e2de71d0a033a7299e85225186b2408 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:17:19 +0900 Subject: [PATCH 17/20] fix(dashboard): restore single-key shortcuts via NSEvent monitor --- .../DashboardApp/DashboardAppMain.swift | 21 +++++++++++-------- .../Sources/DashboardApp/TimerTabView.swift | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 67494b0..4c03bff 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -15,6 +15,7 @@ struct DashboardAppEntry: App { .onAppear { vm.start() configureWindow() + installKeyMonitor() } .onDisappear { vm.stop() } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in @@ -27,14 +28,6 @@ struct DashboardAppEntry: App { .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) .defaultPosition(.topTrailing) - .commands { - CommandMenu("Timer") { - Button("Reset") { vm.resetTimer() } - .keyboardShortcut("r", modifiers: [.command]) - Button("Force Break") { vm.forceBreak() } - .keyboardShortcut("b", modifiers: [.command]) - } - } } private func configureWindow() { @@ -44,11 +37,21 @@ struct DashboardAppEntry: App { window.isMovableByWindowBackground = true window.titlebarAppearsTransparent = true window.titleVisibility = .hidden - // Initial focus NSApp.activate(ignoringOtherApps: true) window.makeKeyAndOrderFront(nil) } } + + private func installKeyMonitor() { + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + switch event.charactersIgnoringModifiers { + case "q": NSApp.terminate(nil); return nil + case "r": vm.resetTimer(); return nil + case "b": vm.forceBreak(); return nil + default: return event + } + } + } } class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/helpers/Sources/DashboardApp/TimerTabView.swift b/helpers/Sources/DashboardApp/TimerTabView.swift index 8cace10..d7555a4 100644 --- a/helpers/Sources/DashboardApp/TimerTabView.swift +++ b/helpers/Sources/DashboardApp/TimerTabView.swift @@ -89,7 +89,7 @@ struct TimerTabView: View { private var shortcutHint: some View { HStack { Spacer() - Text("⌘Q: quit ⌘R: reset ⌘B: break") + Text("q: quit r: reset b: break") .font(.system(size: 10)) .foregroundColor(.gray) Spacer() From cb58f7708cb9e82592ed0e3e1345b2b0c86fd8aa Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:22:02 +0900 Subject: [PATCH 18/20] fix(dashboard): use SwiftUI focus + controlActiveState for reliable shortcuts Replace NSEvent local monitor + NotificationCenter key-window observers with @FocusState, .onKeyPress, and @Environment(\.controlActiveState). Removes isWindowActive from the view model; opacity and keyboard focus are now driven entirely from SwiftUI's environment. --- .../DashboardApp/DashboardAppMain.swift | 46 +++++++++++-------- .../DashboardApp/DashboardViewModel.swift | 1 - 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 4c03bff..2247353 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -15,15 +15,8 @@ struct DashboardAppEntry: App { .onAppear { vm.start() configureWindow() - installKeyMonitor() } .onDisappear { vm.stop() } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in - vm.isWindowActive = true - } - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { _ in - vm.isWindowActive = false - } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -41,25 +34,23 @@ struct DashboardAppEntry: App { window.makeKeyAndOrderFront(nil) } } - - private func installKeyMonitor() { - NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in - switch event.charactersIgnoringModifiers { - case "q": NSApp.terminate(nil); return nil - case "r": vm.resetTimer(); return nil - case "b": vm.forceBreak(); return nil - default: return event - } - } - } } class AppDelegate: NSObject, NSApplicationDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } } struct DashboardContentView: View { @ObservedObject var vm: DashboardViewModel + @FocusState private var isFocused: Bool + @Environment(\.controlActiveState) private var controlActiveState + + private var isWindowActive: Bool { + controlActiveState == .key || controlActiveState == .active + } var body: some View { VStack(spacing: 0) { @@ -67,14 +58,29 @@ struct DashboardContentView: View { Divider().background(Color(white: 0.2)) TimerTabView(vm: vm) } - .opacity(vm.isWindowActive ? 1.0 : 0.55) - .animation(.easeInOut(duration: 0.2), value: vm.isWindowActive) + .opacity(isWindowActive ? 1.0 : 0.55) + .animation(.easeInOut(duration: 0.2), value: isWindowActive) + .focusable() + .focused($isFocused) + .focusEffectDisabled() + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + isFocused = true + } + } + .onChange(of: isWindowActive) { _, newValue in + if newValue { isFocused = true } + } + .onKeyPress("q") { NSApp.terminate(nil); return .handled } + .onKeyPress("r") { vm.resetTimer(); return .handled } + .onKeyPress("b") { vm.forceBreak(); return .handled } .contentShape(Rectangle()) .onTapGesture { NSApp.activate(ignoringOtherApps: true) if let window = NSApp.windows.first(where: { $0.title == "Break Reminder" }) { window.makeKeyAndOrderFront(nil) } + isFocused = true } } } diff --git a/helpers/Sources/DashboardApp/DashboardViewModel.swift b/helpers/Sources/DashboardApp/DashboardViewModel.swift index 9f8a8f3..c837e04 100644 --- a/helpers/Sources/DashboardApp/DashboardViewModel.swift +++ b/helpers/Sources/DashboardApp/DashboardViewModel.swift @@ -8,7 +8,6 @@ final class DashboardViewModel: ObservableObject { @Published var config: AppConfig = AppConfig() @Published var idleSeconds: Int = 0 @Published var launchdStatusText: String = "Unknown" - @Published var isWindowActive: Bool = true private var timer: Timer? From 8ad7d382c7d44a28a7ac6f63af17f04d7ed11818 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:26:29 +0900 Subject: [PATCH 19/20] fix(dashboard): use physical keyCode for IME-independent shortcuts --- .../DashboardApp/DashboardAppMain.swift | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/helpers/Sources/DashboardApp/DashboardAppMain.swift b/helpers/Sources/DashboardApp/DashboardAppMain.swift index 2247353..466e844 100644 --- a/helpers/Sources/DashboardApp/DashboardAppMain.swift +++ b/helpers/Sources/DashboardApp/DashboardAppMain.swift @@ -67,13 +67,11 @@ struct DashboardContentView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { isFocused = true } + installKeyMonitor() } .onChange(of: isWindowActive) { _, newValue in if newValue { isFocused = true } } - .onKeyPress("q") { NSApp.terminate(nil); return .handled } - .onKeyPress("r") { vm.resetTimer(); return .handled } - .onKeyPress("b") { vm.forceBreak(); return .handled } .contentShape(Rectangle()) .onTapGesture { NSApp.activate(ignoringOtherApps: true) @@ -83,4 +81,28 @@ struct DashboardContentView: View { isFocused = true } } + + private func installKeyMonitor() { + NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + // Only handle plain keys (no modifiers like Cmd/Ctrl/Opt) + let relevantFlags: NSEvent.ModifierFlags = [.command, .control, .option] + if !event.modifierFlags.intersection(relevantFlags).isEmpty { + return event + } + + switch event.keyCode { + case 12: // Q (physical key) + NSApp.terminate(nil) + return nil + case 15: // R + vm.resetTimer() + return nil + case 11: // B + vm.forceBreak() + return nil + default: + return event + } + } + } } From 752859db49690a17ce3c5545f8dd1e639da0e220 Mon Sep 17 00:00:00 2001 From: devlikebear Date: Sat, 18 Apr 2026 10:43:58 +0900 Subject: [PATCH 20/20] chore: gitignore .claude local settings directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3d474fc..b6ddf19 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ helpers/.build/ # Superpowers brainstorm sessions .superpowers/ + +# Claude Code local settings +.claude/