Compare commits

...

4 Commits

Author SHA1 Message Date
Julien Calixte
2a4a3ed9f7 fix(macos): drop status item title while notch overlay is visible
The notch overlay wings already display elapsed time and step title;
keeping them on the status item too produced a duplicate readout
elsewhere in the menu bar.
2026-05-15 14:41:30 +02:00
Julien Calixte
fe75df4e0e refactor(macos): route notch geometry through overlay state
Store NotchGeometry on NotchOverlayState so the SwiftUI content can read
it reactively instead of receiving it via initializer. Lets the
controller drive frame updates when geometry changes and simplifies the
panel resize to NSPanel's built-in animate flag.
2026-05-15 12:05:35 +02:00
Julien Calixte
030ab950eb 2026-05-15 11:29:37 2026-05-15 11:29:37 +02:00
Julien Calixte
29a71a73b2 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.
2026-05-15 11:27:51 +02:00
34 changed files with 2612 additions and 1 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,118 @@
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)
if notchOverlay?.isShowing == true {
button.title = ""
} else {
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,257 @@
import SwiftUI
struct NotchOverlayContent: View {
@Environment(AppStore.self) private var store
@Environment(Clock.self) private var clock
let state: NotchOverlayState
var body: some View {
if let geometry = state.geometry,
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, geometry: geometry)
} else {
Color.clear
}
}
private func island(
active: (task: TaskPlan, record: TaskRecord),
step: Step,
range: TimeRange,
geometry: NotchGeometry
) -> 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)
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
)
.padding(.top, menuBarH)
.transition(.opacity)
}
}
.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,161 @@
import AppKit
import SwiftUI
import Observation
@MainActor
@Observable
final class NotchOverlayState {
var isExpanded = false
var geometry: NotchGeometry?
}
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>?
var isShowing: Bool { panel != nil }
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 geometry = notchGeometry()
let shouldShow = store.activeRecord != nil && geometry != nil
if shouldShow, let geometry {
let geometryChanged = state.geometry != geometry
state.geometry = geometry
ensurePanel()
if geometryChanged {
updateFrame(animated: false)
}
} 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 = state.geometry 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)
.environment(store)
.environment(clock)
let hosting = NSHostingController(rootView: content)
hosting.view.wantsLayer = true
hosting.view.layer?.backgroundColor = .clear
hosting.view.layerContentsPlacement = .topLeft
hosting.view.layerContentsRedrawPolicy = .duringViewResize
panel.contentViewController = hosting
panel.orderFront(nil)
self.panel = panel
}
private func updateFrame(animated: Bool) {
guard let panel, let geometry = state.geometry else { return }
let newFrame = panelFrame(geometry: geometry, expanded: state.isExpanded)
if panel.frame == newFrame { return }
panel.setFrame(newFrame, display: true, animate: animated)
}
private func tearDown() {
state.isExpanded = false
state.geometry = nil
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)
}
}

View File

@@ -9,7 +9,7 @@ const label = computed(() => `${props.estimation} minutes`)
<template> <template>
<div class="estimation-time-arrival tags has-addons"> <div class="estimation-time-arrival tags has-addons">
<div class="tag">ETA</div> <div class="tag">est</div>
<div class="tag is-primary" data-test="tag-label"> <div class="tag is-primary" data-test="tag-label">
{{ label }} {{ label }}
</div> </div>