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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user