Compare commits

...

7 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
Julien Calixte
0c0d555ac1 docs: add implementation plan to align code with feedback loop ADR
Vertical-slice plan with five slices across four phases, each with
acceptance criteria, manual verification, and human-review checkpoints
between phases. Surfaces and orders the inconsistencies between the
current code and the per-task loop language. Flat todo.md mirrors the
plan as a checkable task list.
2026-05-15 11:26:06 +02:00
Julien Calixte
0ad89f06ef docs: record ADR 0001 for per-task feedback loop design
Captures the four coupled commitments that define Fail Well's per-task
loop: Initial Plan anchored at Start-of-Execution, Failure Signal with
three named modes (Estimation Variance, Discovered Scope, Abandoned
Scope), distinct affordances for Re-estimation vs Re-planning, and a
dedicated Closing Ceremony. Also records the alternatives considered
and rejected so future readers don't relitigate them.
2026-05-15 11:26:01 +02:00
Julien Calixte
5796c50566 docs: add CONTEXT.md ubiquitous language for per-task loop
Establishes the shared vocabulary for Fail Well: Task, Step, Plan,
Step History, Initial Plan, Execution, Step Record, Notes, plus the
Failure Signal (with its three named modes), Step-Back Signal, Closing
Ceremony, Re-estimation, and Re-planning. The glossary anchors all
downstream decisions about the per-task feedback loop.
2026-05-15 11:25:55 +02:00
38 changed files with 3105 additions and 1 deletions

75
CONTEXT.md Normal file
View File

@@ -0,0 +1,75 @@
# Context — Ubiquitous Language
This glossary defines the shared vocabulary for Fail Well. Use these terms verbatim in code, tests, commits, and conversation. No implementation details — this is a glossary, not a spec.
## Core terms
### Task
A unit of developer work, planned once and executed at most once. The atomic "thing the developer is doing right now." A Task carries a title, an optional link, a date, and a Plan that may evolve over the course of execution.
### Step
An atomic item inside a Plan. Has a title and a time **Estimation** (minutes). Steps are the granularity at which the developer thinks about and times their work.
### Plan
The ordered list of Steps the developer intends to do. The current Plan may differ from earlier versions — the developer can edit the Plan, and every edit is preserved in **Step History**.
### Step History
The append-only chain of Plan revisions for a Task. The first entry is the **Initial Plan**; the last entry is the current Plan.
### Initial Plan
The Plan version active **at the moment Execution begins** — i.e., the Plan the developer committed to when the first Step Record started. This is the baseline the per-task loop measures against; it is the version that *cannot lie to itself*.
Pre-Execution edits (saving, redrafting, refining before pressing Start) do **not** affect the Initial Plan — those are still planning, not execution. The Initial Plan freezes when contact-with-reality begins, not when the first draft is saved.
### Execution
The single, timed pass through the Plan. Each Task has at most one Execution.
### Step Record
The timed range (start → end) within an Execution for a single Step. Step Records are keyed by Step id, so they survive Plan edits as long as the Step itself does.
## The iteration model — Per-Task Loop
Fail Well is built around a single per-task feedback loop: the developer plans, executes, and compares plan-vs-actual *within one Task*. There is no cross-run comparison — a Task is executed at most once.
Two variance signals exist, with deliberately different framing:
### Failure Signal — variance vs the Initial Plan
The honest signal. "Did my morning self predict correctly?" Computed against **the Initial Plan**, so re-estimating mid-flight cannot silence it. This is the signal the developer learns from — the "fail well" of the product name. Variance here is **not bad**; it is the data the loop produces.
The Failure Signal has three named modes, each a distinct learning vocabulary. The common case (no Re-planning) collapses to the first.
- **Estimation Variance** — for a Step that exists in both the Initial Plan and the Final Plan (identity preserved by id): actual duration minus the Initial Plan's estimation for that Step. *"I underestimated this."*
- **Discovered Scope** — Steps in the Final Plan that were *not* in the Initial Plan. Work the developer added mid-flight because they realised it was missing. *"I didn't foresee this."*
- **Abandoned Scope** — Steps in the Initial Plan that are *not* in the Final Plan (deleted before being executed). Work the developer planned and then decided was unnecessary or wrong-shaped. *"I over-planned."*
### Step-Back Signal — variance vs the latest re-estimate
A softer, real-time signal. When a developer re-estimates a Step mid-execution to reflect new reality, the comparison against that *new* estimate becomes a gentle prompt: *"this is still drifting — take a moment to reconsider."* It is **not framed as failure** — re-estimation is a healthy response to new information, not an admission of bad planning.
### Closing Ceremony
The dedicated end-of-Task moment where the loop closes. Surfaces the Failure Signal in full — per-Step variance against the Initial Plan, total variance, and the shape of any re-planning that happened. The Closing Ceremony is where "fail well" earns the product its name: the failure is named, surfaced, and digested in one place, not hidden behind a single sentence.
### Notes
Free-form text attached to an Execution. Deliberately **dual-purpose**: written *during* execution (capture thoughts as they happen) **and** *after* execution (close the loop with reflection). One textarea, two moments. There is intentionally no separate "reflection field" — the same place catches both.
## Mid-flight edits — two kinds
Editing during Execution is not one operation; it is two, with different weight.
### Re-estimation
Changing a Step's **Estimation** value while the rest of the Plan structure (titles, order, identity) is unchanged. Cheap, frequent, low ceremony. Feeds the Step-Back Signal. Never destroys data.
### Re-planning
Changing the *structure* of the Plan during Execution — adding, removing, reordering, or renaming Steps. **Rare and deliberate.** A re-planning is itself a strong failure signal — the developer is admitting the Plan's shape was wrong, not just its numbers. Re-planning should preserve every Step Record whose Step still exists by identity.

View File

@@ -0,0 +1,42 @@
---
status: accepted
---
# Per-Task Feedback Loop — Initial Plan baseline, three failure modes, and a Closing Ceremony
## Context
Fail Well exists to support a per-task feedback loop: a developer plans, executes, and learns from the variance — within a single execution of a single Task. The product's name commits to the framing that variance is data, not failure-to-avoid. For that framing to be honest, the loop needs a baseline that cannot be edited away mid-flight, and a moment where the loop visibly closes.
When this ADR was written, the code did *not* match that intent: per-Step variance was computed against the *current* Plan (so re-estimation mid-flight silenced the signal), `stepHistory[0]` (the first save, possibly a brainstorming draft) was treated as the baseline, mid-flight Plan edits could silently destroy Step Records, and the post-Execution UI was a single colored sentence with no dedicated reflection surface.
## Decision
The per-task feedback loop is defined by four coupled commitments:
1. **Initial Plan is anchored at Start-of-Execution**, not at first save. The Initial Plan is the Plan version active at the moment the first Step Record begins. Pre-Execution drafting does not pollute the baseline.
2. **The Failure Signal compares against the Initial Plan**, with three named modes:
- **Estimation Variance** — for Steps present in both Initial and Final Plan: actual duration minus Initial Plan estimation.
- **Discovered Scope** — Steps added to the Final Plan that were not in the Initial Plan ("work I didn't foresee").
- **Abandoned Scope** — Steps in the Initial Plan that were never executed ("work I over-planned").
Variance against the *latest* re-estimate is preserved as a separate, softer **Step-Back Signal** — a real-time prompt to reconsider, not framed as failure.
3. **Re-estimation and Re-planning are distinct operations.** Re-estimation (changing a Step's `estimation` number, Plan structure unchanged) is cheap, frequent, and non-destructive. Re-planning (add / delete / reorder / rename Steps) is deliberate and rare — it is itself a strong failure-mode signal. Re-planning must preserve every Step Record whose Step still exists by id; orphan-or-destroy behaviour is incompatible with the loop.
4. **The loop closes in a dedicated Closing Ceremony** — a real end-of-Task surface that names the three failure modes, surfaces the Initial-vs-Final Plan shape if Re-planning happened, and hosts the Notes textarea as the reflection input. The existing plan-version archaeology view is replaced by this ceremony; intermediate `stepHistory` entries remain in the data model but are not the primary artifact rendered.
## Consequences
- The `TaskRecord` (or equivalent) must capture the Initial Plan at Execution start — `stepHistory[0]` alone is no longer sufficient.
- `cleanCurrentStepId` and the mid-Execution edit flow must be redesigned around the Re-estimation / Re-planning split.
- The single `NewStepsForm` modal currently used for initial drafting, mid-flight edits, and (transitively) the Re-estimation / Re-planning conflation must be split into distinct affordances.
- The Closing Ceremony view replaces `TaskHistory.vue`'s current adjacent-version diff behaviour.
## Considered alternatives
- **Compare against the latest Plan** (status quo): rejected — silences the signal on re-estimation, defeats the loop's purpose.
- **First-appearance baseline** (each Step compares against the version it first appeared in): rejected — blurs what "Initial Plan" means and loses the Discovered / Abandoned Scope vocabulary.
- **Totals-only comparison when Re-planning occurs**: rejected — loses per-Step diagnostic value precisely in the cases where the loop produces the most learning.
- **Intentional minimalism** (no Closing Ceremony, raw numbers only): rejected — the product's name commits to a named, surfaced failure; one colored sentence does not deliver that.

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>
<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">
{{ label }}
</div>

260
tasks/plan.md Normal file
View File

@@ -0,0 +1,260 @@
# Plan — Align code with the Per-Task Feedback Loop
This plan implements the decisions recorded in [`docs/adr/0001-per-task-feedback-loop.md`](../docs/adr/0001-per-task-feedback-loop.md) and the language in [`CONTEXT.md`](../CONTEXT.md). It closes the inconsistencies surfaced during the deep-design session.
Each slice is vertical: it touches model → store → UI → tests for one user-visible behaviour, rather than refactoring a layer at a time. Phases gate on checkpoints.
## Goals (recap)
1. The Failure Signal compares actual durations against the **Initial Plan**, defined as the Plan version active at the moment Execution begins.
2. Re-planning (mid-Execution structural Plan changes) preserves every Step Record whose Step still exists by id.
3. The loop closes in a dedicated **Closing Ceremony** that names three Failure modes: Estimation Variance, Discovered Scope, Abandoned Scope.
4. Re-estimation (changing a number) and Re-planning (changing the shape) are distinct affordances.
## Dependency graph
```
Phase 1 — Foundation
├─ Slice 1 (Initial Plan capture) ──┐
└─ Slice 2 (Preserve records on re-plan)─┴─▶ Phase 2
Phase 2 — Closing the loop
└─ Slice 3 (Closing Ceremony view) ──▶ Phase 3
Phase 3 — UX split
└─ Slice 4 (Re-estimation inline / Re-planning modal) ──▶ Phase 4
Phase 4 — Cleanup
└─ Slice 5 (Model & naming hygiene)
```
Slice 4 is technically parallelisable with Slice 3 once Slice 2 lands, but is sequenced after Slice 3 to keep the Closing Ceremony available as the source of truth while reshaping the during-Execution UI.
---
## Phase 1 — Foundation
### Slice 1 — Anchor the Initial Plan at Start-of-Execution
**Goal.** The `Recordable` stores the Plan that was active when its first Step Record began. The Failure Signal reads from this snapshot, not from the current Plan.
**Files in scope.**
- `src/modules/record/interfaces/recordable.ts` — add `initialPlan: Stepable[] | null`.
- `src/modules/record/models/task-record.ts` — initialize and round-trip `initialPlan` in `fromRecordable`.
- `src/modules/record/stores/useTaskRecordStore.ts:50-85` — in `startStepRecord`, when `stepRecords` is empty, snapshot `task.steps` into `record.initialPlan` (only if it isn't already set).
- `src/modules/record/components/StepRecord.vue:66-69` — replace `step.value.estimation` with the Initial Plan's estimation for the same Step id; fall back to `step.value.estimation` only when no match.
- `src/modules/record/components/RecordResume.vue:14-20` — total comparison uses sum of Initial Plan estimations, not `task.totalEstimation` (which reflects current Plan).
- `src/modules/record/services/compare-with-estimation.ts` — keep as is (still pure); update its callers.
- Tests: extend `task-record.test.ts`, `compare-with-estimation.test.ts`, and add new unit tests for the snapshot behaviour.
**Acceptance criteria.**
- Pressing Start (first Step Record) captures `task.steps` into `record.initialPlan`. Verified in a unit test against the store.
- Re-estimating a Step during Execution does **not** mutate `record.initialPlan`.
- The "off by 10%" indicator on a Step compares against the Initial Plan's estimation for that Step id.
- Existing persisted records (without `initialPlan`) gracefully fall back to `task.stepHistory[0]`. This fallback path is covered by a unit test.
- `pnpm test` passes (lint + types + unit).
**Manual verification.**
- Create a Task with Steps at 5 / 10 / 10 min.
- Start recording. Re-estimate the first Step to 30 min via the existing edit modal.
- The first row's "off" indicator continues to reference 5 min (not 30) once the Step has elapsed more than ~30 seconds.
**Risks / migration.**
- Persisted localStorage data lacks `initialPlan`. Fallback covers display; new data will populate it. Document the fallback in a code comment.
- `RecordResume` total now diverges from `task.totalEstimation` if Re-planning occurred. Acceptable — see Slice 3 for full surface.
---
### Slice 2 — Preserve Step Records when the Plan changes mid-Execution
**Goal.** Re-planning during Execution never destroys recorded time. Step Records keyed by id survive Plan edits; Steps removed from the Plan keep their orphan records for the Closing Ceremony to inspect.
**Files in scope.**
- `src/modules/record/stores/useTaskRecordStore.ts:193-225` — replace `cleanCurrentStepId` with a non-destructive reconciliation:
- Find the first Step in the new Plan that has no `end` in the existing records. That becomes the new `currentStepId`.
- If that Step has no existing record yet, start a new one from the latest end-of-completed-step time (or `Date.now()` if none).
- **Do not delete** any existing Step Record. Orphan records (no longer in the Plan) remain in `stepRecords`.
- `src/modules/record/stores/useTaskRecordStore.ts:20-39``syncTaskRecord` currently wipes the whole record when any recorded Step id is missing from the Plan. Replace with the same non-destructive reconciliation.
- Tests: add scenarios in `useTaskRecordStore.test.ts` (new file if missing) for reorder, delete-in-progress, delete-completed, insert-at-start.
**Acceptance criteria.**
- Reorder during recording — start times of all completed Steps preserved; in-progress Step's start preserved.
- Delete a completed Step during recording — its record is preserved as an orphan in `stepRecords` (still keyed by id, not in current `task.steps`).
- Delete the in-progress Step — its record preserved; the new `currentStepId` is the next un-finished Step in the new Plan.
- Insert a brand-new Step at the start — no existing records mutated; the new Step has no record yet.
- `pnpm test` passes.
**Manual verification.**
- During recording, open the edit modal, reorder steps. After saving, recorded times are unchanged in the table.
- Open browser devtools → Application → Local Storage. Confirm orphan records remain after deleting a Step from the Plan.
**Risks / migration.**
- Orphan records change `stepRecords` from "subset of current Steps" to "history of all Steps that ever had a record." Any consumer that iterates `stepRecords` and assumes membership in `task.steps` must be audited:
- `RecordResume.vue` → uses `useTaskRecordMetadata` (total duration). Acceptable since orphan time *was* spent.
- `StepRecord.vue` is rendered per `task.steps[i]`, so it skips orphans naturally.
- `useTaskRecordMetadata` — audit during this slice.
### CHECKPOINT 1 — after Phase 1
Stop. Verify:
- [ ] `pnpm test` green.
- [ ] Manual smoke: full Task lifecycle (create → record → finish) with Re-estimation and Re-planning mid-flight; localStorage shows preserved `initialPlan` and preserved Step Records.
- [ ] No regression in existing per-Step "off" coloring for un-edited Tasks.
Reviewer (human): confirm Foundation matches `CONTEXT.md` semantics before proceeding to Phase 2.
---
## Phase 2 — Closing the loop
### Slice 3 — Closing Ceremony view (three Failure modes)
**Goal.** A dedicated end-of-Task view that surfaces the three Failure modes, hosts the Notes textarea for post-Execution reflection, and replaces the current adjacent-version diff view at `/task/:id/history`.
**Files in scope.**
- New: `src/modules/record/services/compute-failure-modes.ts` — pure function:
```
computeFailureModes({ initialPlan, finalPlan, stepRecords }) → {
estimationVariance: Array<{ stepId, title, estimation, actual, deltaMin, isOff }>,
discoveredScope: Array<{ stepId, title, estimation, actual? }>,
abandonedScope: Array<{ stepId, title, estimation }>,
totalActualMin, totalInitialMin, totalDeltaMin
}
```
with unit tests covering each mode in isolation and combined.
- Rename `src/views/task/TaskHistory.vue` → `TaskClosingCeremony.vue`. Body fully replaced.
- `src/router/index.ts` — repoint the `history-task` route at the new component. Keep the path `/task/:id/history` for backwards-compatibility of bookmarks (rename the route name to `task-closing-ceremony` to match vocabulary).
- `src/modules/record/components/TaskRecord.vue` — when `record.end` is set, surface a "Close the loop" CTA linking to the Closing Ceremony route.
- `src/modules/record/components/RecordResume.vue` — keep the one-line summary as a tease, but defer the rich breakdown to the new view.
- Tests: unit tests on `compute-failure-modes`, component test smoke on `TaskClosingCeremony`.
**Open design questions (resolve during this slice).**
1. How are orphan Step Records (Steps removed from the Plan but with executed time) surfaced? Options: (a) hidden, (b) as a fourth "Discarded Work" section, (c) folded into Abandoned Scope. Default in the plan: **hidden**, with the time still contributing to the totals. Revisit if user feedback contradicts.
2. Does completing a Task auto-navigate to the Closing Ceremony, or is it CTA-only? Default: **CTA-only** — auto-navigation disrupts the during-Execution view if the user is mid-thought. Reflection should be a deliberate transition.
**Acceptance criteria.**
- `computeFailureModes` returns the three buckets correctly given fixture inputs covering: no re-planning (only Estimation Variance), Step added mid-flight (Discovered Scope present), Step deleted from Initial Plan (Abandoned Scope present), all three at once.
- The new view renders all three buckets, hiding empty ones.
- The Notes textarea on the new view binds to the same Pinia field as during-Execution Notes (single source — no duplication).
- The route `/task/:id/history` no longer renders an adjacent-version diff.
- `pnpm test` passes.
**Manual verification.**
- Complete a Task with no Plan changes → only Estimation Variance shown.
- Complete a Task where you added a Step mid-flight → Discovered Scope section appears with that Step.
- Complete a Task where you deleted a planned Step before executing it → Abandoned Scope section appears.
- Notes typed during Execution are visible on the Closing Ceremony view, and edits there persist back.
**Risks / migration.**
- Removing the diff view eliminates a feature, however lightly-used. Acceptable per the deep-design session (declared "dead view").
- Bookmark stability: route path preserved.
### CHECKPOINT 2 — after Phase 2
Stop. Verify:
- [ ] `pnpm test` green.
- [ ] End-to-end loop demoable: create → start → execute → re-plan mid-flight → finish → open Closing Ceremony → three modes correct.
Reviewer (human): confirm the Closing Ceremony surface matches intent before reshaping the during-Execution UI.
---
## Phase 3 — UX split
### Slice 4 — Re-estimation inline; Re-planning deliberate
**Goal.** Re-estimating a Step's number does not require opening the structural-edit modal. Re-planning becomes the modal's explicit purpose with framing that matches its weight.
**Files in scope.**
- `src/modules/record/components/StepRecord.vue` — extend the existing inline-edit pattern (already present for `duration` on completed Steps, see lines around the `isEditing` ref) to cover `estimation` for any Step. Pressing Enter commits; Esc cancels.
- New store action in `useTask.store.ts`: `reEstimateStep({ taskId, stepId, newEstimationMinutes })`. Implementation: push a new entry to `stepHistory` containing the current Plan with only that Step's `estimation` updated. The new entry does **not** disturb `record.initialPlan` (which is already snapshotted per Slice 1).
- `src/modules/task/components/NewStepsForm.vue` — rename heading from "Edit steps" to "Re-plan steps" (or similar weighty phrasing). Update the explanatory text to reflect that this is a structural change. Keep the existing flow for actually adding/removing/reordering.
- `src/modules/record/components/RecordControls.vue:170-176` — the "+" button: relabel/icon-update to "Re-plan" (not "+"); the action is unchanged.
- Tests: store test for `reEstimateStep`; component test for `StepRecord` inline editor.
**Acceptance criteria.**
- Tapping/clicking the estimation cell on a Step row opens an inline editor.
- Committing the edit triggers `reEstimateStep`, which pushes a new `stepHistory` entry.
- `record.initialPlan` is **not** mutated by re-estimation (covered by a unit test).
- The structural-edit modal is now reached only via the explicit "Re-plan" button.
- `pnpm test` passes.
**Manual verification.**
- During recording, click a Step's estimation cell, type a new number, hit Enter. The number updates; no modal opens.
- The Failure Signal (per-Step "off" indicator) continues to reference the Initial Plan estimation.
- Clicking "Re-plan" opens the modal; the heading reflects the new framing.
**Risks / migration.**
- The inline editor needs to coexist with the existing `duration` inline editor on completed Steps. Confirm visual layout supports both without ambiguity.
- Step-Back Signal (variance vs latest re-estimate) is not yet rendered. Surface as a follow-up unless trivial here. Default: render as a secondary, subtler indicator next to the existing "off" color — TBD during implementation.
### CHECKPOINT 3 — after Phase 3
Stop. Verify:
- [ ] `pnpm test` green.
- [ ] Re-estimation is a one-click, one-Enter interaction with no modal.
- [ ] Re-planning still opens the modal with deliberate framing.
Reviewer (human): confirm UX feels right before final cleanup.
---
## Phase 4 — Cleanup
### Slice 5 — Model and naming hygiene
**Goal.** Remove the inconsistencies in the `Task` model and surrounding code that no longer carry their weight after Phases 1-3.
**Files in scope.**
- `src/modules/task/models/task.ts`:
- Remove `updateSteps` (line 53-56) — identical to `newSteps`. Update test on line 73 (`task.test.ts:73`) to call `newSteps` (or the renamed action — see below).
- Rename `newSteps` → `rePlan` to match the vocabulary established in `CONTEXT.md`.
- Keep `editSteps(...steps)` (append semantics) if any consumer needs it; if not, remove. Verify with grep across the codebase before deletion.
- Fix the `readonly stepHistory` declaration — either drop `readonly` from the constructor parameter (since the array is mutated via `.push()`), or migrate to immutable updates that produce a new array. Recommended: drop `readonly`; document the mutation pattern in CONTEXT.md if non-obvious.
- Re-examine `wasUpdated` getter: name is misleading (it's `stepHistory.length > 0`, i.e., "has any steps at all"). Rename to `hasSteps` or remove if unused.
- `src/modules/record/stores/useTaskRecordStore.ts`:
- Rename `cleanCurrentStepId` → `reconcileWithReplannedPlan` (or similar) to match its new non-destructive contract from Slice 2.
- `src/modules/record/models/task-record.ts`:
- `TaskRecord.start = toISODate(new Date())` at construction is misleading — the real start is set in `startStepRecord` when the first Step Record begins. Initialize as `null` and tighten the type: `start: ISODate | null`. Update consumers.
- `CONTEXT.md`: resolve `Task.link` and `Task.date` semantics. Likely additions:
- **Task Date**: the moment the Task was created. Used for ordering the Task list. Not editable.
- **Task Link**: optional external reference (e.g., ticket URL).
- Tests: update all references to renamed methods; add tests for `TaskRecord.start === null` until first Step Record.
**Acceptance criteria.**
- `Task` model has no duplicate-bodied methods.
- All renames propagated; `pnpm test` (lint + types + unit) green.
- `readonly` lies removed.
- `TaskRecord.start` is `null` until the first Step Record begins.
- `CONTEXT.md` includes Task Date and Task Link definitions.
**Manual verification.**
- Smoke the full Task lifecycle one more time to ensure no regressions.
**Risks / migration.**
- Renames touch many files. Use rename-symbol IDE refactors where possible; verify with `pnpm test:types`.
### CHECKPOINT 4 — after Phase 4
Stop. Verify:
- [ ] `pnpm test` green.
- [ ] `pnpm build` succeeds.
- [ ] Manual: full lifecycle smoke once more on a fresh localStorage (incognito tab) to confirm no migration footgun.
---
## Explicitly out of scope (deferred)
These were surfaced in the deep-design session but are not addressed by this plan. They can be picked up in a follow-up plan if value emerges.
- **`breakTime` single-pause model.** Multiple pauses are silently absorbed; the *fact* of pause is unrecoverable. Acceptable for now; the Closing Ceremony does not surface pauses.
- **Step-Back Signal rendering.** Slice 4 mentions surfacing variance vs latest re-estimate as a softer indicator; default behaviour is to render only the Failure Signal until product feedback contradicts.
- **Cross-Task patterns** (e.g., "you underestimate refactors by 40%"). The per-task loop is the focus; cross-task analytics is a separate product surface.
## Test command reference
- `pnpm test` — full (lint + types + unit). Use at every checkpoint.
- `pnpm test:unit` — fast iteration during a slice.
- `pnpm test:types` — type-check only, useful after renames.
- `pnpm dev` — manual UI verification.
- `pnpm vitest run path/to/file.spec.ts` — single test file.

116
tasks/todo.md Normal file
View File

@@ -0,0 +1,116 @@
# Todo — Per-Task Feedback Loop alignment
Flat task list for execution. Detailed acceptance criteria, files, and verification steps live in [`plan.md`](./plan.md). Tick items as you complete them; pause at each **Checkpoint** for review before continuing.
## Phase 1 — Foundation
### Slice 1 — Initial Plan capture
- [ ] Add `initialPlan: Stepable[] | null` to `Recordable` interface
- [ ] Round-trip `initialPlan` in `TaskRecord.fromRecordable`
- [ ] In `startStepRecord`, snapshot `task.steps` into `record.initialPlan` when `stepRecords` is empty and `initialPlan` is not yet set
- [ ] Add fallback to `task.stepHistory[0]` when `record.initialPlan` is missing (legacy data)
- [ ] Replace `step.value.estimation` with Initial Plan lookup in `StepRecord.vue` "off by 10%" computation
- [ ] Update `RecordResume.vue` total comparison to sum Initial Plan estimations
- [ ] Add unit tests: snapshot occurs on first Step Record; re-estimation does not mutate `initialPlan`; legacy fallback works
- [ ] `pnpm test` green
- [ ] Manual verification per `plan.md` Slice 1
### Slice 2 — Preserve records on re-planning
- [ ] Audit consumers of `record.stepRecords` for "step is in current Plan" assumptions
- [ ] Replace `cleanCurrentStepId` with non-destructive reconciliation: never delete records, only set the new `currentStepId`
- [ ] Replace destructive `syncTaskRecord` body with the same non-destructive reconciliation
- [ ] Add unit tests for reorder, delete-completed, delete-in-progress, insert-at-start
- [ ] `pnpm test` green
- [ ] Manual verification per `plan.md` Slice 2
### ✋ Checkpoint 1 — Foundation review
- [ ] Full test suite green
- [ ] Manual smoke: full Task lifecycle with re-estimation and re-planning
- [ ] Reviewer (human) confirms Foundation matches `CONTEXT.md` semantics
---
## Phase 2 — Closing the loop
### Slice 3 — Closing Ceremony view
- [ ] Implement pure `computeFailureModes({ initialPlan, finalPlan, stepRecords })` service
- [ ] Unit tests for all three modes in isolation and combined
- [ ] Decide: orphan record handling (default: hidden, time still totalled)
- [ ] Decide: auto-navigate to ceremony on Task completion (default: CTA-only)
- [ ] Rename `TaskHistory.vue``TaskClosingCeremony.vue`; replace body
- [ ] Update router: route name → `task-closing-ceremony`; path preserved
- [ ] Add "Close the loop" CTA in `TaskRecord.vue` when `record.end` is set
- [ ] Trim `RecordResume.vue` to a one-line tease
- [ ] Bind ceremony Notes textarea to the same Pinia field as during-Execution Notes
- [ ] Component smoke test on `TaskClosingCeremony`
- [ ] `pnpm test` green
- [ ] Manual verification: each of the three modes per `plan.md` Slice 3
### ✋ Checkpoint 2 — Closing Ceremony review
- [ ] Full test suite green
- [ ] End-to-end loop demo: create → start → re-plan mid-flight → finish → open Ceremony → three modes correct
- [ ] Reviewer (human) confirms Ceremony surface matches intent
---
## Phase 3 — UX split
### Slice 4 — Re-estimation inline / Re-planning modal
- [ ] Add `reEstimateStep({ taskId, stepId, newEstimationMinutes })` action to task store
- [ ] Push a new `stepHistory` entry on re-estimation (Plan structure unchanged, only the number)
- [ ] Confirm via unit test that `record.initialPlan` is unaffected by re-estimation
- [ ] Extend inline-edit pattern in `StepRecord.vue` to cover the `estimation` cell on any Step
- [ ] Inline editor commits on Enter, cancels on Esc
- [ ] Rename modal heading to "Re-plan steps" (or similar); update explanatory text
- [ ] Relabel the "+" button in `RecordControls.vue` to "Re-plan"
- [ ] Component test for inline estimation editor
- [ ] Decide where the Step-Back Signal renders (default: deferred, see Out-of-Scope in plan)
- [ ] `pnpm test` green
- [ ] Manual verification per `plan.md` Slice 4
### ✋ Checkpoint 3 — UX review
- [ ] Full test suite green
- [ ] Re-estimation is one-click + Enter, no modal
- [ ] Re-planning opens modal with deliberate framing
- [ ] Reviewer (human) confirms UX matches intent
---
## Phase 4 — Cleanup
### Slice 5 — Model and naming hygiene
- [ ] Grep for `updateSteps`, remove method (identical to `newSteps`), update tests
- [ ] Rename `newSteps``rePlan` on `Task`
- [ ] Grep for `editSteps` (append semantics): remove if unused, keep otherwise
- [ ] Drop `readonly` from the `stepHistory` constructor parameter on `Task`
- [ ] Re-examine `wasUpdated`: rename to `hasSteps` or remove if unused
- [ ] Rename `cleanCurrentStepId``reconcileWithReplannedPlan` (or similar)
- [ ] Tighten `TaskRecord.start` type to `ISODate | null`; initialize as `null`
- [ ] Update all consumers of `TaskRecord.start` to handle `null` (until first Step Record)
- [ ] Update `CONTEXT.md`: define `Task Date` and `Task Link`
- [ ] `pnpm test:types` after renames
- [ ] `pnpm test` green
- [ ] `pnpm build` succeeds
### ✋ Checkpoint 4 — Final review
- [ ] Full test suite green
- [ ] `pnpm build` succeeds
- [ ] Fresh-localStorage smoke (incognito) — no migration footgun
- [ ] Plan considered complete
---
## Out of scope (parked)
- `breakTime` single-pause model
- Cross-Task pattern analytics
- Step-Back Signal rendering (decided per-slice; default deferred)