This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
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.
The Xcode project (FocusSessions/FocusSessions.xcodeproj) has two app targets sharing one source tree:
FocusSessions(macOS 13+) — the original app. Entry:FocusSessionsApp.swift→ContentView.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 inFocusSessions/FocusSessionsiOS/:FocusSessionsiOSApp.swift(@maingated#if os(iOS)),iOSRootView.swift(aTabView: 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=NOAn 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.
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 underFocusSessions/FocusSessions/with directory names that have a trailing2(Models 2/,Views 2/,ViewModels 2/). Thepbxprojtarget compilesContentView.swift,FocusSessionsApp.swift, and the files inside those2directories. Edit these. - Single-file prototypes at repo root —
FocusSessionsNative.swift(~2000 lines, WebKit-based),FocusSessionsWebApp.swift,FocusSessionsApp.swift(root, ~336 lines), andindex.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, orSessionEditView 2.swiftis stale. Ignore unless explicitly asked. Newest/andSaved/hold archived built.appbundles and CSV exports, not source.build/andFocusSessions/build/,FocusSessions/DerivedData/are compiler output.
Preferred — build the canonical app via Xcode:
xcodebuild -project FocusSessions/FocusSessions.xcodeproj -scheme FocusSessions -configuration Debug buildOr 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.
SwiftUI MVVM. FocusSessionsApp → ContentView, which owns three @StateObjects wired through the whole view tree:
TimerViewModel(ViewModels 2/TimerViewModel.swift) — the 15-min countdown. Hardcoded 900-second session; drives aTimer, exposesformattedTime,progress, andminuteProgress(per-minute fill for the 15-segment ring). SetssessionCompleteto 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'sdurationis split across its categories bydata.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).
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 intaskName); the prompt explicitly allows non-task items so e.g. "9–9:15 call" becomes a real block.DayPlanManagerstores 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 exposesselectedDate+ a date-awareplancomputed property and the sameloadFailedguard asSessionManager. The Replan sheet andDayPlanViewboth 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-agnosticAIPlannerprotocol (Models 2/AIPlanner.swift), all over rawURLSession(no official SDKs for these providers). SharedPlanPrompt(system prompt,buildPrompt,parsePlan→ bare-JSON-to-DayPlan) andPlanHTTP(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-versionheaders) andDeepSeekPlanner(OpenAI-compatible Chat Completions atapi.deepseek.com,Authorization: Bearer,deepseek-chatordeepseek-reasoner). TheAIProviderenum is the registry: it owns each provider's display name, per-provider Keychain account (apiKey-<provider>), key-page URL, and two factories —makePlanner()(one-shot) andmakeChatPlanner()(streaming chat);AIProvider.selectedreads the chosen provider fromUserDefaults(aiProvider). To add a provider: add anAIProvidercase + both factory branches + conformances toAIPlannerandAIChatPlanner.
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 aweak var dndManager) callsbeginFocus()/endFocus()on start/complete/reset, andplayNotificationSound()checksshouldSuppressAppSounds; plus (2) an optional user-named Shortcut fired via theshortcuts://run-shortcutURL scheme to set a real system Focus. Settings live in@AppStorageandViews 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 unsandboxed — FocusSessions.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.
- 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 inContentView). Changing block length means touching all of them, not one constant. - When adding a field to a persisted model, add it to
CodingKeys, the memberwiseinit, theinit(from:)decoder (with adecodeIfPresentdefault), andencode(to:)— all four, or persisted data breaks.