feat(macos): add native menu bar app with notch overlay

Introduces a SwiftUI companion app for the Fail Well PWA: menu bar
status item, popover with task list / record / history views, command
palette, and a Dynamic Island-style notch overlay that shows the active
step and timer beside the hardware notch and expands on hover to reveal
pause/next controls.
This commit is contained in:
Julien Calixte
2026-05-15 11:27:51 +02:00
parent 0c0d555ac1
commit 29a71a73b2
33 changed files with 2602 additions and 0 deletions

View File

@@ -0,0 +1,64 @@
import Foundation
import Testing
@testable import FailWell
@Suite("BreakCalculator")
struct BreakCalculatorTests {
@Test func passesThroughWhenNoBreakTime() {
let record = TaskRecord(
taskId: "t1",
stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))],
breakTime: nil
)
#expect(BreakCalculator.applyingBreakTime(to: record) == record)
}
@Test func passesThroughWhenBreakNotEnded() {
let record = TaskRecord(
taskId: "t1",
stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))],
breakTime: TimeRange(start: date("2023-04-17T19:00:00Z"))
)
#expect(BreakCalculator.applyingBreakTime(to: record) == record)
}
@Test func shiftsUnfinishedStepStartsByBreakDuration() {
let record = TaskRecord(
taskId: "t1",
stepRecords: ["step-id-1": TimeRange(start: date("2023-04-17T18:00:00Z"))],
breakTime: TimeRange(
start: date("2023-04-17T19:00:00Z"),
end: date("2023-04-17T20:00:00Z")
)
)
let result = BreakCalculator.applyingBreakTime(to: record)
#expect(result.stepRecords["step-id-1"]?.start == date("2023-04-17T19:00:00Z"))
}
@Test func doesNotShiftFinishedStepRecords() {
let record = TaskRecord(
taskId: "t1",
stepRecords: [
"step-id-1": TimeRange(
start: date("2023-04-17T17:00:00Z"),
end: date("2023-04-17T18:00:00Z")
),
"step-id-2": TimeRange(start: date("2023-04-17T18:00:00Z"))
],
breakTime: TimeRange(
start: date("2023-04-17T19:00:00Z"),
end: date("2023-04-17T20:00:00Z")
)
)
let result = BreakCalculator.applyingBreakTime(to: record)
#expect(result.stepRecords["step-id-1"]?.start == date("2023-04-17T17:00:00Z"))
#expect(result.stepRecords["step-id-1"]?.end == date("2023-04-17T18:00:00Z"))
#expect(result.stepRecords["step-id-2"]?.start == date("2023-04-17T19:00:00Z"))
}
private func date(_ iso: String) -> Date {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f.date(from: iso) ?? ISO8601DateFormatter().date(from: iso)!
}
}

View File

@@ -0,0 +1,13 @@
import Testing
@testable import FailWell
@Suite("EstimationComparator")
struct EstimationComparatorTests {
@Test func flagsDurationsMoreThan10PercentOff() {
// From `compare-with-estimation.test.ts`.
#expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 9) == false)
#expect(EstimationComparator.is10PercentOff(estimation: 5, duration: 4) == false)
#expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 5) == true)
#expect(EstimationComparator.is10PercentOff(estimation: 10, duration: 8) == true)
}
}

View File

@@ -0,0 +1,167 @@
import Testing
@testable import FailWell
@Suite("StepParser")
struct StepParserTests {
@Test func adaptsStepsToTextarea() {
let steps = [
Step(id: "a", title: "step 1", estimation: 3),
Step(id: "b", title: "step 2", estimation: 4),
Step(id: "c", title: "step 3", estimation: 5)
]
#expect(StepParser.adaptStepsToTextarea(steps) == "- step 1 | 3\n- step 2 | 4\n- step 3 | 5")
}
@Test func parsesTextareaIntoSteps() {
let input = """
- step 1 | 3
- step 2 | 4
- step 3 | 5
"""
let steps = StepParser.adaptTextareaToSteps(input)
#expect(steps.map(\.title) == ["step 1", "step 2", "step 3"])
#expect(steps.map(\.estimation) == [3, 4, 5])
}
@Test func fallsBackToZeroWhenNoEstimation() {
let steps = StepParser.adaptTextareaToSteps("- step 1")
#expect(steps.count == 1)
#expect(steps[0].title == "step 1")
#expect(steps[0].estimation == 0)
}
@Test func fallsBackToZeroWhenEstimationUnparseable() {
let steps = StepParser.adaptTextareaToSteps("- step 1 | not an estimation")
#expect(steps.count == 1)
#expect(steps[0].title == "step 1")
#expect(steps[0].estimation == 0)
}
@Test func skipsEmptyTitles() {
let steps = StepParser.adaptTextareaToSteps("\n-")
#expect(steps.isEmpty)
}
@Test func generatesStableIdsFromTitleAlone() {
// Step identity is title-only (md5(title) + duplicate suffix). Re-estimating
// the same title must yield the same id so the Step Record survives.
let single = StepParser.adaptTextareaToSteps("- step 1 | 3")
#expect(single.first?.id == "7eedced2241bcccda700ccac1b5915d6-1")
let duped = StepParser.adaptTextareaToSteps("""
- step duplicated | 3
- step duplicated | 3
""")
#expect(duped.map(\.id) == [
"b53d37cc4caaf9d8d4f1e459e335a242-1",
"b53d37cc4caaf9d8d4f1e459e335a242-2"
])
}
@Test func reestimationPreservesStepIdentity() {
// CONTEXT.md §"Re-estimation": changing the estimation never destroys data.
// Same title with different estimations must produce the same Step id.
let original = StepParser.adaptTextareaToSteps("- refactor parser | 5")
let reestimated = StepParser.adaptTextareaToSteps("- refactor parser | 12")
#expect(original.first?.id == reestimated.first?.id)
#expect(original.first?.estimation == 5)
#expect(reestimated.first?.estimation == 12)
}
@Test func parsesUncheckedCheckboxes() {
let steps = StepParser.adaptTextareaToSteps("- [ ] step with checkbox | 20")
#expect(steps.count == 1)
#expect(steps[0].title == "step with checkbox")
#expect(steps[0].estimation == 20)
}
@Test func parsesCheckedCheckboxes() {
let steps = StepParser.adaptTextareaToSteps("- [x] completed step | 15")
#expect(steps.count == 1)
#expect(steps[0].title == "completed step")
#expect(steps[0].estimation == 15)
}
@Test func parsesCheckboxesWithoutEstimation() {
let steps = StepParser.adaptTextareaToSteps("- [ ] step without estimation")
#expect(steps.count == 1)
#expect(steps[0].title == "step without estimation")
#expect(steps[0].estimation == 0)
}
@Suite("subtask support")
struct SubtaskTests {
@Test func flattensIndentedSubtasksWithParentPrefix() {
let input = """
- Parent task | 5
- Child task 1 | 3
- Child task 2 | 2
"""
let steps = StepParser.adaptTextareaToSteps(input)
#expect(steps.map(\.title) == ["(Parent task) - Child task 1", "(Parent task) - Child task 2"])
#expect(steps.map(\.estimation) == [3, 2])
}
@Test func ignoresParentEstimationWhenItHasChildren() {
let steps = StepParser.adaptTextareaToSteps("""
- Parent | 10
- Child | 3
""")
#expect(steps.count == 1)
#expect(steps[0].title == "(Parent) - Child")
#expect(steps[0].estimation == 3)
}
@Test func treatsStandaloneItemsAsRegularSteps() {
let steps = StepParser.adaptTextareaToSteps("- Standalone task | 4")
#expect(steps.count == 1)
#expect(steps[0].title == "Standalone task")
#expect(steps[0].estimation == 4)
}
@Test func handlesMixedSubtasksAndStandaloneItems() {
let input = """
- Parent task | 5
- Child task 1 | 3
- Child task 2 | 2
- Standalone | 4
"""
let steps = StepParser.adaptTextareaToSteps(input)
#expect(steps.map(\.title) == [
"(Parent task) - Child task 1",
"(Parent task) - Child task 2",
"Standalone"
])
#expect(steps.map(\.estimation) == [3, 2, 4])
}
@Test func handlesTabIndentation() {
let steps = StepParser.adaptTextareaToSteps("- Parent | 5\n\t- Child | 3")
#expect(steps.count == 1)
#expect(steps[0].title == "(Parent) - Child")
}
@Test func treatsOrphanIndentedLineAsRegularStep() {
let steps = StepParser.adaptTextareaToSteps(" - Orphan indented | 3")
#expect(steps.count == 1)
#expect(steps[0].title == "Orphan indented")
#expect(steps[0].estimation == 3)
}
@Test func handlesMultipleParentsWithChildren() {
let input = """
- Parent 1 | 5
- Child 1a | 2
- Child 1b | 3
- Parent 2 | 4
- Child 2a | 1
"""
let steps = StepParser.adaptTextareaToSteps(input)
#expect(steps.map(\.title) == [
"(Parent 1) - Child 1a",
"(Parent 1) - Child 1b",
"(Parent 2) - Child 2a"
])
}
}
}

View File

@@ -0,0 +1,51 @@
import Testing
@testable import FailWell
@Suite("TaskPlan — Initial Plan baseline")
struct TaskPlanTests {
@Test func initialEstimationReturnsInitialPlanValueAfterReestimation() {
// CONTEXT.md §"Failure Signal" the honest baseline is the Initial Plan,
// even after the developer re-estimates mid-Execution.
let initialSteps = StepParser.adaptTextareaToSteps("- refactor parser | 5")
var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps])
// Re-estimate: same title, new estimation same Step id, new Step entry.
let reestimated = StepParser.adaptTextareaToSteps("- refactor parser | 12")
task.newSteps(reestimated)
let stepId = task.steps[0].id
#expect(initialSteps[0].id == stepId) // identity preserved across re-estimation
#expect(task.steps[0].estimation == 12) // current Plan reflects the new number
#expect(task.initialEstimation(for: stepId) == 5) // baseline reflects the commitment
}
@Test func initialEstimationReturnsNilForStepsAddedMidExecution() {
let initialSteps = StepParser.adaptTextareaToSteps("- planned step | 5")
var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps])
let addedLater = StepParser.adaptTextareaToSteps("""
- planned step | 5
- unplanned step | 3
""")
task.newSteps(addedLater)
let addedStepId = task.steps[1].id
#expect(task.initialEstimation(for: addedStepId) == nil) // no baseline no Failure Signal
}
@Test func failureSignalFiresAgainstInitialPlanNotCurrent() {
// Scenario from the CONTEXT.md challenge: planned 5m, drifted, re-estimated to 12m,
// finished at 12m. Honest signal must fire because we missed the *original* commitment.
let initialSteps = StepParser.adaptTextareaToSteps("- step | 5")
var task = TaskPlan(id: "t1", title: "Test", stepHistory: [initialSteps])
task.newSteps(StepParser.adaptTextareaToSteps("- step | 12"))
let stepId = task.steps[0].id
let baseline = task.initialEstimation(for: stepId)!
let actualMinutes = 12.0
#expect(EstimationComparator.is10PercentOff(estimation: Double(baseline), duration: actualMinutes) == true)
// Confirm the silent-signal bug: comparing against the *current* (re-estimated) value would hide it.
#expect(EstimationComparator.is10PercentOff(estimation: Double(task.steps[0].estimation), duration: actualMinutes) == false)
}
}