Compare commits
7 Commits
25afabaf3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a4a3ed9f7 | ||
|
|
fe75df4e0e | ||
|
|
030ab950eb | ||
|
|
29a71a73b2 | ||
|
|
0c0d555ac1 | ||
|
|
0ad89f06ef | ||
|
|
5796c50566 |
75
CONTEXT.md
Normal file
75
CONTEXT.md
Normal 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.
|
||||
42
docs/adr/0001-per-task-feedback-loop.md
Normal file
42
docs/adr/0001-per-task-feedback-loop.md
Normal 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
7
macos/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
.build/
|
||||
.swiftpm/
|
||||
.DS_Store
|
||||
DerivedData/
|
||||
FailWell.app/
|
||||
*.xcodeproj/
|
||||
Package.resolved
|
||||
36
macos/Info.plist
Normal file
36
macos/Info.plist
Normal 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
38
macos/Makefile
Normal 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
24
macos/Package.swift
Normal 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
37
macos/README.md
Normal 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.
|
||||
118
macos/Sources/FailWell/FailWellApp.swift
Normal file
118
macos/Sources/FailWell/FailWellApp.swift
Normal 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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
8
macos/Sources/FailWell/Models/Step.swift
Normal file
8
macos/Sources/FailWell/Models/Step.swift
Normal 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
|
||||
}
|
||||
54
macos/Sources/FailWell/Models/Task.swift
Normal file
54
macos/Sources/FailWell/Models/Task.swift
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
41
macos/Sources/FailWell/Models/TaskRecord.swift
Normal file
41
macos/Sources/FailWell/Models/TaskRecord.swift
Normal 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
|
||||
}
|
||||
}
|
||||
11
macos/Sources/FailWell/Models/TimeRange.swift
Normal file
11
macos/Sources/FailWell/Models/TimeRange.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
270
macos/Sources/FailWell/Services/AppStore.swift
Normal file
270
macos/Sources/FailWell/Services/AppStore.swift
Normal 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
|
||||
}
|
||||
}
|
||||
22
macos/Sources/FailWell/Services/BreakCalculator.swift
Normal file
22
macos/Sources/FailWell/Services/BreakCalculator.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
macos/Sources/FailWell/Services/ClipboardImporter.swift
Normal file
32
macos/Sources/FailWell/Services/ClipboardImporter.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
25
macos/Sources/FailWell/Services/Clock.swift
Normal file
25
macos/Sources/FailWell/Services/Clock.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
13
macos/Sources/FailWell/Services/EstimationComparator.swift
Normal file
13
macos/Sources/FailWell/Services/EstimationComparator.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
81
macos/Sources/FailWell/Services/StepParser.swift
Normal file
81
macos/Sources/FailWell/Services/StepParser.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
28
macos/Sources/FailWell/Services/TimeFormat.swift
Normal file
28
macos/Sources/FailWell/Services/TimeFormat.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
147
macos/Sources/FailWell/Views/CommandPalette.swift
Normal file
147
macos/Sources/FailWell/Views/CommandPalette.swift
Normal 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
|
||||
}
|
||||
}
|
||||
98
macos/Sources/FailWell/Views/MenuBarPopover.swift
Normal file
98
macos/Sources/FailWell/Views/MenuBarPopover.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
204
macos/Sources/FailWell/Views/RecordView.swift
Normal file
204
macos/Sources/FailWell/Views/RecordView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
37
macos/Sources/FailWell/Views/Router.swift
Normal file
37
macos/Sources/FailWell/Views/Router.swift
Normal 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]
|
||||
}
|
||||
}
|
||||
17
macos/Sources/FailWell/Views/SettingsView.swift
Normal file
17
macos/Sources/FailWell/Views/SettingsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
132
macos/Sources/FailWell/Views/TaskDetailView.swift
Normal file
132
macos/Sources/FailWell/Views/TaskDetailView.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
142
macos/Sources/FailWell/Views/TaskEditor.swift
Normal file
142
macos/Sources/FailWell/Views/TaskEditor.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
84
macos/Sources/FailWell/Views/TaskHistoryView.swift
Normal file
84
macos/Sources/FailWell/Views/TaskHistoryView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
160
macos/Sources/FailWell/Views/TaskListView.swift
Normal file
160
macos/Sources/FailWell/Views/TaskListView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
32
macos/Sources/FailWell/Views/Theme.swift
Normal file
32
macos/Sources/FailWell/Views/Theme.swift
Normal 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) }
|
||||
}
|
||||
64
macos/Tests/FailWellTests/BreakCalculatorTests.swift
Normal file
64
macos/Tests/FailWellTests/BreakCalculatorTests.swift
Normal 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)!
|
||||
}
|
||||
}
|
||||
13
macos/Tests/FailWellTests/EstimationComparatorTests.swift
Normal file
13
macos/Tests/FailWellTests/EstimationComparatorTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
167
macos/Tests/FailWellTests/StepParserTests.swift
Normal file
167
macos/Tests/FailWellTests/StepParserTests.swift
Normal 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"
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
51
macos/Tests/FailWellTests/TaskPlanTests.swift
Normal file
51
macos/Tests/FailWellTests/TaskPlanTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
260
tasks/plan.md
Normal 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
116
tasks/todo.md
Normal 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)
|
||||
Reference in New Issue
Block a user