Skip to content

Latest commit

 

History

History
86 lines (54 loc) · 13.7 KB

File metadata and controls

86 lines (54 loc) · 13.7 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

What this is

Focus Sessions is a native macOS (SwiftUI + AppKit) productivity app for tracking work in 15-minute blocks. A circular timer counts down 15 minutes; when it completes, the user attributes the block to one or more categories/projects, optionally adds notes, and the session is saved. The app then shows analytics (category breakdown, most productive hour, daily/weekly stats) and can export to CSV/JSON.

It's a Git repository (branch main) with no package manager — a plain Xcode project.

Targets (macOS + iOS)

The Xcode project (FocusSessions/FocusSessions.xcodeproj) has two app targets sharing one source tree:

  • FocusSessions (macOS 13+) — the original app. Entry: FocusSessionsApp.swiftContentView.swift. Both are gated #if os(macOS) (they use AppKit window resizing / compact mode that don't exist on iOS).
  • FocusSessionsiOS (iOS 17+, iPhone/iPad) — sources in FocusSessions/FocusSessionsiOS/: FocusSessionsiOSApp.swift (@main gated #if os(iOS)), iOSRootView.swift (a TabView: Timer / Plan / Analytics / Settings), iOSTimerView.swift (phone-native timer + completion sheet). It reuses the entire shared model/viewmodel/AI/persistence layer and the cross-platform views (DayPlanView, AnalyticsView, PlanChatView, SettingsView, SessionHistoryView).

Cross-platform code lives behind Models 2/Platform.swift (PlatformColor, Color.platform*Background, Platform.open, Platform.playChime) and #if os(macOS) gates for the rest. Build commands:

# macOS
xcodebuild -project FocusSessions/FocusSessions.xcodeproj -scheme FocusSessions -configuration Debug build
# iOS (compile-check; no simulator runtimes installed here)
xcodebuild -project FocusSessions/FocusSessions.xcodeproj -target FocusSessionsiOS -sdk iphonesimulator -configuration Debug build CODE_SIGNING_ALLOWED=NO

An Android version (Kotlin/Compose, a from-scratch rewrite) is planned in docs/ANDROID_PORT.md. A full manual QA checklist is in docs/QA_CHECKLIST.md.

Critical: which copy of the code is real

The repo contains several parallel, mostly-redundant implementations. Only one is canonical. Before editing, confirm you're touching the right files.

  • Canonical app = the Xcode project at FocusSessions/FocusSessions.xcodeproj. Its sources live under FocusSessions/FocusSessions/ with directory names that have a trailing 2 (Models 2/, Views 2/, ViewModels 2/). The pbxproj target compiles ContentView.swift, FocusSessionsApp.swift, and the files inside those 2 directories. Edit these.
  • Single-file prototypes at repo rootFocusSessionsNative.swift (~2000 lines, WebKit-based), FocusSessionsWebApp.swift, FocusSessionsApp.swift (root, ~336 lines), and index.html — are standalone earlier versions, not part of the Xcode build. Don't edit these expecting the app to change.
  • Backup/dead files — anything named *_backup.swift, *_broken.swift, *.old.swift, or SessionEditView 2.swift is stale. Ignore unless explicitly asked.
  • Newest/ and Saved/ hold archived built .app bundles and CSV exports, not source. build/ and FocusSessions/build/, FocusSessions/DerivedData/ are compiler output.

Building & running

Preferred — build the canonical app via Xcode:

xcodebuild -project FocusSessions/FocusSessions.xcodeproj -scheme FocusSessions -configuration Debug build

Or open FocusSessions/FocusSessions.xcodeproj in Xcode and run (⌘R). Target: macOS 13+, bundle id com.example.FocusSessions. The app is sandboxed (see FocusSessions/FocusSessions/FocusSessions.entitlements) with user-selected and Downloads read-write access for CSV/JSON export.

Root shell scripts are mostly stale — do not trust them for the canonical app. build_app.sh references swiftc against paths like FocusSessions/FocusSessions/Models/... that no longer exist (the real dirs are Models 2/ etc.), so it will fail. The other scripts (build_native_app.sh, build_focus_app.sh, create_simple_app.sh) compile the standalone root prototypes, not the Xcode project.

There is no test target, no linter config, and no test command in this repo.

Architecture (canonical app)

SwiftUI MVVM. FocusSessionsAppContentView, which owns three @StateObjects wired through the whole view tree:

  • TimerViewModel (ViewModels 2/TimerViewModel.swift) — the 15-min countdown. Hardcoded 900-second session; drives a Timer, exposes formattedTime, progress, and minuteProgress (per-minute fill for the 15-segment ring). Sets sessionComplete to trigger the attribution overlay. Persists the last-selected categories.
  • SessionManager (ViewModels 2/SessionManager.swift) — owns [Session], all analytics math (sessionsForTimeRange, categoryBreakdown, mostProductiveHour, totalMinutes), and CSV export. Category minutes are computed proportionally: each session's duration is split across its categories by data.count / totalCount.
  • WorkFolderManager (same file) — the folder → project → task hierarchy shown in the editor, plus the configurable "Procrastinating" category and default tasks.

Data model (Models 2/Session.swift): a Session has a categories: [String: CategoryData] map (category id → {color, count}), a totalCount, and a duration (default 15). CategoryData colors are hex strings. Custom Codable init(from:) decoders use decodeIfPresent for backward compatibility — preserve this pattern when adding fields so older persisted data still loads.

Persistence: everything is stored in UserDefaults as JSON, not files or a database. Keys: focusSessions, workFoldersData, procrastinatingSettings, defaultTasks, lastSelectedCategories ([categoryKey: count]), lastSelectedFolder. There is no migration framework — backward compatibility relies on decodeIfPresent defaults in the model decoders (Session and CategoryData now decode every field defensively, so a single missing/renamed key can't fail the whole [Session] decode). SessionManager/WorkFolderManager set a loadFailed flag when stored data exists but fails to decode, and refuse to save while it's set — this prevents a transient decode failure from being overwritten into permanent loss. Use SessionManager.replaceAllSessions(_:) for intentional bulk replacement (it clears the guard); never removeAll() + saveSessions() before validating new data.

Color encoding: Color is not Codable, so the model defines Color(hex:) / toHex() extensions (in Session.swift) and every Codable type that holds a color encodes/decodes it as a hex string. Reuse these helpers rather than inventing new color serialization.

Categories are self-describing. CategoryData carries an optional categoryName captured at logging time (category keys are task UUIDs that may be renamed/deleted later). Resolve display names via CategoryData.displayName(fallback:key:) — stored name first, then a live lookup, then the raw key — rather than looking up the hardcoded sessionManager.categories list (which only holds two legacy entries and won't match real UUID keys).

Export: the only real export path is CSV, via NSSavePanel in Views 2/ImportExportView.swift (generateCSVContent) and SessionManager.exportToCSV. Both build category columns/minutes from each session's real duration and resolved names, and escape every field through the shared CSV.escape/CSV.row helpers in Models 2/AnalyticsExport.swift. (The old unused AnalyticsExport JSON struct was removed; that file now only hosts the CSV helper.)

Views (Views 2/): AnalyticsView, SessionHistoryView, CategoryEditorView, WorkFolderEditorView, TimerView, InteractiveTimelineView, ManualSessionView/NewManualSessionView, ImportExportView, SessionEditView. The session-complete attribution overlay (SimpleSessionCompleteView) lives inline in ContentView.swift. ContentView also implements a compact/expanded window mode (resizing the AppKit window directly).

AI day-planner & DND (added 2026-06-25)

Two newer subsystems, both wired through ContentView's @StateObjects (DayPlanManager, DNDManager):

  • AI day-planner. A time-blocked schedule (Models 2/DayPlan.swift: DayPlan[PlanBlock], each block = start time + duration + folder/project/task + goal + status). Blocks can be task-backed (folder/project/task from the hierarchy) or ad-hoc (a call, meeting, break, lunch — empty folder/project, the description in taskName); the prompt explicitly allows non-task items so e.g. "9–9:15 call" becomes a real block. DayPlanManager stores one plan per day keyed by start-of-day (dayPlansData), so a plan for tomorrow or any future date survives until that day passes; past days are pruned on load. It exposes selectedDate + a date-aware plan computed property and the same loadFailed guard as SessionManager. The Replan sheet and DayPlanView both have a date picker (today … +10 years) — when planning today the AI schedules from "now" forward; for a future date it plans the full day. Planning goes through a provider-agnostic AIPlanner protocol (Models 2/AIPlanner.swift), all over raw URLSession (no official SDKs for these providers). Shared PlanPrompt (system prompt, buildPrompt, parsePlan → bare-JSON-to-DayPlan) and PlanHTTP (send + status check) are used by every provider; each provider only differs in request/response shape. Shipped providers: ClaudePlanner (Anthropic Messages API, claude-opus-4-8, x-api-key/anthropic-version headers) and DeepSeekPlanner (OpenAI-compatible Chat Completions at api.deepseek.com, Authorization: Bearer, deepseek-chat or deepseek-reasoner). The AIProvider enum is the registry: it owns each provider's display name, per-provider Keychain account (apiKey-<provider>), key-page URL, and two factoriesmakePlanner() (one-shot) and makeChatPlanner() (streaming chat); AIProvider.selected reads the chosen provider from UserDefaults (aiProvider). To add a provider: add an AIProvider case + both factory branches + conformances to AIPlanner and AIChatPlanner.

The primary UI is a streaming chat (Views 2/PlanChatView.swift, opened from DayPlanView's Replan/Make-a-plan button). The user describes fixed commitments + tasks; the assistant streams a reply (token-by-token) explaining how it scheduled things and embeds the full schedule as a fenced plan JSON block. `Models 2/AIChat.swift` holds the `AIChatPlanner` protocol, the SSE streaming impls for both providers (`ClaudeChatPlanner` parses `content_block_delta`/`text_delta`; `DeepSeekChatPlanner` parses `choices[].delta.content` — both via the shared `SSE.stream` helper over `URLSession.bytes(for:)`), the `ChatPrompt` (system prompt + `extractPlan`/`displayText` to split prose from the plan block), and ChatMessage. Conversation is multi-turn and the model can target any day from natural language ("tomorrow") via a date field in the plan JSON. "Save plan" commits the latest extracted plan to that date. Chats are persisted per-day by ChatStore (UserDefaults key planChats) and reload when you revisit a day. ReplanView.swift (the older one-shot edit/generate sheet) and AIPlanner.swift's makePlan still exist/compile but are no longer wired into the main flow.

Plan ↔ timeline. The day plan and logged sessions are still separate stores (dayPlansData vs focusSessions — a plan block is never a Session), but the Today timeline in AnalyticsView now overlays the day's planned blocks as a faint dashed "intended" layer (PlannedBlockBar) behind the solid real-session blocks, so plan-vs-actual reads in one view (AnalyticsView/TimelineView take a DayPlanManager). The plan date picker allows past dates (plans pruned only after ~90 days). Session History (SessionHistoryView, previously built but unreachable) is now opened from a History button in the ContentView header.

  • DND. Models 2/DNDManager.swift. macOS exposes no public API to toggle system Focus/DND for third-party apps (sandbox or not), so DND is (1) in-app: while a block is active, TimerViewModel (which holds a weak var dndManager) calls beginFocus()/endFocus() on start/complete/reset, and playNotificationSound() checks shouldSuppressAppSounds; plus (2) an optional user-named Shortcut fired via the shortcuts://run-shortcut URL scheme to set a real system Focus. Settings live in @AppStorage and Views 2/SettingsView.swift.

Secrets: AI API keys are the one thing stored in the macOS Keychain (Models 2/Keychain.swift), not UserDefaults — one key per provider, keyed by AIProvider.keychainAccount (apiKey-claude, apiKey-deepseek). Networking requires the app be unsandboxedFocusSessions.entitlements has app-sandbox removed and network.client added; this is a direct-distribution app, not Mac App Store.

Debug seed data: SessionManager.addTestSessions() is wrapped in #if DEBUG and seeds sample sessions for "today" on launch when none exist. Debug builds will show fake data; release builds won't.

Conventions

  • Dark mode only (preferredColorScheme(.dark), black background); the UI assumes it.
  • The 15-minute block length is hardcoded in several places (900 seconds in TimerViewModel, "15-minute blocks" copy in ContentView). Changing block length means touching all of them, not one constant.
  • When adding a field to a persisted model, add it to CodingKeys, the memberwise init, the init(from:) decoder (with a decodeIfPresent default), and encode(to:) — all four, or persisted data breaks.