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.
This commit is contained in:
Julien Calixte
2026-05-15 11:27:51 +02:00
parent 0c0d555ac1
commit 29a71a73b2
33 changed files with 2602 additions and 0 deletions

7
macos/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.build/
.swiftpm/
.DS_Store
DerivedData/
FailWell.app/
*.xcodeproj/
Package.resolved

36
macos/Info.plist Normal file
View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Fail Well</string>
<key>CFBundleExecutable</key>
<string>FailWell</string>
<key>CFBundleIdentifier</key>
<string>io.calixte.failwell.macos</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Fail Well</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>15.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticTermination</key>
<true/>
<key>NSSupportsSuddenTermination</key>
<true/>
</dict>
</plist>

38
macos/Makefile Normal file
View File

@@ -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)

24
macos/Package.swift Normal file
View File

@@ -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"
)
]
)

37
macos/README.md Normal file
View File

@@ -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.

View File

@@ -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<Void, Never>?
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 = ""
}
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
struct Step: Codable, Identifiable, Hashable, Sendable {
let id: String
var title: String
/// estimation in minutes
var estimation: Int
}

View File

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

View File

@@ -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
}
}

View File

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

View File

@@ -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<Void, Never>?
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
}
}

View File

@@ -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
}
}

View File

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

View File

@@ -0,0 +1,25 @@
import Foundation
import Observation
@MainActor
@Observable
final class Clock {
var now: Date = Date()
@ObservationIgnored private var tickTask: Task<Void, Never>?
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()
}
}

View File

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

View File

@@ -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()
}
}

View File

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

View File

@@ -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
}
}

View File

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

View File

@@ -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
}
}

View File

@@ -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<Void, Never>?
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()
}
}

View File

@@ -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<String> {
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
}
}

View File

@@ -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]
}
}

View File

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

View File

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

View File

@@ -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<Content: View>: View {
let label: String
@ViewBuilder let content: () -> Content
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(label).monoSmall().foregroundStyle(Theme.dim)
content()
}
}
}

View File

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

View File

@@ -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())
}
}

View File

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

View File

@@ -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)!
}
}

View File

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

View File

@@ -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"
])
}
}
}

View File

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