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,270 @@
import Foundation
import Observation
@MainActor
@Observable
final class AppStore {
private(set) var tasks: [TaskPlan] = []
private(set) var records: [String: TaskRecord] = [:]
@ObservationIgnored private let storageURL: URL
@ObservationIgnored private var pendingSave: Task<Void, Never>?
private struct DataFile: Codable {
var version: Int
var tasks: [TaskPlan]
var records: [String: TaskRecord]
}
init(storageURL: URL? = nil) {
if let storageURL {
self.storageURL = storageURL
} else {
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let dir = appSupport.appendingPathComponent("FailWell", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.storageURL = dir.appendingPathComponent("data.json")
}
load()
}
// MARK: - Persistence
private func load() {
guard let data = try? Data(contentsOf: storageURL) else { return }
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
do {
let file = try decoder.decode(DataFile.self, from: data)
self.tasks = file.tasks
self.records = file.records
} catch {
NSLog("FailWell: failed to decode data.json: \(error)")
}
}
private func scheduleSave() {
pendingSave?.cancel()
let url = storageURL
let snapshot = DataFile(version: 1, tasks: tasks, records: records)
pendingSave = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
Self.write(snapshot, to: url)
}
}
private nonisolated static func write(_ file: DataFile, to url: URL) {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
do {
let data = try encoder.encode(file)
try data.write(to: url, options: .atomic)
} catch {
NSLog("FailWell: failed to write data.json: \(error)")
}
}
/// Flush pending writes synchronously. Used on app termination.
func flush() {
pendingSave?.cancel()
pendingSave = nil
Self.write(DataFile(version: 1, tasks: tasks, records: records), to: storageURL)
}
// MARK: - Task queries
func task(id: String) -> TaskPlan? {
tasks.first(where: { $0.id == id })
}
var tasksByRecency: [TaskPlan] {
tasks.sorted(by: { $0.date > $1.date })
}
func record(taskId: String) -> TaskRecord? {
records[taskId]
}
// MARK: - Task mutations
func upsertTask(_ task: TaskPlan) {
if let idx = tasks.firstIndex(where: { $0.id == task.id }) {
tasks[idx] = task
} else {
tasks.append(task)
}
scheduleSave()
}
func deleteTask(id: String) {
tasks.removeAll { $0.id == id }
records.removeValue(forKey: id)
scheduleSave()
}
func duplicateTask(id: String) -> TaskPlan? {
guard let source = task(id: id) else { return nil }
let newId = UUID().uuidString
let copy = TaskPlan(
id: newId,
title: source.title,
date: Date(),
link: source.link,
stepHistory: [source.steps]
)
upsertTask(copy)
return copy
}
func editStepsToTask(taskId: String, steps: [Step]) {
guard var task = task(id: taskId) else { return }
task.newSteps(steps)
upsertTask(task)
cleanCurrentStepId(for: task)
}
// MARK: - Record mutations
func initRecord(taskId: String) {
if records[taskId] == nil {
records[taskId] = TaskRecord(taskId: taskId)
scheduleSave()
}
}
func startStepRecord(taskId: String, stepId: String, at start: Date = Date()) {
var record = records[taskId] ?? TaskRecord(taskId: taskId)
if record.stepRecords.isEmpty {
record.start = start
}
record.breakTime = nil
record.stepRecords[stepId] = TimeRange(start: start)
record.currentStepId = stepId
records[taskId] = record
scheduleSave()
}
func endStepRecord(taskId: String, stepId: String, at end: Date = Date()) {
guard var record = records[taskId], var range = record.stepRecords[stepId] else { return }
range.end = end
record.stepRecords[stepId] = range
records[taskId] = record
scheduleSave()
}
func nextStep(taskId: String, at tick: Date = Date()) {
guard let task = task(id: taskId),
let record = records[taskId],
let currentStepId = record.currentStepId
else { return }
endStepRecord(taskId: taskId, stepId: currentStepId, at: tick)
if let nextStepId = task.nextStepId(after: currentStepId) {
startStepRecord(taskId: taskId, stepId: nextStepId, at: tick)
} else {
endRecord(taskId: taskId, at: tick)
}
}
func endRecord(taskId: String, at end: Date = Date()) {
guard var record = records[taskId] else { return }
record.end = end
record.currentStepId = nil
records[taskId] = record
scheduleSave()
}
func updateNotes(taskId: String, notes: String) {
guard var record = records[taskId] else { return }
record.notes = notes
records[taskId] = record
scheduleSave()
}
func reset(taskId: String) {
guard var record = records[taskId] else { return }
record.stepRecords = [:]
record.end = nil
record.currentStepId = nil
record.breakTime = nil
records[taskId] = record
scheduleSave()
}
func pause(taskId: String, at start: Date = Date()) {
guard var record = records[taskId], record.breakTime == nil else { return }
record.breakTime = TimeRange(start: start)
records[taskId] = record
scheduleSave()
}
func resume(taskId: String, at end: Date = Date()) {
guard var record = records[taskId], var breakRange = record.breakTime else { return }
breakRange.end = end
record.breakTime = breakRange
var shifted = BreakCalculator.applyingBreakTime(to: record)
shifted.breakTime = nil
records[taskId] = shifted
scheduleSave()
}
/// When task steps change mid-recording, drop step records that no longer exist
/// and restart from the first unfinished step. Mirrors `cleanCurrentStepId`.
private func cleanCurrentStepId(for task: TaskPlan) {
guard var record = records[task.id] else { return }
// Find first step whose record isn't finished.
let firstPendingIndex = task.steps.firstIndex(where: { step in
record.stepRecords[step.id]?.end == nil
})
guard let firstPendingIndex else { return }
let latestStart = record.stepRecords.values
.map(\.start)
.max() ?? Date()
// Drop records for steps after the first pending one.
let keepIds = Set(task.steps.prefix(firstPendingIndex + 1).map(\.id))
record.stepRecords = record.stepRecords.filter { keepIds.contains($0.key) }
records[task.id] = record
startStepRecord(
taskId: task.id,
stepId: task.steps[firstPendingIndex].id,
at: latestStart
)
}
/// Active recording across all tasks (only one can be active at a time in practice).
var activeRecord: (task: TaskPlan, record: TaskRecord)? {
for (taskId, record) in records where record.isActive {
if let task = task(id: taskId) {
return (task, record)
}
}
return nil
}
}
extension TaskPlan {
func nextStepId(after stepId: String?) -> String? {
guard let stepId else { return steps.first?.id }
guard let idx = steps.firstIndex(where: { $0.id == stepId }) else { return nil }
let next = idx + 1
return next < steps.count ? steps[next].id : nil
}
func step(id: String) -> Step? {
steps.first(where: { $0.id == id })
}
/// The estimation the developer committed to in the Initial Plan for this Step, if any.
/// Returns `nil` for Steps that were added mid-Execution (no baseline no Failure Signal).
/// See CONTEXT.md §"Failure Signal".
func initialEstimation(for stepId: String) -> Int? {
initialPlan.first(where: { $0.id == stepId })?.estimation
}
}

View File

@@ -0,0 +1,22 @@
import Foundation
enum BreakCalculator {
/// Shifts the `start` of any unfinished step records forward by the break duration.
/// Mirrors `addBreakTimeToStepRecords` from the PWA.
static func applyingBreakTime(to record: TaskRecord) -> TaskRecord {
guard let breakTime = record.breakTime, let breakEnd = breakTime.end else {
return record
}
let diff = breakEnd.timeIntervalSince(breakTime.start)
var updated = record
for (stepId, range) in updated.stepRecords where range.end == nil {
updated.stepRecords[stepId] = TimeRange(
start: range.start.addingTimeInterval(diff),
end: nil
)
}
return updated
}
}

View File

@@ -0,0 +1,32 @@
import AppKit
import Foundation
enum ClipboardImporter {
struct Draft: Equatable {
var title: String?
var steps: [Step]
}
/// Reads the current pasteboard and, if it looks like a markdown task
/// (`# Title` optional, followed by `- step | mins` lines), returns a draft.
static func currentDraft() -> Draft? {
guard let text = NSPasteboard.general.string(forType: .string) else { return nil }
return draft(from: text)
}
static func draft(from text: String) -> Draft? {
let (title, content) = extractTitleAndContent(text)
let steps = StepParser.adaptTextareaToSteps(content)
guard !steps.isEmpty else { return nil }
return Draft(title: title, steps: steps)
}
nonisolated(unsafe) private static let titleRegex = /^(#{1,6})\s+(.+?)\n+([\s\S]*)$/
private static func extractTitleAndContent(_ text: String) -> (title: String?, content: String) {
if let match = try? titleRegex.firstMatch(in: text) {
return (String(match.2).trimmingCharacters(in: .whitespaces), String(match.3))
}
return (nil, text)
}
}

View File

@@ -0,0 +1,25 @@
import Foundation
import Observation
@MainActor
@Observable
final class Clock {
var now: Date = Date()
@ObservationIgnored private var tickTask: Task<Void, Never>?
init(tickInterval: Duration = .milliseconds(500)) {
tickTask = Task { [weak self] in
while !Task.isCancelled {
await MainActor.run {
self?.now = Date()
}
try? await Task.sleep(for: tickInterval)
}
}
}
deinit {
tickTask?.cancel()
}
}

View File

@@ -0,0 +1,13 @@
import Foundation
enum EstimationComparator {
/// Returns `true` when the actual duration deviates from the estimation by more than
/// 10 % (with a 1-minute floor for very short steps). Mirrors `is10PercentOffThanEstimation`.
static func is10PercentOff(estimation: Double, duration: Double) -> Bool {
abs(duration - estimation) > max(estimation * 0.1, 1)
}
static func is10PercentOff(estimation: Int, duration: Int) -> Bool {
is10PercentOff(estimation: Double(estimation), duration: Double(duration))
}
}

View File

@@ -0,0 +1,81 @@
import CryptoKit
import Foundation
enum StepParser {
static func adaptStepsToTextarea(_ steps: [Step]) -> String {
steps.map { "- \($0.title) | \($0.estimation)" }.joined(separator: "\n")
}
static func adaptTextareaToSteps(_ value: String) -> [Step] {
let lines = value.components(separatedBy: "\n")
var result: [(title: String, estimation: Int)] = []
var currentParent: (title: String, estimation: Int)?
var parentHasChildren = false
func flushParent() {
if let parent = currentParent, !parentHasChildren {
result.append(parent)
}
currentParent = nil
parentHasChildren = false
}
for line in lines {
let (title, estimation) = extractTitleAndEstimation(from: line)
if title.isEmpty { continue }
if isIndented(line) {
if let parent = currentParent, !parent.title.isEmpty {
let flattened = "(\(parent.title)) - \(title)"
result.append((flattened, estimation))
parentHasChildren = true
} else {
result.append((title, estimation))
}
} else {
flushParent()
currentParent = (title, estimation)
}
}
flushParent()
// Step identity is derived from title only. Re-estimation must preserve identity so
// the existing Step Record (keyed by Step id) survives see CONTEXT.md §"Re-estimation".
let bases = result.map { md5($0.title) }
return result.enumerated().map { index, item in
let base = bases[index]
let duplicates = bases[...index].filter { $0 == base }.count
return Step(id: "\(base)-\(duplicates)", title: item.title, estimation: item.estimation)
}
}
private static func isIndented(_ line: String) -> Bool {
guard let first = line.first else { return false }
return first == " " || first == "\t"
}
nonisolated(unsafe) private static let stripPrefix = /^-\s*(\[[ x]\]\s*)?/
private static func extractTitleAndEstimation(from rawStep: String) -> (String, Int) {
let trimmed = rawStep.trimmingCharacters(in: .whitespaces)
let stripped = trimmed.replacing(stripPrefix, with: "")
let parts = stripped.split(separator: "|", maxSplits: 1, omittingEmptySubsequences: false)
let title = parts.first.map { $0.trimmingCharacters(in: .whitespaces) } ?? ""
let rawEstimation = parts.count > 1 ? String(parts[1]).trimmingCharacters(in: .whitespaces) : ""
let estimation: Int
if rawEstimation.isEmpty {
estimation = 0
} else if let d = Double(rawEstimation), !d.isNaN {
estimation = Int(d.rounded())
} else {
estimation = 0
}
return (title, estimation)
}
private static func md5(_ s: String) -> String {
let digest = Insecure.MD5.hash(data: Data(s.utf8))
return digest.map { String(format: "%02x", $0) }.joined()
}
}

View File

@@ -0,0 +1,28 @@
import Foundation
enum TimeFormat {
/// "MM:SS" or "H:MM:SS" depending on magnitude.
static func clock(_ seconds: TimeInterval) -> String {
let total = max(0, Int(seconds))
let h = total / 3600
let m = (total % 3600) / 60
let s = total % 60
if h > 0 { return String(format: "%d:%02d:%02d", h, m, s) }
return String(format: "%d:%02d", m, s)
}
/// Rounded to minutes. Returns "0m" for sub-minute durations.
static func minutes(_ seconds: TimeInterval) -> String {
"\(Int((seconds / 60).rounded()))m"
}
/// Compact relative-date string, e.g. "today", "yesterday", "Mar 14".
static func date(_ date: Date, relativeTo reference: Date = Date()) -> String {
let cal = Calendar.current
if cal.isDateInToday(date) { return "today" }
if cal.isDateInYesterday(date) { return "yesterday" }
let f = DateFormatter()
f.dateFormat = cal.component(.year, from: date) == cal.component(.year, from: reference) ? "MMM d" : "MMM d yyyy"
return f.string(from: date)
}
}