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