From 29a71a73b270382e3b120deb7ba90fb2193617ec Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Fri, 15 May 2026 11:27:51 +0200 Subject: [PATCH] feat(macos): add native menu bar app with notch overlay Introduces a SwiftUI companion app for the Fail Well PWA: menu bar status item, popover with task list / record / history views, command palette, and a Dynamic Island-style notch overlay that shows the active step and timer beside the hardware notch and expands on hover to reveal pause/next controls. --- macos/.gitignore | 7 + macos/Info.plist | 36 +++ macos/Makefile | 38 +++ macos/Package.swift | 24 ++ macos/README.md | 37 +++ macos/Sources/FailWell/FailWellApp.swift | 114 ++++++++ macos/Sources/FailWell/Models/Step.swift | 8 + macos/Sources/FailWell/Models/Task.swift | 54 ++++ .../Sources/FailWell/Models/TaskRecord.swift | 41 +++ macos/Sources/FailWell/Models/TimeRange.swift | 11 + .../Sources/FailWell/Services/AppStore.swift | 270 ++++++++++++++++++ .../FailWell/Services/BreakCalculator.swift | 22 ++ .../FailWell/Services/ClipboardImporter.swift | 32 +++ macos/Sources/FailWell/Services/Clock.swift | 25 ++ .../Services/EstimationComparator.swift | 13 + .../FailWell/Services/StepParser.swift | 81 ++++++ .../FailWell/Services/TimeFormat.swift | 28 ++ .../FailWell/Views/CommandPalette.swift | 147 ++++++++++ .../FailWell/Views/MenuBarPopover.swift | 98 +++++++ .../NotchOverlay/NotchOverlayContent.swift | 256 +++++++++++++++++ .../NotchOverlay/NotchOverlayController.swift | 157 ++++++++++ macos/Sources/FailWell/Views/RecordView.swift | 204 +++++++++++++ macos/Sources/FailWell/Views/Router.swift | 37 +++ .../Sources/FailWell/Views/SettingsView.swift | 17 ++ .../FailWell/Views/TaskDetailView.swift | 132 +++++++++ macos/Sources/FailWell/Views/TaskEditor.swift | 142 +++++++++ .../FailWell/Views/TaskHistoryView.swift | 84 ++++++ .../Sources/FailWell/Views/TaskListView.swift | 160 +++++++++++ macos/Sources/FailWell/Views/Theme.swift | 32 +++ .../FailWellTests/BreakCalculatorTests.swift | 64 +++++ .../EstimationComparatorTests.swift | 13 + .../Tests/FailWellTests/StepParserTests.swift | 167 +++++++++++ macos/Tests/FailWellTests/TaskPlanTests.swift | 51 ++++ 33 files changed, 2602 insertions(+) create mode 100644 macos/.gitignore create mode 100644 macos/Info.plist create mode 100644 macos/Makefile create mode 100644 macos/Package.swift create mode 100644 macos/README.md create mode 100644 macos/Sources/FailWell/FailWellApp.swift create mode 100644 macos/Sources/FailWell/Models/Step.swift create mode 100644 macos/Sources/FailWell/Models/Task.swift create mode 100644 macos/Sources/FailWell/Models/TaskRecord.swift create mode 100644 macos/Sources/FailWell/Models/TimeRange.swift create mode 100644 macos/Sources/FailWell/Services/AppStore.swift create mode 100644 macos/Sources/FailWell/Services/BreakCalculator.swift create mode 100644 macos/Sources/FailWell/Services/ClipboardImporter.swift create mode 100644 macos/Sources/FailWell/Services/Clock.swift create mode 100644 macos/Sources/FailWell/Services/EstimationComparator.swift create mode 100644 macos/Sources/FailWell/Services/StepParser.swift create mode 100644 macos/Sources/FailWell/Services/TimeFormat.swift create mode 100644 macos/Sources/FailWell/Views/CommandPalette.swift create mode 100644 macos/Sources/FailWell/Views/MenuBarPopover.swift create mode 100644 macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayContent.swift create mode 100644 macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayController.swift create mode 100644 macos/Sources/FailWell/Views/RecordView.swift create mode 100644 macos/Sources/FailWell/Views/Router.swift create mode 100644 macos/Sources/FailWell/Views/SettingsView.swift create mode 100644 macos/Sources/FailWell/Views/TaskDetailView.swift create mode 100644 macos/Sources/FailWell/Views/TaskEditor.swift create mode 100644 macos/Sources/FailWell/Views/TaskHistoryView.swift create mode 100644 macos/Sources/FailWell/Views/TaskListView.swift create mode 100644 macos/Sources/FailWell/Views/Theme.swift create mode 100644 macos/Tests/FailWellTests/BreakCalculatorTests.swift create mode 100644 macos/Tests/FailWellTests/EstimationComparatorTests.swift create mode 100644 macos/Tests/FailWellTests/StepParserTests.swift create mode 100644 macos/Tests/FailWellTests/TaskPlanTests.swift diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..8dd56ad --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +.build/ +.swiftpm/ +.DS_Store +DerivedData/ +FailWell.app/ +*.xcodeproj/ +Package.resolved diff --git a/macos/Info.plist b/macos/Info.plist new file mode 100644 index 0000000..625fb0a --- /dev/null +++ b/macos/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Fail Well + CFBundleExecutable + FailWell + CFBundleIdentifier + io.calixte.failwell.macos + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Fail Well + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.productivity + LSMinimumSystemVersion + 15.0 + LSUIElement + + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/macos/Makefile b/macos/Makefile new file mode 100644 index 0000000..7d2a5da --- /dev/null +++ b/macos/Makefile @@ -0,0 +1,38 @@ +APP_NAME := FailWell +BUNDLE := $(APP_NAME).app +CONFIG := release +BIN_DIR := .build/$(CONFIG) +BIN := $(BIN_DIR)/$(APP_NAME) + +.PHONY: build release app run debug test clean + +build: + swift build + +release: + swift build -c release + +app: release + @rm -rf $(BUNDLE) + @mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources + @cp $(BIN) $(BUNDLE)/Contents/MacOS/$(APP_NAME) + @cp Info.plist $(BUNDLE)/Contents/Info.plist + @codesign --force --deep --sign - $(BUNDLE) 2>/dev/null || true + @echo "Built $(BUNDLE)" + +run: app + @open $(BUNDLE) + +debug: build + @rm -rf $(BUNDLE) + @mkdir -p $(BUNDLE)/Contents/MacOS $(BUNDLE)/Contents/Resources + @cp .build/debug/$(APP_NAME) $(BUNDLE)/Contents/MacOS/$(APP_NAME) + @cp Info.plist $(BUNDLE)/Contents/Info.plist + @codesign --force --deep --sign - $(BUNDLE) 2>/dev/null || true + @open $(BUNDLE) + +test: + swift test + +clean: + @rm -rf .build $(BUNDLE) diff --git a/macos/Package.swift b/macos/Package.swift new file mode 100644 index 0000000..8135e42 --- /dev/null +++ b/macos/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "FailWell", + platforms: [.macOS(.v15)], + dependencies: [ + .package(url: "https://github.com/sindresorhus/KeyboardShortcuts", from: "2.3.0") + ], + targets: [ + .executableTarget( + name: "FailWell", + dependencies: [ + .product(name: "KeyboardShortcuts", package: "KeyboardShortcuts") + ], + path: "Sources/FailWell" + ), + .testTarget( + name: "FailWellTests", + dependencies: ["FailWell"], + path: "Tests/FailWellTests" + ) + ] +) diff --git a/macos/README.md b/macos/README.md new file mode 100644 index 0000000..9823dc2 --- /dev/null +++ b/macos/README.md @@ -0,0 +1,37 @@ +# Fail Well — macOS + +Menu bar + notch companion to the [Fail Well PWA](../). See [`see-this-pwa-and-proud-porcupine.md`](../../.claude/plans/see-this-pwa-and-proud-porcupine.md) for the design plan. + +## Requirements + +- macOS 15 Sequoia or later +- Xcode 16 / Swift 6.0+ toolchain (`xcode-select --install` is enough — full Xcode not required) + +## Build & run + +```sh +make run # release build, packaged into FailWell.app, opened +make debug # debug build, packaged and opened +make test # swift test +make clean # remove .build and FailWell.app +``` + +The resulting `FailWell.app` lives next to the `Makefile`. It's an unsigned ad-hoc-signed bundle, fine for local use; for distribution you'd need a Developer ID. + +## Layout + +``` +macos/ +├── Package.swift +├── Info.plist bundle config (LSUIElement = true → no Dock icon) +├── Makefile build/run/test/package targets +└── Sources/FailWell/ + ├── FailWellApp.swift @main + MenuBarExtra + ├── Models/ Codable Task / Step / TaskRecord / TimeRange + ├── Services/ Parser, BreakCalculator, Comparator, Clock, Storage, Clipboard + └── Views/ SwiftUI popover, command palette, notch overlay +``` + +## Data location + +`~/Library/Application Support/FailWell/data.json` — versioned, atomic writes. diff --git a/macos/Sources/FailWell/FailWellApp.swift b/macos/Sources/FailWell/FailWellApp.swift new file mode 100644 index 0000000..be4e75c --- /dev/null +++ b/macos/Sources/FailWell/FailWellApp.swift @@ -0,0 +1,114 @@ +import AppKit +import KeyboardShortcuts +import SwiftUI + +extension KeyboardShortcuts.Name { + static let togglePopover = Self("togglePopover", default: .init(.f, modifiers: [.control, .option, .command])) +} + +@main +struct FailWellApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + + var body: some Scene { + Settings { + SettingsView() + .environment(appDelegate.store) + .environment(appDelegate.router) + .environment(appDelegate.clock) + } + } +} + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + let store = AppStore() + let router = Router() + let clock = Clock() + + private var statusItem: NSStatusItem! + private var popover: NSPopover! + private var labelUpdater: Task? + private var notchOverlay: NotchOverlayController? + + func applicationDidFinishLaunching(_ notification: Notification) { + setupStatusItem() + setupPopover() + setupHotkey() + startLabelUpdates() + notchOverlay = NotchOverlayController(store: store, clock: clock) + } + + func applicationWillTerminate(_ notification: Notification) { + labelUpdater?.cancel() + store.flush() + } + + private func setupStatusItem() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.behavior = .terminationOnRemoval + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "checklist", accessibilityDescription: "Fail Well") + button.imagePosition = .imageLeading + button.font = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .regular) + button.action = #selector(togglePopover(_:)) + button.target = self + } + } + + private func setupPopover() { + popover = NSPopover() + popover.behavior = .transient + popover.contentSize = NSSize(width: Theme.popoverWidth, height: Theme.popoverHeight) + popover.animates = false + let root = MenuBarPopover() + .environment(store) + .environment(router) + .environment(clock) + popover.contentViewController = NSHostingController(rootView: root) + } + + private func setupHotkey() { + KeyboardShortcuts.onKeyDown(for: .togglePopover) { [weak self] in + self?.togglePopover(nil) + } + } + + @objc func togglePopover(_ sender: Any?) { + if popover.isShown { + popover.performClose(sender) + return + } + guard let button = statusItem.button else { return } + NSApp.activate(ignoringOtherApps: true) + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + popover.contentViewController?.view.window?.makeKey() + } + + private func startLabelUpdates() { + labelUpdater = Task { [weak self] in + while !Task.isCancelled { + self?.refreshLabel() + try? await Task.sleep(for: .milliseconds(500)) + } + } + } + + private func refreshLabel() { + guard let button = statusItem.button else { return } + if let active = store.activeRecord, + let stepId = active.record.currentStepId, + let step = active.task.step(id: stepId), + let range = active.record.stepRecords[stepId] + { + let elapsed = clock.now.timeIntervalSince(range.start) + let icon = active.record.isPaused ? "pause.circle.fill" : "record.circle" + button.image = NSImage(systemSymbolName: icon, accessibilityDescription: nil) + let title = "\(TimeFormat.clock(elapsed)) \(step.title)" + button.title = title.count > 32 ? "\(title.prefix(30))…" : title + } else { + button.image = NSImage(systemSymbolName: "checklist", accessibilityDescription: nil) + button.title = "" + } + } +} diff --git a/macos/Sources/FailWell/Models/Step.swift b/macos/Sources/FailWell/Models/Step.swift new file mode 100644 index 0000000..59f1b7b --- /dev/null +++ b/macos/Sources/FailWell/Models/Step.swift @@ -0,0 +1,8 @@ +import Foundation + +struct Step: Codable, Identifiable, Hashable, Sendable { + let id: String + var title: String + /// estimation in minutes + var estimation: Int +} diff --git a/macos/Sources/FailWell/Models/Task.swift b/macos/Sources/FailWell/Models/Task.swift new file mode 100644 index 0000000..94d93bc --- /dev/null +++ b/macos/Sources/FailWell/Models/Task.swift @@ -0,0 +1,54 @@ +import Foundation + +/// A planned task with a title, optional link, and an append-only history of step lists. +/// Named `TaskPlan` to avoid collision with `_Concurrency.Task`. +struct TaskPlan: Codable, Identifiable, Hashable, Sendable { + let id: String + var title: String + var date: Date + var link: String? + var stepHistory: [[Step]] + + init(id: String, title: String, date: Date = Date(), link: String? = nil, stepHistory: [[Step]] = []) { + self.id = id + self.title = title + self.date = date + self.link = link + self.stepHistory = stepHistory + } + + var steps: [Step] { + stepHistory.last ?? [] + } + + var initialPlan: [Step] { + stepHistory.first ?? [] + } + + var wasUpdated: Bool { + stepHistory.count > 1 + } + + var totalEstimation: Int { + steps.reduce(0) { $0 + $1.estimation } + } + + mutating func newSteps(_ steps: [Step]) { + stepHistory.append(steps) + } + + mutating func editSteps(_ steps: [Step]) { + stepHistory.append(self.steps + steps) + } + + mutating func updateSteps(_ steps: [Step]) { + stepHistory.append(steps) + } + + mutating func removeStep(at index: Int) { + guard index >= 0, index < steps.count else { return } + stepHistory.append(steps.enumerated().compactMap { i, step in + i == index ? nil : step + }) + } +} diff --git a/macos/Sources/FailWell/Models/TaskRecord.swift b/macos/Sources/FailWell/Models/TaskRecord.swift new file mode 100644 index 0000000..f7c8160 --- /dev/null +++ b/macos/Sources/FailWell/Models/TaskRecord.swift @@ -0,0 +1,41 @@ +import Foundation + +struct TaskRecord: Codable, Hashable, Sendable { + let taskId: String + var start: Date + var end: Date? + var stepRecords: [String: TimeRange] + var notes: String + var breakTime: TimeRange? + var currentStepId: String? + + init( + taskId: String, + start: Date = Date(), + end: Date? = nil, + stepRecords: [String: TimeRange] = [:], + notes: String = "", + breakTime: TimeRange? = nil, + currentStepId: String? = nil + ) { + self.taskId = taskId + self.start = start + self.end = end + self.stepRecords = stepRecords + self.notes = notes + self.breakTime = breakTime + self.currentStepId = currentStepId + } + + var isActive: Bool { + end == nil && currentStepId != nil + } + + var isPaused: Bool { + breakTime != nil && breakTime?.end == nil + } + + var hasStarted: Bool { + !stepRecords.isEmpty + } +} diff --git a/macos/Sources/FailWell/Models/TimeRange.swift b/macos/Sources/FailWell/Models/TimeRange.swift new file mode 100644 index 0000000..dedf9cb --- /dev/null +++ b/macos/Sources/FailWell/Models/TimeRange.swift @@ -0,0 +1,11 @@ +import Foundation + +struct TimeRange: Codable, Hashable, Sendable { + var start: Date + var end: Date? + + var duration: TimeInterval? { + guard let end else { return nil } + return end.timeIntervalSince(start) + } +} diff --git a/macos/Sources/FailWell/Services/AppStore.swift b/macos/Sources/FailWell/Services/AppStore.swift new file mode 100644 index 0000000..d58da6d --- /dev/null +++ b/macos/Sources/FailWell/Services/AppStore.swift @@ -0,0 +1,270 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class AppStore { + private(set) var tasks: [TaskPlan] = [] + private(set) var records: [String: TaskRecord] = [:] + + @ObservationIgnored private let storageURL: URL + @ObservationIgnored private var pendingSave: Task? + + private struct DataFile: Codable { + var version: Int + var tasks: [TaskPlan] + var records: [String: TaskRecord] + } + + init(storageURL: URL? = nil) { + if let storageURL { + self.storageURL = storageURL + } else { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + let dir = appSupport.appendingPathComponent("FailWell", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + self.storageURL = dir.appendingPathComponent("data.json") + } + load() + } + + // MARK: - Persistence + + private func load() { + guard let data = try? Data(contentsOf: storageURL) else { return } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + do { + let file = try decoder.decode(DataFile.self, from: data) + self.tasks = file.tasks + self.records = file.records + } catch { + NSLog("FailWell: failed to decode data.json: \(error)") + } + } + + private func scheduleSave() { + pendingSave?.cancel() + let url = storageURL + let snapshot = DataFile(version: 1, tasks: tasks, records: records) + pendingSave = Task { + try? await Task.sleep(for: .milliseconds(500)) + guard !Task.isCancelled else { return } + Self.write(snapshot, to: url) + } + } + + private nonisolated static func write(_ file: DataFile, to url: URL) { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + do { + let data = try encoder.encode(file) + try data.write(to: url, options: .atomic) + } catch { + NSLog("FailWell: failed to write data.json: \(error)") + } + } + + /// Flush pending writes synchronously. Used on app termination. + func flush() { + pendingSave?.cancel() + pendingSave = nil + Self.write(DataFile(version: 1, tasks: tasks, records: records), to: storageURL) + } + + // MARK: - Task queries + + func task(id: String) -> TaskPlan? { + tasks.first(where: { $0.id == id }) + } + + var tasksByRecency: [TaskPlan] { + tasks.sorted(by: { $0.date > $1.date }) + } + + func record(taskId: String) -> TaskRecord? { + records[taskId] + } + + // MARK: - Task mutations + + func upsertTask(_ task: TaskPlan) { + if let idx = tasks.firstIndex(where: { $0.id == task.id }) { + tasks[idx] = task + } else { + tasks.append(task) + } + scheduleSave() + } + + func deleteTask(id: String) { + tasks.removeAll { $0.id == id } + records.removeValue(forKey: id) + scheduleSave() + } + + func duplicateTask(id: String) -> TaskPlan? { + guard let source = task(id: id) else { return nil } + let newId = UUID().uuidString + let copy = TaskPlan( + id: newId, + title: source.title, + date: Date(), + link: source.link, + stepHistory: [source.steps] + ) + upsertTask(copy) + return copy + } + + func editStepsToTask(taskId: String, steps: [Step]) { + guard var task = task(id: taskId) else { return } + task.newSteps(steps) + upsertTask(task) + cleanCurrentStepId(for: task) + } + + // MARK: - Record mutations + + func initRecord(taskId: String) { + if records[taskId] == nil { + records[taskId] = TaskRecord(taskId: taskId) + scheduleSave() + } + } + + func startStepRecord(taskId: String, stepId: String, at start: Date = Date()) { + var record = records[taskId] ?? TaskRecord(taskId: taskId) + if record.stepRecords.isEmpty { + record.start = start + } + record.breakTime = nil + record.stepRecords[stepId] = TimeRange(start: start) + record.currentStepId = stepId + records[taskId] = record + scheduleSave() + } + + func endStepRecord(taskId: String, stepId: String, at end: Date = Date()) { + guard var record = records[taskId], var range = record.stepRecords[stepId] else { return } + range.end = end + record.stepRecords[stepId] = range + records[taskId] = record + scheduleSave() + } + + func nextStep(taskId: String, at tick: Date = Date()) { + guard let task = task(id: taskId), + let record = records[taskId], + let currentStepId = record.currentStepId + else { return } + + endStepRecord(taskId: taskId, stepId: currentStepId, at: tick) + + if let nextStepId = task.nextStepId(after: currentStepId) { + startStepRecord(taskId: taskId, stepId: nextStepId, at: tick) + } else { + endRecord(taskId: taskId, at: tick) + } + } + + func endRecord(taskId: String, at end: Date = Date()) { + guard var record = records[taskId] else { return } + record.end = end + record.currentStepId = nil + records[taskId] = record + scheduleSave() + } + + func updateNotes(taskId: String, notes: String) { + guard var record = records[taskId] else { return } + record.notes = notes + records[taskId] = record + scheduleSave() + } + + func reset(taskId: String) { + guard var record = records[taskId] else { return } + record.stepRecords = [:] + record.end = nil + record.currentStepId = nil + record.breakTime = nil + records[taskId] = record + scheduleSave() + } + + func pause(taskId: String, at start: Date = Date()) { + guard var record = records[taskId], record.breakTime == nil else { return } + record.breakTime = TimeRange(start: start) + records[taskId] = record + scheduleSave() + } + + func resume(taskId: String, at end: Date = Date()) { + guard var record = records[taskId], var breakRange = record.breakTime else { return } + breakRange.end = end + record.breakTime = breakRange + var shifted = BreakCalculator.applyingBreakTime(to: record) + shifted.breakTime = nil + records[taskId] = shifted + scheduleSave() + } + + /// When task steps change mid-recording, drop step records that no longer exist + /// and restart from the first unfinished step. Mirrors `cleanCurrentStepId`. + private func cleanCurrentStepId(for task: TaskPlan) { + guard var record = records[task.id] else { return } + + // Find first step whose record isn't finished. + let firstPendingIndex = task.steps.firstIndex(where: { step in + record.stepRecords[step.id]?.end == nil + }) + guard let firstPendingIndex else { return } + + let latestStart = record.stepRecords.values + .map(\.start) + .max() ?? Date() + + // Drop records for steps after the first pending one. + let keepIds = Set(task.steps.prefix(firstPendingIndex + 1).map(\.id)) + record.stepRecords = record.stepRecords.filter { keepIds.contains($0.key) } + records[task.id] = record + + startStepRecord( + taskId: task.id, + stepId: task.steps[firstPendingIndex].id, + at: latestStart + ) + } + + /// Active recording across all tasks (only one can be active at a time in practice). + var activeRecord: (task: TaskPlan, record: TaskRecord)? { + for (taskId, record) in records where record.isActive { + if let task = task(id: taskId) { + return (task, record) + } + } + return nil + } +} + +extension TaskPlan { + func nextStepId(after stepId: String?) -> String? { + guard let stepId else { return steps.first?.id } + guard let idx = steps.firstIndex(where: { $0.id == stepId }) else { return nil } + let next = idx + 1 + return next < steps.count ? steps[next].id : nil + } + + func step(id: String) -> Step? { + steps.first(where: { $0.id == id }) + } + + /// The estimation the developer committed to in the Initial Plan for this Step, if any. + /// Returns `nil` for Steps that were added mid-Execution (no baseline → no Failure Signal). + /// See CONTEXT.md §"Failure Signal". + func initialEstimation(for stepId: String) -> Int? { + initialPlan.first(where: { $0.id == stepId })?.estimation + } +} diff --git a/macos/Sources/FailWell/Services/BreakCalculator.swift b/macos/Sources/FailWell/Services/BreakCalculator.swift new file mode 100644 index 0000000..accda05 --- /dev/null +++ b/macos/Sources/FailWell/Services/BreakCalculator.swift @@ -0,0 +1,22 @@ +import Foundation + +enum BreakCalculator { + /// Shifts the `start` of any unfinished step records forward by the break duration. + /// Mirrors `addBreakTimeToStepRecords` from the PWA. + static func applyingBreakTime(to record: TaskRecord) -> TaskRecord { + guard let breakTime = record.breakTime, let breakEnd = breakTime.end else { + return record + } + + let diff = breakEnd.timeIntervalSince(breakTime.start) + + var updated = record + for (stepId, range) in updated.stepRecords where range.end == nil { + updated.stepRecords[stepId] = TimeRange( + start: range.start.addingTimeInterval(diff), + end: nil + ) + } + return updated + } +} diff --git a/macos/Sources/FailWell/Services/ClipboardImporter.swift b/macos/Sources/FailWell/Services/ClipboardImporter.swift new file mode 100644 index 0000000..5d157f5 --- /dev/null +++ b/macos/Sources/FailWell/Services/ClipboardImporter.swift @@ -0,0 +1,32 @@ +import AppKit +import Foundation + +enum ClipboardImporter { + struct Draft: Equatable { + var title: String? + var steps: [Step] + } + + /// Reads the current pasteboard and, if it looks like a markdown task + /// (`# Title` optional, followed by `- step | mins` lines), returns a draft. + static func currentDraft() -> Draft? { + guard let text = NSPasteboard.general.string(forType: .string) else { return nil } + return draft(from: text) + } + + static func draft(from text: String) -> Draft? { + let (title, content) = extractTitleAndContent(text) + let steps = StepParser.adaptTextareaToSteps(content) + guard !steps.isEmpty else { return nil } + return Draft(title: title, steps: steps) + } + + nonisolated(unsafe) private static let titleRegex = /^(#{1,6})\s+(.+?)\n+([\s\S]*)$/ + + private static func extractTitleAndContent(_ text: String) -> (title: String?, content: String) { + if let match = try? titleRegex.firstMatch(in: text) { + return (String(match.2).trimmingCharacters(in: .whitespaces), String(match.3)) + } + return (nil, text) + } +} diff --git a/macos/Sources/FailWell/Services/Clock.swift b/macos/Sources/FailWell/Services/Clock.swift new file mode 100644 index 0000000..6c78c95 --- /dev/null +++ b/macos/Sources/FailWell/Services/Clock.swift @@ -0,0 +1,25 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class Clock { + var now: Date = Date() + + @ObservationIgnored private var tickTask: Task? + + init(tickInterval: Duration = .milliseconds(500)) { + tickTask = Task { [weak self] in + while !Task.isCancelled { + await MainActor.run { + self?.now = Date() + } + try? await Task.sleep(for: tickInterval) + } + } + } + + deinit { + tickTask?.cancel() + } +} diff --git a/macos/Sources/FailWell/Services/EstimationComparator.swift b/macos/Sources/FailWell/Services/EstimationComparator.swift new file mode 100644 index 0000000..9b9caf3 --- /dev/null +++ b/macos/Sources/FailWell/Services/EstimationComparator.swift @@ -0,0 +1,13 @@ +import Foundation + +enum EstimationComparator { + /// Returns `true` when the actual duration deviates from the estimation by more than + /// 10 % (with a 1-minute floor for very short steps). Mirrors `is10PercentOffThanEstimation`. + static func is10PercentOff(estimation: Double, duration: Double) -> Bool { + abs(duration - estimation) > max(estimation * 0.1, 1) + } + + static func is10PercentOff(estimation: Int, duration: Int) -> Bool { + is10PercentOff(estimation: Double(estimation), duration: Double(duration)) + } +} diff --git a/macos/Sources/FailWell/Services/StepParser.swift b/macos/Sources/FailWell/Services/StepParser.swift new file mode 100644 index 0000000..8b110d2 --- /dev/null +++ b/macos/Sources/FailWell/Services/StepParser.swift @@ -0,0 +1,81 @@ +import CryptoKit +import Foundation + +enum StepParser { + static func adaptStepsToTextarea(_ steps: [Step]) -> String { + steps.map { "- \($0.title) | \($0.estimation)" }.joined(separator: "\n") + } + + static func adaptTextareaToSteps(_ value: String) -> [Step] { + let lines = value.components(separatedBy: "\n") + var result: [(title: String, estimation: Int)] = [] + var currentParent: (title: String, estimation: Int)? + var parentHasChildren = false + + func flushParent() { + if let parent = currentParent, !parentHasChildren { + result.append(parent) + } + currentParent = nil + parentHasChildren = false + } + + for line in lines { + let (title, estimation) = extractTitleAndEstimation(from: line) + if title.isEmpty { continue } + + if isIndented(line) { + if let parent = currentParent, !parent.title.isEmpty { + let flattened = "(\(parent.title)) - \(title)" + result.append((flattened, estimation)) + parentHasChildren = true + } else { + result.append((title, estimation)) + } + } else { + flushParent() + currentParent = (title, estimation) + } + } + flushParent() + + // Step identity is derived from title only. Re-estimation must preserve identity so + // the existing Step Record (keyed by Step id) survives — see CONTEXT.md §"Re-estimation". + let bases = result.map { md5($0.title) } + return result.enumerated().map { index, item in + let base = bases[index] + let duplicates = bases[...index].filter { $0 == base }.count + return Step(id: "\(base)-\(duplicates)", title: item.title, estimation: item.estimation) + } + } + + private static func isIndented(_ line: String) -> Bool { + guard let first = line.first else { return false } + return first == " " || first == "\t" + } + + nonisolated(unsafe) private static let stripPrefix = /^-\s*(\[[ x]\]\s*)?/ + + private static func extractTitleAndEstimation(from rawStep: String) -> (String, Int) { + let trimmed = rawStep.trimmingCharacters(in: .whitespaces) + let stripped = trimmed.replacing(stripPrefix, with: "") + let parts = stripped.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false) + let title = parts.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? "" + let rawEstimation = parts.count > 1 ? String(parts[1]).trimmingCharacters(in: .whitespaces) : "" + + let estimation: Int + if rawEstimation.isEmpty { + estimation = 0 + } else if let d = Double(rawEstimation), !d.isNaN { + estimation = Int(d.rounded()) + } else { + estimation = 0 + } + return (title, estimation) + } + + private static func md5(_ s: String) -> String { + let digest = Insecure.MD5.hash(data: Data(s.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/macos/Sources/FailWell/Services/TimeFormat.swift b/macos/Sources/FailWell/Services/TimeFormat.swift new file mode 100644 index 0000000..ff9fea8 --- /dev/null +++ b/macos/Sources/FailWell/Services/TimeFormat.swift @@ -0,0 +1,28 @@ +import Foundation + +enum TimeFormat { + /// "MM:SS" or "H:MM:SS" depending on magnitude. + static func clock(_ seconds: TimeInterval) -> String { + let total = max(0, Int(seconds)) + let h = total / 3600 + let m = (total % 3600) / 60 + let s = total % 60 + if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) } + return String(format: "%d:%02d", m, s) + } + + /// Rounded to minutes. Returns "0m" for sub-minute durations. + static func minutes(_ seconds: TimeInterval) -> String { + "\(Int((seconds / 60).rounded()))m" + } + + /// Compact relative-date string, e.g. "today", "yesterday", "Mar 14". + static func date(_ date: Date, relativeTo reference: Date = Date()) -> String { + let cal = Calendar.current + if cal.isDateInToday(date) { return "today" } + if cal.isDateInYesterday(date) { return "yesterday" } + let f = DateFormatter() + f.dateFormat = cal.component(.year, from: date) == cal.component(.year, from: reference) ? "MMM d" : "MMM d yyyy" + return f.string(from: date) + } +} diff --git a/macos/Sources/FailWell/Views/CommandPalette.swift b/macos/Sources/FailWell/Views/CommandPalette.swift new file mode 100644 index 0000000..1f6c0da --- /dev/null +++ b/macos/Sources/FailWell/Views/CommandPalette.swift @@ -0,0 +1,147 @@ +import AppKit +import SwiftUI + +struct CommandPalette: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + @State private var query: String = "" + @State private var selection: Int = 0 + @FocusState private var fieldFocused: Bool + + private struct Command: Identifiable { + let id: String + let label: String + let hint: String + let run: () -> Void + } + + private var commands: [Command] { + var list: [Command] = [] + let activeTaskId = store.activeRecord?.task.id + + list.append(.init(id: "new", label: "new task", hint: "create a blank task") { + router.push(.editor(taskId: nil)) + }) + list.append(.init(id: "paste", label: "paste task from clipboard", hint: "import markdown") { + if let draft = ClipboardImporter.currentDraft() { + let t = TaskPlan(id: UUID().uuidString, title: draft.title ?? "Untitled", stepHistory: [draft.steps]) + store.upsertTask(t) + router.push(.detail(taskId: t.id)) + } + }) + if let activeTaskId { + list.append(.init(id: "open-record", label: "open active recording", hint: "jump to current session") { + router.push(.record(taskId: activeTaskId)) + }) + list.append(.init(id: "pause", label: "pause / resume session", hint: "toggle break") { + if let r = store.record(taskId: activeTaskId), r.isPaused { + store.resume(taskId: activeTaskId) + } else { + store.pause(taskId: activeTaskId) + } + }) + list.append(.init(id: "next", label: "next step", hint: "advance current step") { + store.nextStep(taskId: activeTaskId) + }) + } + list.append(.init(id: "settings", label: "settings", hint: "global hotkey, etc.") { + NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil) + }) + list.append(.init(id: "reveal", label: "reveal data file in finder", hint: "~/Library/Application Support/FailWell") { + let url = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("FailWell", isDirectory: true) + NSWorkspace.shared.activateFileViewerSelecting([url]) + }) + list.append(.init(id: "quit", label: "quit fail well", hint: "⌘Q") { + NSApp.terminate(nil) + }) + + // Recently-used tasks as "start session" entries. + for task in store.tasksByRecency.prefix(8) { + list.append(.init(id: "start-\(task.id)", label: "start session: \(task.title)", hint: "\(task.steps.count) steps · \(task.totalEstimation)m") { + store.initRecord(taskId: task.id) + store.reset(taskId: task.id) + if let first = task.steps.first { + store.startStepRecord(taskId: task.id, stepId: first.id) + router.push(.record(taskId: task.id)) + } + }) + list.append(.init(id: "open-\(task.id)", label: "open: \(task.title)", hint: "task detail") { + router.push(.detail(taskId: task.id)) + }) + } + + return list + } + + private var filtered: [Command] { + let q = query.trimmingCharacters(in: .whitespaces).lowercased() + guard !q.isEmpty else { return commands } + return commands.filter { fuzzyMatch(q, in: $0.label.lowercased()) } + } + + var body: some View { + VStack(spacing: 0) { + TextField(">", text: $query, prompt: Text("type to search commands")) + .textFieldStyle(.plain) + .font(Theme.monoTitle) + .padding(12) + .focused($fieldFocused) + .onSubmit { runSelected() } + .onChange(of: query) { _, _ in selection = 0 } + Divider() + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(filtered.enumerated()), id: \.element.id) { i, cmd in + HStack(spacing: 8) { + Text(i == selection ? "▸" : " ").monoBody().foregroundStyle(Theme.accent) + Text(cmd.label).monoBody() + Spacer() + Text(cmd.hint).monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .frame(height: Theme.rowHeight) + .background(i == selection ? Theme.accent.opacity(0.12) : .clear) + .contentShape(Rectangle()) + .onTapGesture { selection = i; runSelected() } + } + } + } + } + .frame(width: Theme.popoverWidth - 40, height: Theme.popoverHeight - 80) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color(nsColor: .separatorColor))) + .shadow(radius: 12) + .onAppear { fieldFocused = true; selection = 0 } + .onKeyPress(.downArrow) { + selection = min(selection + 1, filtered.count - 1) + return .handled + } + .onKeyPress(.upArrow) { + selection = max(0, selection - 1) + return .handled + } + .onKeyPress(.escape) { + router.showPalette = false + return .handled + } + } + + private func runSelected() { + let list = filtered + guard selection >= 0, selection < list.count else { return } + list[selection].run() + router.showPalette = false + } + + private func fuzzyMatch(_ needle: String, in haystack: String) -> Bool { + var hIdx = haystack.startIndex + for ch in needle { + guard let found = haystack[hIdx...].firstIndex(of: ch) else { return false } + hIdx = haystack.index(after: found) + } + return true + } +} diff --git a/macos/Sources/FailWell/Views/MenuBarPopover.swift b/macos/Sources/FailWell/Views/MenuBarPopover.swift new file mode 100644 index 0000000..afe2cca --- /dev/null +++ b/macos/Sources/FailWell/Views/MenuBarPopover.swift @@ -0,0 +1,98 @@ +import SwiftUI + +struct MenuBarPopover: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + var body: some View { + @Bindable var router = router + + ZStack { + VStack(spacing: 0) { + if let active = store.activeRecord, !isOnRecordScreen(active.task.id) { + ActiveRecordBanner(taskId: active.task.id) + Divider() + } + screen + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .windowBackgroundColor)) + + if router.showPalette { + CommandPalette() + .transition(.opacity) + } + } + .onKeyPress(.init("k"), phases: .down) { event in + if event.modifiers.contains(.command) { + router.showPalette.toggle() + return .handled + } + return .ignored + } + .onKeyPress(.escape) { + if router.showPalette { + router.showPalette = false + return .handled + } + if router.stack.count > 1 { + router.pop() + return .handled + } + return .ignored + } + } + + @ViewBuilder private var screen: some View { + switch router.current { + case .list: + TaskListView() + case .detail(let id): + TaskDetailView(taskId: id) + case .editor(let id): + TaskEditor(taskId: id) + case .record(let id): + RecordView(taskId: id) + case .history(let id): + TaskHistoryView(taskId: id) + case .settings: + SettingsView() + } + } + + private func isOnRecordScreen(_ taskId: String) -> Bool { + if case .record(let id) = router.current, id == taskId { return true } + return false + } +} + +private struct ActiveRecordBanner: View { + @Environment(AppStore.self) private var store + @Environment(Clock.self) private var clock + @Environment(Router.self) private var router + let taskId: String + + var body: some View { + Button { + router.push(.record(taskId: taskId)) + } label: { + HStack(spacing: 8) { + let isPaused = store.record(taskId: taskId)?.isPaused ?? false + Image(systemName: isPaused ? "pause.circle.fill" : "record.circle") + .foregroundStyle(isPaused ? Theme.flag : Theme.accent) + if let stepId = store.record(taskId: taskId)?.currentStepId, + let step = store.task(id: taskId)?.step(id: stepId), + let range = store.record(taskId: taskId)?.stepRecords[stepId] + { + Text(step.title).monoBody().lineLimit(1) + Spacer() + Text(TimeFormat.clock(clock.now.timeIntervalSince(range.start))).monoTimer() + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } +} diff --git a/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayContent.swift b/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayContent.swift new file mode 100644 index 0000000..b8ba87e --- /dev/null +++ b/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayContent.swift @@ -0,0 +1,256 @@ +import SwiftUI + +struct NotchOverlayContent: View { + @Environment(AppStore.self) private var store + @Environment(Clock.self) private var clock + + let state: NotchOverlayState + let geometry: NotchGeometry + + var body: some View { + if let active = store.activeRecord, + let stepId = active.record.currentStepId, + let step = active.task.step(id: stepId), + let range = active.record.stepRecords[stepId] + { + island(active: active, step: step, range: range) + } else { + Color.clear + } + } + + private func island( + active: (task: TaskPlan, record: TaskRecord), + step: Step, + range: TimeRange + ) -> some View { + let elapsed = clock.now.timeIntervalSince(range.start) + let estimationSec = Double(step.estimation) * 60 + let remaining = max(0, estimationSec - elapsed) + let overshoot = max(0, elapsed - estimationSec) + let isPaused = active.record.isPaused + let isExpanded = state.isExpanded + let menuBarH = geometry.menuBarHeight + let notchW = geometry.notchWidth + + return ZStack(alignment: .top) { + NotchShape(notchWidth: notchW, menuBarHeight: menuBarH) + .fill(Color.black, style: FillStyle(eoFill: true)) + .shadow(color: .black.opacity(isExpanded ? 0.35 : 0), radius: 12, x: 0, y: 6) + + VStack(spacing: 0) { + wings( + step: step, + elapsed: elapsed, + remaining: remaining, + overshoot: overshoot, + isPaused: isPaused, + menuBarH: menuBarH, + notchW: notchW + ) + if isExpanded { + expandedBody( + active: active, + step: step, + elapsed: elapsed, + remaining: remaining, + overshoot: overshoot, + isPaused: isPaused + ) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + .animation(.spring(response: 0.32, dampingFraction: 0.82), value: isExpanded) + .onHover { hover in + state.isExpanded = hover + } + .onTapGesture(count: 2) { + NSApp.activate(ignoringOtherApps: true) + } + } + + private func wings( + step: Step, + elapsed: TimeInterval, + remaining: TimeInterval, + overshoot: TimeInterval, + isPaused: Bool, + menuBarH: CGFloat, + notchW: CGFloat + ) -> some View { + HStack(spacing: 0) { + // Left wing: status indicator + step title + HStack(spacing: 6) { + Image(systemName: isPaused ? "pause.circle.fill" : "record.circle") + .foregroundStyle(isPaused ? Theme.flag : Theme.accent) + .font(.system(size: 11)) + Text(step.title) + .font(Theme.mono) + .foregroundStyle(.white) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.trailing, 10) + .frame(maxWidth: .infinity, alignment: .trailing) + + Color.clear.frame(width: notchW) + + // Right wing: timer + HStack(spacing: 4) { + Text(TimeFormat.clock(elapsed)) + .font(Theme.mono.monospacedDigit()) + .foregroundStyle(.white) + if step.estimation > 0 { + if overshoot > 0 { + Text("+\(TimeFormat.clock(overshoot))") + .font(Theme.monoSmall) + .foregroundStyle(Theme.flag) + } else { + Text("·\(TimeFormat.clock(remaining))") + .font(Theme.monoSmall) + .foregroundStyle(Theme.dim) + } + } + } + .padding(.leading, 10) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(height: menuBarH) + } + + private func expandedBody( + active: (task: TaskPlan, record: TaskRecord), + step: Step, + elapsed: TimeInterval, + remaining: TimeInterval, + overshoot: TimeInterval, + isPaused: Bool + ) -> some View { + VStack(spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text(step.title) + .font(Theme.monoTitle) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + Spacer(minLength: 8) + if step.estimation > 0 { + Text("est \(step.estimation)m") + .font(Theme.monoSmall) + .foregroundStyle(Theme.dim) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.white.opacity(0.10)) + .clipShape(Capsule()) + } + } + + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(TimeFormat.clock(elapsed)) + .font(Theme.monoTimer) + .foregroundStyle(.white) + if step.estimation > 0 { + if overshoot > 0 { + Text("+\(TimeFormat.clock(overshoot))") + .font(Theme.mono) + .foregroundStyle(Theme.flag) + } else { + Text("/ \(TimeFormat.clock(remaining))") + .font(Theme.mono) + .foregroundStyle(Theme.dim) + } + } + Spacer() + } + + HStack(spacing: 8) { + pillButton( + title: isPaused ? "Resume" : "Pause", + systemImage: isPaused ? "play.fill" : "pause.fill" + ) { + isPaused + ? store.resume(taskId: active.task.id) + : store.pause(taskId: active.task.id) + } + pillButton(title: "Next", systemImage: "forward.end.fill") { + store.nextStep(taskId: active.task.id) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 14) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func pillButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .font(Theme.mono) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.white.opacity(0.14)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } +} + +struct NotchShape: Shape { + let notchWidth: CGFloat + let menuBarHeight: CGFloat + + func path(in rect: CGRect) -> Path { + let outerR: CGFloat = 14 + let innerR: CGFloat = 8 + let halfNotch = notchWidth / 2 + let cx = rect.midX + let notchLeft = cx - halfNotch + let notchRight = cx + halfNotch + let cutoutBottom = rect.minY + menuBarHeight + let cutoutTop = rect.minY - 1 + let bottomIsRounded = rect.height > menuBarHeight + outerR + + var p = Path() + + // Outer boundary: top flat, bottom rounded (when expanded) + p.move(to: CGPoint(x: rect.minX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) + if bottomIsRounded { + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - outerR)) + p.addQuadCurve( + to: CGPoint(x: rect.maxX - outerR, y: rect.maxY), + control: CGPoint(x: rect.maxX, y: rect.maxY) + ) + p.addLine(to: CGPoint(x: rect.minX + outerR, y: rect.maxY)) + p.addQuadCurve( + to: CGPoint(x: rect.minX, y: rect.maxY - outerR), + control: CGPoint(x: rect.minX, y: rect.maxY) + ) + } else { + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + } + p.closeSubpath() + + // Cutout: matches the hardware notch (rounded inner bottom corners) + p.move(to: CGPoint(x: notchLeft, y: cutoutTop)) + p.addLine(to: CGPoint(x: notchLeft, y: cutoutBottom - innerR)) + p.addQuadCurve( + to: CGPoint(x: notchLeft + innerR, y: cutoutBottom), + control: CGPoint(x: notchLeft, y: cutoutBottom) + ) + p.addLine(to: CGPoint(x: notchRight - innerR, y: cutoutBottom)) + p.addQuadCurve( + to: CGPoint(x: notchRight, y: cutoutBottom - innerR), + control: CGPoint(x: notchRight, y: cutoutBottom) + ) + p.addLine(to: CGPoint(x: notchRight, y: cutoutTop)) + p.closeSubpath() + + return p + } +} diff --git a/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayController.swift b/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayController.swift new file mode 100644 index 0000000..455a3c1 --- /dev/null +++ b/macos/Sources/FailWell/Views/NotchOverlay/NotchOverlayController.swift @@ -0,0 +1,157 @@ +import AppKit +import SwiftUI +import Observation + +@MainActor +@Observable +final class NotchOverlayState { + var isExpanded = false +} + +struct NotchGeometry: Equatable { + let screenFrame: NSRect + let menuBarHeight: CGFloat + let notchWidth: CGFloat +} + +enum NotchOverlayLayout { + static let wingExtension: CGFloat = 96 + static let expandedBodyHeight: CGFloat = 132 +} + +@MainActor +final class NotchOverlayController { + private let store: AppStore + private let clock: Clock + private let state = NotchOverlayState() + private var panel: NSPanel? + private var screenObserver: NSObjectProtocol? + private var syncTask: Task? + + init(store: AppStore, clock: Clock) { + self.store = store + self.clock = clock + + screenObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didChangeScreenParametersNotification, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor in + self?.rebuild() + } + } + startSync() + observeExpansion() + } + + private func startSync() { + syncTask = Task { [weak self] in + while !Task.isCancelled { + self?.sync() + try? await Task.sleep(for: .milliseconds(500)) + } + } + } + + private func observeExpansion() { + withObservationTracking { [weak self] in + _ = self?.state.isExpanded + } onChange: { [weak self] in + Task { @MainActor in + self?.updateFrame(animated: true) + self?.observeExpansion() + } + } + } + + private func sync() { + let shouldShow = store.activeRecord != nil && notchGeometry() != nil + if shouldShow { + ensurePanel() + } else { + tearDown() + } + } + + private func notchGeometry() -> NotchGeometry? { + guard let screen = NSScreen.main, screen.safeAreaInsets.top > 0 else { return nil } + let menuBarHeight = screen.safeAreaInsets.top + let notchWidth: CGFloat + if let left = screen.auxiliaryTopLeftArea, let right = screen.auxiliaryTopRightArea { + notchWidth = max(0, screen.frame.width - left.width - right.width) + } else { + notchWidth = 200 + } + return NotchGeometry( + screenFrame: screen.frame, + menuBarHeight: menuBarHeight, + notchWidth: notchWidth + ) + } + + private func panelFrame(geometry: NotchGeometry, expanded: Bool) -> NSRect { + let totalWidth = geometry.notchWidth + NotchOverlayLayout.wingExtension * 2 + let totalHeight = geometry.menuBarHeight + (expanded ? NotchOverlayLayout.expandedBodyHeight : 0) + let x = geometry.screenFrame.origin.x + (geometry.screenFrame.width - totalWidth) / 2 + let y = geometry.screenFrame.origin.y + geometry.screenFrame.height - totalHeight + return NSRect(x: x, y: y, width: totalWidth, height: totalHeight) + } + + private func ensurePanel() { + guard let geometry = notchGeometry() else { return } + if panel != nil { return } + + let panel = NSPanel( + contentRect: panelFrame(geometry: geometry, expanded: state.isExpanded), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.level = .screenSaver + panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle] + panel.ignoresMouseEvents = false + panel.isMovable = false + + let content = NotchOverlayContent(state: state, geometry: geometry) + .environment(store) + .environment(clock) + let hosting = NSHostingController(rootView: content) + hosting.view.wantsLayer = true + hosting.view.layer?.backgroundColor = .clear + panel.contentViewController = hosting + panel.orderFront(nil) + + self.panel = panel + } + + private func updateFrame(animated: Bool) { + guard let panel, let geometry = notchGeometry() else { return } + let newFrame = panelFrame(geometry: geometry, expanded: state.isExpanded) + if animated { + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = 0.32 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + ctx.allowsImplicitAnimation = true + panel.animator().setFrame(newFrame, display: true) + } + } else { + panel.setFrame(newFrame, display: true) + } + } + + private func tearDown() { + state.isExpanded = false + panel?.orderOut(nil) + panel?.close() + panel = nil + } + + private func rebuild() { + tearDown() + sync() + } +} diff --git a/macos/Sources/FailWell/Views/RecordView.swift b/macos/Sources/FailWell/Views/RecordView.swift new file mode 100644 index 0000000..87979e4 --- /dev/null +++ b/macos/Sources/FailWell/Views/RecordView.swift @@ -0,0 +1,204 @@ +import SwiftUI + +struct RecordView: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + @Environment(Clock.self) private var clock + + let taskId: String + + @FocusState private var notesFocused: Bool + @State private var showResetConfirm = false + + private var task: TaskPlan? { store.task(id: taskId) } + private var record: TaskRecord? { store.record(taskId: taskId) } + + @FocusState private var rootFocused: Bool + + var body: some View { + if let task, let record { + VStack(spacing: 0) { + header(task, record) + Divider() + stepList(task, record) + Divider() + notes + Divider() + controls(task, record) + } + .focusable() + .focused($rootFocused) + .focusEffectDisabled() + .onAppear { rootFocused = true } + .onKeyPress(.init("s")) { startIfNeeded(task, record) } + .onKeyPress(.init("n")) { nextIfPossible(record) } + .onKeyPress(.init("p")) { togglePause(record) } + .onKeyPress(.init("r")) { showResetConfirm = true; return .handled } + .onKeyPress(.init("i")) { + notesFocused = true + return .handled + } + .confirmationDialog("Reset session?", isPresented: $showResetConfirm) { + Button("Reset", role: .destructive) { store.reset(taskId: taskId) } + Button("Cancel", role: .cancel) {} + } + } else { + ContentUnavailableView("Record not found", systemImage: "clock.badge.questionmark") + .onAppear { router.popToRoot() } + } + } + + private func header(_ task: TaskPlan, _ record: TaskRecord) -> some View { + HStack(spacing: 8) { + Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) } + .buttonStyle(.plain) + Text(task.title).monoTitle().lineLimit(1) + Spacer() + if record.isPaused { + Text(Theme.Glyph.paused).foregroundStyle(Theme.flag) + } else if record.currentStepId != nil { + Text(Theme.Glyph.active).foregroundStyle(Theme.accent) + } + Text(elapsedTotal(record)).monoTimer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func stepList(_ task: TaskPlan, _ record: TaskRecord) -> some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(task.steps) { step in + StepRow( + step: step, + initialEstimation: task.initialEstimation(for: step.id), + record: record, + now: clock.now + ) + } + } + } + } + + private var notes: some View { + VStack(alignment: .leading, spacing: 4) { + Text("notes").monoSmall().foregroundStyle(Theme.dim).padding(.horizontal, 12).padding(.top, 6) + TextEditor(text: notesBinding) + .font(Theme.mono) + .frame(height: 60) + .scrollContentBackground(.hidden) + .padding(.horizontal, 8) + .padding(.bottom, 6) + .focused($notesFocused) + } + } + + private var notesBinding: Binding { + Binding( + get: { store.record(taskId: taskId)?.notes ?? "" }, + set: { store.updateNotes(taskId: taskId, notes: $0) } + ) + } + + private func controls(_ task: TaskPlan, _ record: TaskRecord) -> some View { + HStack(spacing: 12) { + if record.currentStepId == nil && !record.hasStarted { + Text("s start").monoSmall().foregroundStyle(Theme.dim) + } else if record.end != nil { + Text("session ended").monoSmall().foregroundStyle(Theme.dim) + Text("r reset").monoSmall().foregroundStyle(Theme.dim) + } else { + Text("n next").monoSmall().foregroundStyle(Theme.dim) + Text(record.isPaused ? "p resume" : "p pause").monoSmall().foregroundStyle(Theme.dim) + Text("r reset").monoSmall().foregroundStyle(Theme.dim) + Text("i notes").monoSmall().foregroundStyle(Theme.dim) + } + Spacer() + Text("esc back").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func startIfNeeded(_ task: TaskPlan, _ record: TaskRecord) -> KeyPress.Result { + guard record.currentStepId == nil, !record.hasStarted, !task.steps.isEmpty else { return .ignored } + store.startStepRecord(taskId: task.id, stepId: task.steps[0].id) + return .handled + } + + private func nextIfPossible(_ record: TaskRecord) -> KeyPress.Result { + guard record.currentStepId != nil, !record.isPaused else { return .ignored } + store.nextStep(taskId: taskId) + return .handled + } + + private func togglePause(_ record: TaskRecord) -> KeyPress.Result { + if record.isPaused { + store.resume(taskId: taskId) + } else { + store.pause(taskId: taskId) + } + return .handled + } + + private func elapsedTotal(_ record: TaskRecord) -> String { + let total = record.stepRecords.values.reduce(into: 0.0) { acc, range in + let end = range.end ?? (record.currentStepId.flatMap { record.stepRecords[$0]?.start == range.start ? clock.now : nil } ?? range.start) + acc += end.timeIntervalSince(range.start) + } + return TimeFormat.clock(total) + } +} + +private struct StepRow: View { + let step: Step + /// Estimation from the Initial Plan, or `nil` if this Step was added mid-Execution. + /// The Failure Signal is computed against this — never the current estimation. + let initialEstimation: Int? + let record: TaskRecord + let now: Date + + var body: some View { + let isCurrent = record.currentStepId == step.id + let range = record.stepRecords[step.id] + let isDone = range?.end != nil + let elapsed: TimeInterval? = { + guard let range else { return nil } + return (range.end ?? (isCurrent ? now : range.start)).timeIntervalSince(range.start) + }() + let flagged: Bool = { + guard let elapsed, isDone, let baseline = initialEstimation else { return false } + let actualMinutes = elapsed / 60 + return EstimationComparator.is10PercentOff(estimation: Double(baseline), duration: actualMinutes) + }() + + HStack(spacing: 8) { + Text(glyph(isCurrent: isCurrent, isDone: isDone)) + .foregroundStyle(isCurrent ? Theme.accent : (isDone ? .green : Theme.dim)) + .frame(width: 12) + Text(step.title) + .monoBody() + .lineLimit(1) + .foregroundStyle(isDone ? Theme.dim : .primary) + if flagged { + Text(Theme.Glyph.flagged).foregroundStyle(Theme.flag) + } + Spacer() + Text("\(step.estimation)m").monoSmall().foregroundStyle(Theme.dim).frame(width: 36, alignment: .trailing) + if let elapsed { + Text(TimeFormat.clock(elapsed)).monoTimer().frame(width: 56, alignment: .trailing) + } else { + Text("—").monoSmall().foregroundStyle(Theme.dim).frame(width: 56, alignment: .trailing) + } + } + .padding(.horizontal, 12) + .frame(height: Theme.rowHeight) + .background(isCurrent ? Theme.accent.opacity(0.12) : .clear) + } + + private func glyph(isCurrent: Bool, isDone: Bool) -> String { + if isCurrent { return Theme.Glyph.active } + if isDone { return Theme.Glyph.done } + return Theme.Glyph.pending + } +} diff --git a/macos/Sources/FailWell/Views/Router.swift b/macos/Sources/FailWell/Views/Router.swift new file mode 100644 index 0000000..b503dbf --- /dev/null +++ b/macos/Sources/FailWell/Views/Router.swift @@ -0,0 +1,37 @@ +import Foundation +import Observation + +@MainActor +@Observable +final class Router { + enum Screen: Hashable, Sendable { + case list + case detail(taskId: String) + case editor(taskId: String?) + case record(taskId: String) + case history(taskId: String) + case settings + } + + var stack: [Screen] = [.list] + var showPalette: Bool = false + + var current: Screen { stack.last ?? .list } + + func push(_ screen: Screen) { + stack.append(screen) + } + + func replace(with screen: Screen) { + stack = [screen] + } + + func pop() { + guard stack.count > 1 else { return } + stack.removeLast() + } + + func popToRoot() { + stack = [.list] + } +} diff --git a/macos/Sources/FailWell/Views/SettingsView.swift b/macos/Sources/FailWell/Views/SettingsView.swift new file mode 100644 index 0000000..b3fcf5e --- /dev/null +++ b/macos/Sources/FailWell/Views/SettingsView.swift @@ -0,0 +1,17 @@ +import KeyboardShortcuts +import SwiftUI + +struct SettingsView: View { + var body: some View { + Form { + Section("Global hotkey") { + KeyboardShortcuts.Recorder("Toggle popover:", name: .togglePopover) + Text("Three modifiers + a letter is recommended — single- and two-modifier combos collide with system shortcuts.") + .font(Theme.monoSmall) + .foregroundStyle(Theme.dim) + } + } + .formStyle(.grouped) + .frame(width: 420, height: 200) + } +} diff --git a/macos/Sources/FailWell/Views/TaskDetailView.swift b/macos/Sources/FailWell/Views/TaskDetailView.swift new file mode 100644 index 0000000..2b2e7a8 --- /dev/null +++ b/macos/Sources/FailWell/Views/TaskDetailView.swift @@ -0,0 +1,132 @@ +import SwiftUI + +struct TaskDetailView: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + let taskId: String + + @FocusState private var focused: Bool + @State private var showDeleteConfirm = false + + private var task: TaskPlan? { store.task(id: taskId) } + + var body: some View { + if let task { + VStack(spacing: 0) { + header(task) + Divider() + stepList(task) + Divider() + footer + } + .focusable() + .focused($focused) + .focusEffectDisabled() + .onAppear { focused = true } + .onKeyPress(.init("s")) { startSession(); return .handled } + .onKeyPress(.init("e"), phases: .down) { event in + if event.modifiers.contains(.command) { + router.push(.editor(taskId: task.id)) + return .handled + } + return .ignored + } + .onKeyPress(.init("h"), phases: .down) { event in + if event.modifiers.contains(.command) { + router.push(.history(taskId: task.id)) + return .handled + } + return .ignored + } + .onKeyPress(.init("d"), phases: .down) { event in + if event.modifiers.contains(.command) { + if let copy = store.duplicateTask(id: task.id) { + router.replace(with: .detail(taskId: copy.id)) + } + return .handled + } + return .ignored + } + .onKeyPress(.delete) { + showDeleteConfirm = true + return .handled + } + .confirmationDialog("Delete \(task.title)?", isPresented: $showDeleteConfirm) { + Button("Delete", role: .destructive) { + store.deleteTask(id: task.id) + router.popToRoot() + } + Button("Cancel", role: .cancel) {} + } + } else { + ContentUnavailableView("Task not found", systemImage: "questionmark.folder") + .onAppear { router.popToRoot() } + } + } + + private func header(_ task: TaskPlan) -> some View { + HStack(spacing: 8) { + Button { router.pop() } label: { + Image(systemName: "chevron.left").foregroundStyle(Theme.dim) + } + .buttonStyle(.plain) + .keyboardShortcut(.leftArrow, modifiers: []) + + Text(task.title).monoTitle().lineLimit(1) + Spacer() + Text("\(task.totalEstimation)m").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private func stepList(_ task: TaskPlan) -> some View { + if task.steps.isEmpty { + VStack(spacing: 8) { + Text("no steps").monoBody().foregroundStyle(Theme.dim) + Text("⌘E add steps").monoSmall().foregroundStyle(Theme.dim) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(task.steps) { step in + HStack(spacing: 8) { + Text(Theme.Glyph.pending).foregroundStyle(Theme.dim) + Text(step.title).monoBody().lineLimit(1) + Spacer() + Text("\(step.estimation)m").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .frame(height: Theme.rowHeight) + } + } + } + } + } + + private var footer: some View { + HStack(spacing: 12) { + Text("s start").monoSmall().foregroundStyle(Theme.dim) + Text("⌘E edit").monoSmall().foregroundStyle(Theme.dim) + Text("⌘H history").monoSmall().foregroundStyle(Theme.dim) + Text("⌘D duplicate").monoSmall().foregroundStyle(Theme.dim) + Text("⌫ delete").monoSmall().foregroundStyle(Theme.dim) + Spacer() + Text("esc back").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func startSession() { + guard let task, !task.steps.isEmpty else { return } + store.initRecord(taskId: task.id) + // Reset any prior end and start fresh. + store.reset(taskId: task.id) + store.startStepRecord(taskId: task.id, stepId: task.steps[0].id) + router.push(.record(taskId: task.id)) + } +} diff --git a/macos/Sources/FailWell/Views/TaskEditor.swift b/macos/Sources/FailWell/Views/TaskEditor.swift new file mode 100644 index 0000000..223e55d --- /dev/null +++ b/macos/Sources/FailWell/Views/TaskEditor.swift @@ -0,0 +1,142 @@ +import SwiftUI + +struct TaskEditor: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + let taskId: String? + + @State private var title: String = "" + @State private var stepsText: String = "" + @State private var link: String = "" + @FocusState private var focusedField: Field? + + enum Field { case title, steps, link } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + form + Divider() + footer + } + .onAppear(perform: load) + .onKeyPress(.escape) { + // Save & exit if anything entered, else just exit. + saveAndExit() + return .handled + } + .onKeyPress(.init("s"), phases: .down) { event in + if event.modifiers.contains(.command) { + saveAndExit() + return .handled + } + return .ignored + } + } + + private var header: some View { + HStack(spacing: 8) { + Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) } + .buttonStyle(.plain) + Text(taskId == nil ? "new task" : "edit task").monoTitle() + Spacer() + Text("\(totalEstimation)m").monoSmall().foregroundStyle(Theme.dim) + Button("save") { saveAndExit() } + .keyboardShortcut(.return, modifiers: .command) + .buttonStyle(.borderedProminent) + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var form: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + LabeledField(label: "title") { + TextField("", text: $title, prompt: Text("e.g. refactor parser")) + .textFieldStyle(.roundedBorder) + .font(Theme.mono) + .focused($focusedField, equals: .title) + } + LabeledField(label: "steps") { + TextEditor(text: $stepsText) + .font(Theme.mono) + .frame(minHeight: 220) + .scrollContentBackground(.hidden) + .padding(6) + .background(Color(nsColor: .textBackgroundColor)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(Color(nsColor: .separatorColor))) + .focused($focusedField, equals: .steps) + } + Text("format: `- step | minutes`. indent with spaces or tab to group children under a parent.") + .monoSmall().foregroundStyle(Theme.dim) + + LabeledField(label: "link") { + TextField("", text: $link, prompt: Text("optional")) + .textFieldStyle(.roundedBorder) + .font(Theme.mono) + .focused($focusedField, equals: .link) + } + } + .padding(12) + } + .onAppear { focusedField = .title } + } + + private var footer: some View { + HStack(spacing: 12) { + Text("⌘↵ or ⌘S save").monoSmall().foregroundStyle(Theme.dim) + Text("esc save & back").monoSmall().foregroundStyle(Theme.dim) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private var totalEstimation: Int { + StepParser.adaptTextareaToSteps(stepsText).reduce(0) { $0 + $1.estimation } + } + + private func load() { + guard let taskId, let task = store.task(id: taskId) else { return } + title = task.title + stepsText = StepParser.adaptStepsToTextarea(task.steps) + link = task.link ?? "" + } + + private func saveAndExit() { + let trimmedTitle = title.trimmingCharacters(in: .whitespaces) + let steps = StepParser.adaptTextareaToSteps(stepsText) + guard !trimmedTitle.isEmpty, !steps.isEmpty else { + router.pop() + return + } + let id = taskId ?? UUID().uuidString + let existing = taskId.flatMap { store.task(id: $0) } + let stepHistory = (existing?.stepHistory ?? []) + [steps] + let task = TaskPlan( + id: id, + title: trimmedTitle, + date: existing?.date ?? Date(), + link: link.isEmpty ? nil : link, + stepHistory: stepHistory + ) + store.upsertTask(task) + router.replace(with: .detail(taskId: id)) + } +} + +private struct LabeledField: View { + let label: String + @ViewBuilder let content: () -> Content + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).monoSmall().foregroundStyle(Theme.dim) + content() + } + } +} diff --git a/macos/Sources/FailWell/Views/TaskHistoryView.swift b/macos/Sources/FailWell/Views/TaskHistoryView.swift new file mode 100644 index 0000000..9c7e610 --- /dev/null +++ b/macos/Sources/FailWell/Views/TaskHistoryView.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct TaskHistoryView: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + let taskId: String + + private var task: TaskPlan? { store.task(id: taskId) } + private var record: TaskRecord? { store.record(taskId: taskId) } + + var body: some View { + if let task { + VStack(spacing: 0) { + HStack(spacing: 8) { + Button { router.pop() } label: { Image(systemName: "chevron.left").foregroundStyle(Theme.dim) } + .buttonStyle(.plain) + Text("history · \(task.title)").monoTitle().lineLimit(1) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + Divider() + if let record, !record.stepRecords.isEmpty { + summary(task: task, record: record) + } else { + VStack { + Text("no recorded session yet").monoBody().foregroundStyle(Theme.dim) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } else { + ContentUnavailableView("Task not found", systemImage: "questionmark.folder") + .onAppear { router.popToRoot() } + } + } + + private func summary(task: TaskPlan, record: TaskRecord) -> some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(task.steps) { step in + let range = record.stepRecords[step.id] + let actual = range?.duration.map { $0 / 60 } ?? 0 + // Failure Signal: variance vs the Initial Plan, not the current Plan. + // CONTEXT.md §"Failure Signal". + let baseline = task.initialEstimation(for: step.id) + let flagged = range?.end != nil && baseline.map { + EstimationComparator.is10PercentOff(estimation: Double($0), duration: actual) + } ?? false + HStack(spacing: 8) { + Text(range?.end != nil ? Theme.Glyph.done : Theme.Glyph.pending) + .foregroundStyle(range?.end != nil ? .green : Theme.dim) + .frame(width: 12) + Text(step.title).monoBody().lineLimit(1) + if flagged { Text(Theme.Glyph.flagged).foregroundStyle(Theme.flag) } + Spacer() + Group { + if let baseline { + Text("est \(baseline)m").monoSmall() + } else { + Text("(added)").monoSmall() + } + } + .foregroundStyle(Theme.dim) + Text("actual \(Int(actual.rounded()))m").monoSmall() + .foregroundStyle(flagged ? Theme.flag : .primary) + .frame(width: 80, alignment: .trailing) + } + .padding(.horizontal, 12) + .frame(height: Theme.rowHeight) + } + if !record.notes.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("notes").monoSmall().foregroundStyle(Theme.dim).padding(.top, 8) + Text(record.notes).monoBody() + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } +} diff --git a/macos/Sources/FailWell/Views/TaskListView.swift b/macos/Sources/FailWell/Views/TaskListView.swift new file mode 100644 index 0000000..6fad893 --- /dev/null +++ b/macos/Sources/FailWell/Views/TaskListView.swift @@ -0,0 +1,160 @@ +import SwiftUI + +struct TaskListView: View { + @Environment(AppStore.self) private var store + @Environment(Router.self) private var router + + @State private var selection: Int = 0 + @FocusState private var focused: Bool + + private var tasks: [TaskPlan] { store.tasksByRecency } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + if tasks.isEmpty { + emptyState + } else { + list + } + Divider() + footer + } + .focusable() + .focused($focused) + .focusEffectDisabled() + .onAppear { focused = true } + .onKeyPress(.init("j")) { + selection = min(selection + 1, tasks.count - 1) + return .handled + } + .onKeyPress(.init("k")) { + selection = max(0, selection - 1) + return .handled + } + .onKeyPress(.downArrow) { + selection = min(selection + 1, tasks.count - 1) + return .handled + } + .onKeyPress(.upArrow) { + selection = max(0, selection - 1) + return .handled + } + .onKeyPress(.return) { + openSelected() + return .handled + } + .onKeyPress(.init("n"), phases: .down) { event in + if event.modifiers.contains(.command) { + router.push(.editor(taskId: nil)) + return .handled + } + return .ignored + } + .onKeyPress(.init("v"), phases: .down) { event in + if event.modifiers.contains(.command) { + importFromClipboard() + return .handled + } + return .ignored + } + .onKeyPress(.delete) { deleteSelected() } + .onKeyPress(.deleteForward) { deleteSelected() } + } + + private var header: some View { + HStack { + Text("tasks").monoTitle().foregroundStyle(Theme.dim) + Spacer() + Text("\(tasks.count)").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private var list: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(tasks.enumerated()), id: \.element.id) { index, task in + TaskRow(task: task, selected: index == selection) + .id(task.id) + .onTapGesture { selection = index; openSelected() } + } + } + } + .onChange(of: selection) { _, new in + guard new >= 0, new < tasks.count else { return } + proxy.scrollTo(tasks[new].id, anchor: .center) + } + } + } + + private var emptyState: some View { + VStack(spacing: 8) { + Text("no tasks yet").monoBody().foregroundStyle(Theme.dim) + Text("⌘N new · ⌘V paste · ⌘K commands").monoSmall().foregroundStyle(Theme.dim) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var footer: some View { + HStack(spacing: 12) { + Text("j/k move").monoSmall().foregroundStyle(Theme.dim) + Text("↵ open").monoSmall().foregroundStyle(Theme.dim) + Text("⌘N new").monoSmall().foregroundStyle(Theme.dim) + Text("⌘V paste").monoSmall().foregroundStyle(Theme.dim) + Spacer() + Text("⌘K palette").monoSmall().foregroundStyle(Theme.dim) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + private func openSelected() { + guard selection >= 0, selection < tasks.count else { return } + router.push(.detail(taskId: tasks[selection].id)) + } + + private func deleteSelected() -> KeyPress.Result { + guard selection >= 0, selection < tasks.count else { return .ignored } + let id = tasks[selection].id + store.deleteTask(id: id) + selection = max(0, min(selection, tasks.count - 1)) + return .handled + } + + private func importFromClipboard() { + guard let draft = ClipboardImporter.currentDraft() else { return } + let task = TaskPlan( + id: UUID().uuidString, + title: draft.title ?? "Untitled", + date: Date(), + link: nil, + stepHistory: [draft.steps] + ) + store.upsertTask(task) + router.push(.detail(taskId: task.id)) + } +} + +private struct TaskRow: View { + let task: TaskPlan + let selected: Bool + + var body: some View { + HStack(spacing: 8) { + Text(selected ? "▸" : " ").monoBody().foregroundStyle(Theme.accent) + Text(task.title).monoBody().lineLimit(1) + Spacer() + Text("\(task.steps.count)").monoSmall().foregroundStyle(Theme.dim).frame(width: 24, alignment: .trailing) + Text("\(task.totalEstimation)m").monoSmall().foregroundStyle(Theme.dim).frame(width: 36, alignment: .trailing) + Text(TimeFormat.date(task.date)).monoSmall().foregroundStyle(Theme.dim).frame(width: 70, alignment: .trailing) + } + .padding(.horizontal, 12) + .frame(height: Theme.rowHeight) + .background(selected ? Theme.accent.opacity(0.12) : .clear) + .contentShape(Rectangle()) + } +} diff --git a/macos/Sources/FailWell/Views/Theme.swift b/macos/Sources/FailWell/Views/Theme.swift new file mode 100644 index 0000000..7b05ad4 --- /dev/null +++ b/macos/Sources/FailWell/Views/Theme.swift @@ -0,0 +1,32 @@ +import SwiftUI + +enum Theme { + static let mono: Font = .system(size: 12, weight: .regular, design: .monospaced) + static let monoSmall: Font = .system(size: 11, weight: .regular, design: .monospaced) + static let monoBold: Font = .system(size: 12, weight: .semibold, design: .monospaced) + static let monoTitle: Font = .system(size: 13, weight: .semibold, design: .monospaced) + static let monoTimer: Font = .system(size: 14, weight: .medium, design: .monospaced).monospacedDigit() + + static let rowHeight: CGFloat = 24 + static let popoverWidth: CGFloat = 420 + static let popoverHeight: CGFloat = 520 + + static let dim: Color = .secondary + static let accent: Color = .accentColor + static let flag: Color = .orange + + enum Glyph { + static let pending = "·" + static let active = "▶" + static let done = "✓" + static let flagged = "!" + static let paused = "⏸" + } +} + +extension View { + func monoBody() -> some View { self.font(Theme.mono) } + func monoSmall() -> some View { self.font(Theme.monoSmall) } + func monoTitle() -> some View { self.font(Theme.monoTitle) } + func monoTimer() -> some View { self.font(Theme.monoTimer) } +} diff --git a/macos/Tests/FailWellTests/BreakCalculatorTests.swift b/macos/Tests/FailWellTests/BreakCalculatorTests.swift new file mode 100644 index 0000000..b78f37a --- /dev/null +++ b/macos/Tests/FailWellTests/BreakCalculatorTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import FailWell + +@Suite("BreakCalculator") +struct BreakCalculatorTests { + @Test func passesThroughWhenNoBreakTime() { + let record = TaskRecord( + taskId: "t1", + stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))], + breakTime: nil + ) + #expect(BreakCalculator.applyingBreakTime(to: record) == record) + } + + @Test func passesThroughWhenBreakNotEnded() { + let record = TaskRecord( + taskId: "t1", + stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))], + breakTime: TimeRange(start: date("2023-04-17T19:00:00Z")) + ) + #expect(BreakCalculator.applyingBreakTime(to: record) == record) + } + + @Test func shiftsUnfinishedStepStartsByBreakDuration() { + let record = TaskRecord( + taskId: "t1", + stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))], + breakTime: TimeRange( + start: date("2023-04-17T19:00:00Z"), + end: date("2023-04-17T20:00:00Z") + ) + ) + let result = BreakCalculator.applyingBreakTime(to: record) + #expect(result.stepRecords["step-id-1"]?.start == date("2023-04-17T19:00:00Z")) + } + + @Test func doesNotShiftFinishedStepRecords() { + let record = TaskRecord( + taskId: "t1", + stepRecords: [ + "step-id-1": TimeRange( + start: date("2023-04-17T17:00:00Z"), + end: date("2023-04-17T18:00:00Z") + ), + "step-id-2": TimeRange(start: date("2023-04-17T18:00:00Z")) + ], + breakTime: TimeRange( + start: date("2023-04-17T19:00:00Z"), + end: date("2023-04-17T20:00:00Z") + ) + ) + let result = BreakCalculator.applyingBreakTime(to: record) + #expect(result.stepRecords["step-id-1"]?.start == date("2023-04-17T17:00:00Z")) + #expect(result.stepRecords["step-id-1"]?.end == date("2023-04-17T18:00:00Z")) + #expect(result.stepRecords["step-id-2"]?.start == date("2023-04-17T19:00:00Z")) + } + + private func date(_ iso: String) -> Date { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)! + } +} diff --git a/macos/Tests/FailWellTests/EstimationComparatorTests.swift b/macos/Tests/FailWellTests/EstimationComparatorTests.swift new file mode 100644 index 0000000..3602fe5 --- /dev/null +++ b/macos/Tests/FailWellTests/EstimationComparatorTests.swift @@ -0,0 +1,13 @@ +import Testing +@testable import FailWell + +@Suite("EstimationComparator") +struct EstimationComparatorTests { + @Test func flagsDurationsMoreThan10PercentOff() { + // From `compare-with-estimation.test.ts`. + #expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 9) == false) + #expect(EstimationComparator.is10PercentOff(estimation: 5, duration: 4) == false) + #expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 5) == true) + #expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 8) == true) + } +} diff --git a/macos/Tests/FailWellTests/StepParserTests.swift b/macos/Tests/FailWellTests/StepParserTests.swift new file mode 100644 index 0000000..1db01b0 --- /dev/null +++ b/macos/Tests/FailWellTests/StepParserTests.swift @@ -0,0 +1,167 @@ +import Testing +@testable import FailWell + +@Suite("StepParser") +struct StepParserTests { + @Test func adaptsStepsToTextarea() { + let steps = [ + Step(id: "a", title: "step 1", estimation: 3), + Step(id: "b", title: "step 2", estimation: 4), + Step(id: "c", title: "step 3", estimation: 5) + ] + #expect(StepParser.adaptStepsToTextarea(steps) == "- step 1 | 3\n- step 2 | 4\n- step 3 | 5") + } + + @Test func parsesTextareaIntoSteps() { + let input = """ + - step 1 | 3 + - step 2 | 4 + - step 3 | 5 + """ + let steps = StepParser.adaptTextareaToSteps(input) + #expect(steps.map(\.title) == ["step 1", "step 2", "step 3"]) + #expect(steps.map(\.estimation) == [3, 4, 5]) + } + + @Test func fallsBackToZeroWhenNoEstimation() { + let steps = StepParser.adaptTextareaToSteps("- step 1") + #expect(steps.count == 1) + #expect(steps[0].title == "step 1") + #expect(steps[0].estimation == 0) + } + + @Test func fallsBackToZeroWhenEstimationUnparseable() { + let steps = StepParser.adaptTextareaToSteps("- step 1 | not an estimation") + #expect(steps.count == 1) + #expect(steps[0].title == "step 1") + #expect(steps[0].estimation == 0) + } + + @Test func skipsEmptyTitles() { + let steps = StepParser.adaptTextareaToSteps("\n-") + #expect(steps.isEmpty) + } + + @Test func generatesStableIdsFromTitleAlone() { + // Step identity is title-only (md5(title) + duplicate suffix). Re-estimating + // the same title must yield the same id so the Step Record survives. + let single = StepParser.adaptTextareaToSteps("- step 1 | 3") + #expect(single.first?.id == "7eedced2241bcccda700ccac1b5915d6-1") + + let duped = StepParser.adaptTextareaToSteps(""" + - step duplicated | 3 + - step duplicated | 3 + """) + #expect(duped.map(\.id) == [ + "b53d37cc4caaf9d8d4f1e459e335a242-1", + "b53d37cc4caaf9d8d4f1e459e335a242-2" + ]) + } + + @Test func reestimationPreservesStepIdentity() { + // CONTEXT.md §"Re-estimation": changing the estimation never destroys data. + // Same title with different estimations must produce the same Step id. + let original = StepParser.adaptTextareaToSteps("- refactor parser | 5") + let reestimated = StepParser.adaptTextareaToSteps("- refactor parser | 12") + #expect(original.first?.id == reestimated.first?.id) + #expect(original.first?.estimation == 5) + #expect(reestimated.first?.estimation == 12) + } + + @Test func parsesUncheckedCheckboxes() { + let steps = StepParser.adaptTextareaToSteps("- [ ] step with checkbox | 20") + #expect(steps.count == 1) + #expect(steps[0].title == "step with checkbox") + #expect(steps[0].estimation == 20) + } + + @Test func parsesCheckedCheckboxes() { + let steps = StepParser.adaptTextareaToSteps("- [x] completed step | 15") + #expect(steps.count == 1) + #expect(steps[0].title == "completed step") + #expect(steps[0].estimation == 15) + } + + @Test func parsesCheckboxesWithoutEstimation() { + let steps = StepParser.adaptTextareaToSteps("- [ ] step without estimation") + #expect(steps.count == 1) + #expect(steps[0].title == "step without estimation") + #expect(steps[0].estimation == 0) + } + + @Suite("subtask support") + struct SubtaskTests { + @Test func flattensIndentedSubtasksWithParentPrefix() { + let input = """ + - Parent task | 5 + - Child task 1 | 3 + - Child task 2 | 2 + """ + let steps = StepParser.adaptTextareaToSteps(input) + #expect(steps.map(\.title) == ["(Parent task) - Child task 1", "(Parent task) - Child task 2"]) + #expect(steps.map(\.estimation) == [3, 2]) + } + + @Test func ignoresParentEstimationWhenItHasChildren() { + let steps = StepParser.adaptTextareaToSteps(""" + - Parent | 10 + - Child | 3 + """) + #expect(steps.count == 1) + #expect(steps[0].title == "(Parent) - Child") + #expect(steps[0].estimation == 3) + } + + @Test func treatsStandaloneItemsAsRegularSteps() { + let steps = StepParser.adaptTextareaToSteps("- Standalone task | 4") + #expect(steps.count == 1) + #expect(steps[0].title == "Standalone task") + #expect(steps[0].estimation == 4) + } + + @Test func handlesMixedSubtasksAndStandaloneItems() { + let input = """ + - Parent task | 5 + - Child task 1 | 3 + - Child task 2 | 2 + - Standalone | 4 + """ + let steps = StepParser.adaptTextareaToSteps(input) + #expect(steps.map(\.title) == [ + "(Parent task) - Child task 1", + "(Parent task) - Child task 2", + "Standalone" + ]) + #expect(steps.map(\.estimation) == [3, 2, 4]) + } + + @Test func handlesTabIndentation() { + let steps = StepParser.adaptTextareaToSteps("- Parent | 5\n\t- Child | 3") + #expect(steps.count == 1) + #expect(steps[0].title == "(Parent) - Child") + } + + @Test func treatsOrphanIndentedLineAsRegularStep() { + let steps = StepParser.adaptTextareaToSteps(" - Orphan indented | 3") + #expect(steps.count == 1) + #expect(steps[0].title == "Orphan indented") + #expect(steps[0].estimation == 3) + } + + @Test func handlesMultipleParentsWithChildren() { + let input = """ + - Parent 1 | 5 + - Child 1a | 2 + - Child 1b | 3 + - Parent 2 | 4 + - Child 2a | 1 + """ + let steps = StepParser.adaptTextareaToSteps(input) + #expect(steps.map(\.title) == [ + "(Parent 1) - Child 1a", + "(Parent 1) - Child 1b", + "(Parent 2) - Child 2a" + ]) + } + } +} diff --git a/macos/Tests/FailWellTests/TaskPlanTests.swift b/macos/Tests/FailWellTests/TaskPlanTests.swift new file mode 100644 index 0000000..1f2ffb6 --- /dev/null +++ b/macos/Tests/FailWellTests/TaskPlanTests.swift @@ -0,0 +1,51 @@ +import Testing +@testable import FailWell + +@Suite("TaskPlan — Initial Plan baseline") +struct TaskPlanTests { + @Test func initialEstimationReturnsInitialPlanValueAfterReestimation() { + // CONTEXT.md §"Failure Signal" — the honest baseline is the Initial Plan, + // even after the developer re-estimates mid-Execution. + let initialSteps = StepParser.adaptTextareaToSteps("- refactor parser | 5") + var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps]) + + // Re-estimate: same title, new estimation → same Step id, new Step entry. + let reestimated = StepParser.adaptTextareaToSteps("- refactor parser | 12") + task.newSteps(reestimated) + + let stepId = task.steps[0].id + #expect(initialSteps[0].id == stepId) // identity preserved across re-estimation + #expect(task.steps[0].estimation == 12) // current Plan reflects the new number + #expect(task.initialEstimation(for: stepId) == 5) // baseline reflects the commitment + } + + @Test func initialEstimationReturnsNilForStepsAddedMidExecution() { + let initialSteps = StepParser.adaptTextareaToSteps("- planned step | 5") + var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps]) + + let addedLater = StepParser.adaptTextareaToSteps(""" + - planned step | 5 + - unplanned step | 3 + """) + task.newSteps(addedLater) + + let addedStepId = task.steps[1].id + #expect(task.initialEstimation(for: addedStepId) == nil) // no baseline → no Failure Signal + } + + @Test func failureSignalFiresAgainstInitialPlanNotCurrent() { + // Scenario from the CONTEXT.md challenge: planned 5m, drifted, re-estimated to 12m, + // finished at 12m. Honest signal must fire because we missed the *original* commitment. + let initialSteps = StepParser.adaptTextareaToSteps("- step | 5") + var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps]) + task.newSteps(StepParser.adaptTextareaToSteps("- step | 12")) + + let stepId = task.steps[0].id + let baseline = task.initialEstimation(for: stepId)! + let actualMinutes = 12.0 + + #expect(EstimationComparator.is10PercentOff(estimation: Double(baseline), duration: actualMinutes) == true) + // Confirm the silent-signal bug: comparing against the *current* (re-estimated) value would hide it. + #expect(EstimationComparator.is10PercentOff(estimation: Double(task.steps[0].estimation), duration: actualMinutes) == false) + } +}