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)
+ }
+}