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