krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 1569d791fb

1569d791fbc3c0618d100bc101222d3bfc5f56d9

parent: 30accd16a9

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:33 UTC

Clock history, selection and resolution in ClockModel

clock.json keeps the history with the running clock (old files still
load). Interactive clock-in resolves dangling clocks first and offers
leftover time; idle and dangling clocks are resolved as org-clock-resolve
does. Adds org-clock-idle-time and org-clock-history-length to
config.toml. Clocking in while another clock runs finds the entry again
after the clock-out edit.

Layout: unified · split

Sources/OrgApp/ClockModel.swift +390 −15
@@ -2,8 +2,8 @@ import Foundation
22import Observation
33import OrgCore
44
5/// The running clock, kept across launches as Emacs keeps `org-clock-marker` for the session,
6/// and the commands that start and stop it.
5/// The running clock and the clock history, kept across launches as Org keeps them with
6/// `org-clock-persist`, and the commands that start, stop and resolve clocks.
77@MainActor
88@Observable
99public final class ClockModel {
@@ -15,23 +15,65 @@ public final class ClockModel {
1515 public let startedAt: Date
1616 }
1717
18 /// An open `CLOCK:` line: the running clock, or one left dangling.
19 public struct OpenClock: Equatable, Sendable {
20 public let path: String
21 public let start: String
22 public let startedAt: Date
23 public let heading: String
24 }
25
26 /// What a question about a clock led to: messages, and where to look (`j`).
27 public struct Outcome {
28 public var messages: [String] = []
29 public var jump: (path: String, offset: Int)?
30 }
31
32 /// Asks a question in the echo area or a sheet; nil when it is cancelled.
33 public typealias Ask = @MainActor (Prompt) async -> String?
34
35 private struct Saved: Codable {
36 var running: Running?
37 var history: [ClockTask]
38 }
39
1840 public private(set) var running: Running?
41 /// `org-clock-history`, most recent first.
42 public private(set) var history: [ClockTask] = []
43 /// `org-clock-default-task`, for the session.
44 public private(set) var defaultTask: ClockTask?
45 /// `org-clock-interrupted-task`.
46 public private(set) var interruptedTask: ClockTask?
47 /// `org-clock-leftover-time`: where a clock resolved with `K`, `S` or `T` stopped.
48 public private(set) var leftover: Date?
49 /// A clock question is open (`org-clock-resolving-clocks`).
50 public private(set) var isResolving = false
1951 private let file: URL
52 private let defaults: UserDefaults
2053
21 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json")) {
54 public init(file: URL = WorkspaceModel.defaultDirectory.appendingPathComponent("clock.json"), defaults: UserDefaults = .standard) {
2255 self.file = file
23 running = (try? JSONDecoder().decode(Running.self, from: Data(contentsOf: file)))
56 self.defaults = defaults
57 guard let data = try? Data(contentsOf: file) else { return }
58 if let running = try? JSONDecoder().decode(Running.self, from: data) {
59 self.running = running
60 } else if let saved = try? JSONDecoder().decode(Saved.self, from: data) {
61 running = saved.running
62 history = saved.history
63 }
2464 }
2565
2666 private func save() {
2767 try? FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
28 if let running, let data = try? JSONEncoder().encode(running) {
68 if running != nil || !history.isEmpty, let data = try? JSONEncoder().encode(Saved(running: running, history: history)) {
2969 try? data.write(to: file, options: .atomic)
3070 } else {
3171 try? FileManager.default.removeItem(at: file)
3272 }
3373 }
3474
75 // MARK: - Clocking in and out
76
3577 /// `org-clock-in` at the heading under the caret, clocking out a running clock first.
3678 public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String {
3779 guard let path = session.path, session.document != nil else { return "No file is open." }
@@ -41,18 +83,32 @@ public final class ClockModel {
4183 /// `org-clock-in` at the heading containing `offset` in `path`.
4284 public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String {
4385 guard let text = Self.text(path, session) else { return "Can't read \(path)" }
44 let tree = OrgParser.parse(text)
45 guard let entry = DocumentModel(tree: tree).headings.last(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
46 if running != nil {
86 let headings = DocumentModel(tree: OrgParser.parse(text)).headings
87 guard let index = headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" }
88 let entry = headings[index]
89 var offset = offset
90 var heading = entry.headingRange.lowerBound
91 if let running {
92 if isRunning(path: path, heading: heading, session) { return "Clock continues in \"\(running.heading)\"" }
93 let interrupted = runningTask(session)
4794 let message = clockOut(session, now: now)
48 if running != nil { return message }
95 if self.running != nil { return message }
96 interruptedTask = interrupted
97 // Clocking out may have moved the entry.
98 let target = ClockTask(path: path, title: entry.title, ordinal: index, id: entry.id)
99 guard let moved = Self.text(path, session).flatMap(target.locate) else { return "Can't find \(entry.title) after clocking out." }
100 offset += moved - heading
101 heading = moved
102 } else {
103 interruptedTask = nil
49104 }
50105 switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) {
51106 case .commit:
52 guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: entry.headingRange.lowerBound) else {
107 guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: heading) else {
53108 return "The clock line wasn't found after clocking in."
54109 }
55110 running = Running(path: path, start: start, heading: entry.title, startedAt: now)
111 if let task = ClockTask.at(heading, in: updated, path: path) { push(task, session) }
56112 save()
57113 return "Clock starts at [\(start)]"
58114 case .failed(let message):
@@ -115,10 +171,329 @@ public final class ClockModel {
115171 /// Where the running clock's line is, for `org-clock-goto`.
116172 public func location(_ session: DocumentSession) -> (path: String, offset: Int)? {
117173 guard let clock = running else { return nil }
118 let open = session.openDocuments.first { $0.path == clock.path }?.document.text
119 guard let text = open ?? (try? String(contentsOfFile: clock.path, encoding: .utf8)) else { return nil }
120 let buffer = text as NSString
121 let range = buffer.range(of: "CLOCK: [\(clock.start)]")
122 return range.location == NSNotFound ? (clock.path, 0) : (clock.path, range.location)
174 return lineLocation(path: clock.path, start: clock.start, session)
175 }
176
177 private func lineLocation(path: String, start: String, _ session: DocumentSession) -> (path: String, offset: Int)? {
178 guard let text = Self.text(path, session) else { return nil }
179 let range = (text as NSString).range(of: "CLOCK: [\(start)]")
180 return range.location == NSNotFound ? (path, 0) : (path, range.location)
181 }
182
183 /// The running clock's entry.
184 private func runningTask(_ session: DocumentSession) -> ClockTask? {
185 guard let line = location(session), let text = Self.text(line.path, session) else { return nil }
186 return ClockTask.at(line.offset, in: text, path: line.path)
187 }
188
189 private func isRunning(path: String, heading: Int, _ session: DocumentSession) -> Bool {
190 guard running?.path == path, let current = entry(session) else { return false }
191 return current.offset == heading
192 }
193
194 // MARK: - History
195
196 private func push(_ task: ClockTask, _ session: DocumentSession) {
197 history = ClockHistory.push(task, onto: history, length: OrgPreferences.clockHistoryLength(defaults)) { Self.text($0, session) }
198 }
199
200 /// Where a remembered entry's heading is now.
201 public func locate(_ task: ClockTask, _ session: DocumentSession) -> (path: String, offset: Int)? {
202 Self.text(task.path, session).flatMap { task.locate(in: $0) }.map { (task.path, $0) }
203 }
204
205 /// `org-clock-mark-default-task` for the heading under the caret.
206 public func markDefault(_ session: DocumentSession) -> String {
207 guard let path = session.path, session.document != nil else { return "No file is open." }
208 return markDefault(session, path: path, at: session.caret)
209 }
210
211 /// `org-clock-mark-default-task` for the heading containing `offset` in `path`.
212 public func markDefault(_ session: DocumentSession, path: String, at offset: Int) -> String {
213 guard let text = Self.text(path, session) else { return "Can't read \(path)" }
214 guard let task = ClockTask.at(offset, in: text, path: path) else { return "Before first headline" }
215 defaultTask = task
216 return "Default clock task: \(task.title)"
217 }
218
219 /// `org-clock-select-task`'s entries, or nil with no recent clock.
220 public func selection(_ session: DocumentSession) -> [ClockHistory.Choice]? {
221 ClockHistory.selection(
222 defaultTask: defaultTask, interrupted: interruptedTask, current: running == nil ? nil : runningTask(session), history: history
223 ) { Self.text($0, session) }
224 }
225
226 /// `org-clock-select-task`: the chosen entry, nil when the question is left with `q`,
227 /// `x` or Escape, or an error message.
228 public func chooseTask(_ session: DocumentSession, prompt: String, ask: Ask) async -> Result<ClockTask?, ClockResolution.Failure> {
229 guard let choices = selection(session) else { return .failure(.init(message: "No recent clock")) }
230 guard let answer = await ask(Prompt(key: "clock-task", message: prompt, fast: ClockHistory.fastSelection(choices))),
231 let key = answer.first, key != "q", key != "x" else { return .success(nil) }
232 guard let choice = choices.first(where: { $0.key == key }) else { return .failure(.init(message: "Invalid task choice \(key)")) }
233 return .success(choice.task)
234 }
235
236 /// `org-clock-goto`: the running clock, else the most recent entry clocked.
237 public func goto(_ session: DocumentSession) -> (location: (path: String, offset: Int)?, message: String?) {
238 if let location = location(session) { return (location, nil) }
239 if let recent = history.first, let location = locate(recent, session) {
240 return (location, "No running clock, this is the most recently clocked task")
241 }
242 return (nil, "No active or recent clock task")
243 }
244
245 /// `org-clock-goto` with a prefix argument: an entry from the selection.
246 public func gotoRecent(_ session: DocumentSession, ask: Ask) async -> (location: (path: String, offset: Int)?, message: String?) {
247 switch await chooseTask(session, prompt: "Select task to go to: ", ask: ask) {
248 case .failure(let failure): return (nil, failure.message)
249 case .success(nil): return (nil, "No task selected")
250 case .success(let task?):
251 guard let location = locate(task, session) else { return (nil, "\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more") }
252 return (location, nil)
253 }
254 }
255
256 // MARK: - Clocking in with questions
257
258 /// Where an interactive clock-in goes.
259 public enum Target {
260 /// The heading under the caret (`org-clock-in`).
261 case caret
262 /// An entry chosen from the selection (`org-clock-in` with a prefix argument).
263 case select
264 /// The most recent entry (`org-clock-in-last`).
265 case last
266 /// A remembered entry.
267 case task(ClockTask)
268 }
269
270 /// `org-clock-in` as a command: dangling clocks in `paths` are resolved first when no
271 /// clock runs (`org-clock-auto-clock-resolution` `when-no-clock-is-running`), and a clock
272 /// stopped with `K`, `S` or `T` offers its time as the start.
273 public func clockIn(_ session: DocumentSession, target: Target, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
274 var outcome = Outcome()
275 if case .last = target, history.isEmpty {
276 outcome.messages.append("No last clock")
277 return outcome
278 }
279 let wasRunning = running != nil
280 let stopped = leftover
281 if !wasRunning {
282 leftover = nil
283 for clock in openClocks(session, paths: paths) {
284 let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
285 let result = await resolve(
286 clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: true, session, ask: ask, now: now
287 )
288 outcome.messages += result.messages
289 }
290 }
291 let location: (path: String, offset: Int)
292 switch target {
293 case .caret:
294 guard let path = session.path, session.document != nil else {
295 outcome.messages.append("No file is open.")
296 return outcome
297 }
298 location = (path, session.caret)
299 case .select:
300 switch await chooseTask(session, prompt: "Clock-in on task: ", ask: ask) {
301 case .failure(let failure):
302 outcome.messages.append(failure.message)
303 return outcome
304 case .success(nil):
305 outcome.messages.append("Abort")
306 return outcome
307 case .success(let task?):
308 guard let found = locate(task, session) else {
309 outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
310 return outcome
311 }
312 location = found
313 }
314 case .last, .task:
315 let task: ClockTask
316 if case .task(let chosen) = target { task = chosen } else { task = history[0] }
317 guard let found = locate(task, session) else {
318 outcome.messages.append("\(task.title) isn't in \((task.path as NSString).lastPathComponent) any more")
319 return outcome
320 }
321 location = found
322 }
323 var start = now()
324 if let stopped, !isRunning(path: location.path, heading: headingStart(location, session) ?? -1, session) {
325 let minutes = Int(start.timeIntervalSince(stopped) / 60)
326 let answer = await ask(Prompt(
327 key: "clock-leftover", message: "You stopped another clock \(minutes) mins ago; start this one from then? (y or n) ",
328 fast: FastSelection(items: [.option(key: "y", name: "yes"), .option(key: "n", name: "no")], multiple: false)
329 ))
330 if answer == "y" { start = stopped }
331 }
332 let message = clockIn(session, path: location.path, at: location.offset, now: start)
333 if case .last = target, !wasRunning, let running {
334 outcome.messages.append("Clocking back: \(running.heading) (in \((running.path as NSString).lastPathComponent))")
335 } else {
336 outcome.messages.append(message)
337 }
338 return outcome
339 }
340
341 private func headingStart(_ location: (path: String, offset: Int), _ session: DocumentSession) -> Int? {
342 guard let text = Self.text(location.path, session) else { return nil }
343 return DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= location.offset }?.headingRange.lowerBound
344 }
345
346 // MARK: - Resolving clocks
347
348 /// `org-find-open-clocks` over `paths` and the open org buffers.
349 public func openClocks(_ session: DocumentSession, paths: [String]) -> [OpenClock] {
350 var seen = Set<String>()
351 let all = (paths + session.openDocuments.map(\.path).filter { $0.hasSuffix(".org") }).filter { seen.insert($0).inserted }
352 var clocks: [OpenClock] = []
353 for path in all {
354 guard let text = Self.text(path, session), text.contains("CLOCK: [") else { continue }
355 let open = ClockResolution.openClocks(in: text)
356 guard !open.isEmpty else { continue }
357 let headings = DocumentModel(tree: OrgParser.parse(text)).headings
358 for clock in open {
359 guard let startedAt = ClockResolution.date(of: clock.start) else { continue }
360 let heading = headings.last { $0.headingRange.lowerBound <= clock.offset }?.title ?? ""
361 clocks.append(OpenClock(path: path, start: clock.start, startedAt: startedAt, heading: heading))
362 }
363 }
364 return clocks
365 }
366
367 private func isActive(_ clock: OpenClock) -> Bool {
368 running?.path == clock.path && running?.start == clock.start
369 }
370
371 /// `org-resolve-clocks`: every open clock in `paths`, the running one included.
372 public func resolveOpenClocks(_ session: DocumentSession, paths: [String], ask: Ask, now: () -> Date = Date.init) async -> Outcome {
373 var outcome = Outcome()
374 let clocks = openClocks(session, paths: paths)
375 guard !clocks.isEmpty else {
376 outcome.messages.append("No open clocks")
377 return outcome
378 }
379 for clock in clocks {
380 let minutes = Int(now().timeIntervalSince(clock.startedAt) / 60)
381 let result = await resolve(
382 clock, description: "Dangling clock started \(minutes) mins ago", lastValid: clock.startedAt, clockingIn: false, session, ask: ask, now: now
383 )
384 outcome.messages += result.messages
385 if let jump = result.jump { outcome.jump = jump }
386 }
387 return outcome
388 }
389
390 /// `org-resolve-clocks-if-idle`: the running clock after the user was idle since `idleSince`.
391 public func resolveIdle(_ session: DocumentSession, idleSince: Date, ask: Ask, now: () -> Date = Date.init) async -> Outcome {
392 guard let running, !isResolving else { return Outcome() }
393 let clock = OpenClock(path: running.path, start: running.start, startedAt: running.startedAt, heading: running.heading)
394 let minutes = now().timeIntervalSince(idleSince) / 60
395 return await resolve(
396 clock, description: String(format: "Clocked in & idle for %.1f mins", minutes), lastValid: idleSince, clockingIn: false, session, ask: ask, now: now
397 )
398 }
399
400 /// `org-clock-resolve`: asks what to do with `clock`'s time since `lastValid` and does it.
401 func resolve(
402 _ clock: OpenClock, description: String, lastValid: Date, clockingIn: Bool, _ session: DocumentSession, ask: Ask, now: () -> Date
403 ) async -> Outcome {
404 isResolving = true
405 defer { isResolving = false }
406 var outcome = Outcome()
407 var key: Character = "q"
408 while true {
409 guard let answer = await ask(Prompt(key: "clock-resolve", message: description + ClockResolution.keyHint, fast: ClockResolution.menu)) else {
410 return outcome
411 }
412 if let c = answer.first, ClockResolution.keys.contains(c) {
413 key = c
414 break
415 }
416 }
417 var minutes: Int?
418 var defaultMinutes: Int?
419 switch ClockResolution.input(for: key) {
420 case .minutes(let prompt):
421 let fallback = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
422 defaultMinutes = fallback
423 while minutes == nil {
424 guard let answer = await ask(Prompt(key: "clock-minutes", message: "\(prompt) (default \(fallback)): ")) else { return outcome }
425 let trimmed = answer.trimmingCharacters(in: .whitespaces)
426 minutes = trimmed.isEmpty ? fallback : Int(trimmed)
427 }
428 case .date:
429 guard let answer = await ask(Prompt(key: "clock-date", message: "Date+time: ", date: .init(defaultDate: nil))) else { return outcome }
430 let calendar = Calendar.current
431 let read = ReadDate.read(answer, default: nil, now: now(), calendar: calendar)
432 guard let date = calendar.date(from: read.components(calendar)) else { return outcome }
433 minutes = ClockResolution.minutes(from: lastValid, to: date)
434 defaultMinutes = ClockResolution.defaultMinutes(lastValid: lastValid, now: now())
435 case nil:
436 break
437 }
438 let plan: ClockResolution.Plan
439 do {
440 plan = try ClockResolution.plan(
441 key: key, minutes: minutes, start: clock.startedAt, lastValid: lastValid, now: now(), active: isActive(clock),
442 clockingIn: clockingIn, defaultMinutes: defaultMinutes
443 )
444 } catch {
445 outcome.messages.append((error as? ClockResolution.Failure)?.message ?? "\(error)")
446 return outcome
447 }
448 outcome.messages = apply(plan, to: clock, session)
449 if plan.jump { outcome.jump = lineLocation(path: clock.path, start: clock.start, session) }
450 return outcome
451 }
452
453 /// Carries out `plan` on `clock`'s line.
454 func apply(_ plan: ClockResolution.Plan, to clock: OpenClock, _ session: DocumentSession) -> [String] {
455 var messages: [String] = []
456 let active = isActive(clock)
457 let task = lineLocation(path: clock.path, start: clock.start, session).flatMap { line in
458 Self.text(clock.path, session).flatMap { ClockTask.at(line.offset, in: $0, path: clock.path) }
459 }
460 for step in plan.steps {
461 switch step {
462 case .cancel:
463 if active {
464 messages.append(clockOut(session, cancel: true))
465 } else {
466 messages.append(perform(ClockOut(start: clock.start, cancel: true), clock, session, now: Date()) ?? "Clock canceled")
467 }
468 case .clockOut(let time):
469 if active {
470 messages.append(clockOut(session, now: time))
471 } else {
472 let minutes = Int(time.timeIntervalSince(clock.startedAt) / 60)
473 messages.append(perform(ClockOut(start: clock.start), clock, session, now: time)
474 ?? String(format: "Clock stopped after %d:%02d", minutes / 60, minutes % 60))
475 }
476 case .clockIn(let time):
477 guard let task, let location = locate(task, session) else { continue }
478 messages.append(clockIn(session, path: location.path, at: location.offset, now: time))
479 case .resume:
480 if running != nil { messages.append(clockOut(session, now: Date())) }
481 running = Running(path: clock.path, start: clock.start, heading: clock.heading, startedAt: clock.startedAt)
482 if let task { push(task, session) }
483 save()
484 messages.append("Clock starts at [\(clock.start)]")
485 }
486 }
487 if plan.setsLeftover { leftover = plan.leftover }
488 return messages
489 }
490
491 /// Runs a clock command on a clock that isn't the running one; nil when it worked.
492 private func perform(_ command: ClockOut, _ clock: OpenClock, _ session: DocumentSession, now: Date) -> String? {
493 switch session.perform(command, path: clock.path, at: 0, expect: nil, now: now) {
494 case .commit: return nil
495 case .failed(let message): return message
496 default: return "Can't change the clock."
497 }
123498 }
124499}
Sources/OrgApp/OrgPreferences.swift +4
@@ -53,6 +53,10 @@ public enum OrgPreferences {
5353 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) }
5454 /// `appt-message-warning-time`, in minutes.
5555 public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) }
56 /// `org-clock-idle-time` in minutes; 0 is nil (never).
57 public static func clockIdleTime(_ defaults: UserDefaults = .standard) -> Int { integer("clockIdleTime", defaults) }
58 /// `org-clock-history-length`.
59 public static func clockHistoryLength(_ defaults: UserDefaults = .standard) -> Int { integer("clockHistoryLength", defaults) }
5660
5761 private static func fallback(_ key: String) -> TOML.Value? {
5862 UserConfig.settings.first { $0.defaultsKey == key }?.fallback
Sources/OrgApp/UserConfig.swift +4
@@ -143,6 +143,10 @@ public enum UserConfig {
143143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144144 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
145145 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
146 Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
147 comment: "minutes idle with a clock running before asking what to do with the time; 0 never (nil)", legacy: ""),
148 Setting(section: "", key: "org-clock-history-length", defaultsKey: "clockHistoryLength", kind: .integer, fallback: .integer(5),
149 comment: "recently clocked entries to remember", legacy: ""),
146150 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
147151 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
148152 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
Sources/OrgCore/Commands/ClockResolution.swift +5 −3
@@ -58,6 +58,7 @@ public enum ClockResolution {
5858 /// Org's error, for the echo area.
5959 public struct Failure: Error, Equatable {
6060 public let message: String
61 public init(message: String) { self.message = message }
6162 }
6263
6364 public enum Step: Equatable, Sendable {
@@ -83,9 +84,10 @@ public enum ClockResolution {
8384 /// What `key` does to a clock that started at `start`, idle since `lastValid`.
8485 /// `minutes` is the answer to `input(for:)` (for `t`, `minutes(from:to:)`). `active`: the
8586 /// clock is the running one. `clockingIn`: resolving before a clock-in
86 /// (`org-clock-clocking-in`), which never restarts or resumes.
87 /// (`org-clock-clocking-in`), which never restarts or resumes. `defaultMinutes` is the
88 /// default the minutes prompt offered, when there was one.
8789 public static func plan(
88 key: Character, minutes: Int?, start: Date, lastValid: Date, now: Date, active: Bool, clockingIn: Bool
90 key: Character, minutes: Int?, start: Date, lastValid: Date, now: Date, active: Bool, clockingIn: Bool, defaultMinutes: Int? = nil
8991 ) throws -> Plan {
9092 var plan = Plan()
9193 if key == "j" || key == "J" {
@@ -94,7 +96,7 @@ public enum ClockResolution {
9496 return plan
9597 }
9698 guard "kKgGsSCtT".contains(key) else { return plan }
97 let defaultMinutes = defaultMinutes(lastValid: lastValid, now: now)
99 let defaultMinutes = defaultMinutes ?? Self.defaultMinutes(lastValid: lastValid, now: now)
98100 let keep = "kKtT".contains(key) ? minutes : nil
99101 let gotback = "gG".contains(key) ? minutes : nil
100102 let subtract = key == "s" || key == "S"
Tests/OrgAppTests/ClockHistoryModelTests.swift added +130
@@ -0,0 +1,130 @@
1import Foundation
2import OrgCore
3import OrgDocument
4import Testing
5@testable import OrgApp
6
7/// Answers prompts in order and remembers what they asked.
8@MainActor
9final class ScriptedAnswers {
10 var answers: [String?]
11 var asked: [String] = []
12 init(_ answers: [String?]) { self.answers = answers }
13 var ask: ClockModel.Ask {
14 { prompt in
15 self.asked.append(prompt.message)
16 return self.answers.isEmpty ? nil : self.answers.removeFirst()
17 }
18 }
19}
20
21@MainActor
22struct ClockHistoryModelTests {
23 let notes: Folder
24 let work: URL
25 let session: DocumentSession
26 let stateFile: URL
27 let defaults: UserDefaults
28 let start = Date(timeIntervalSince1970: 1_789_000_000)
29
30 init() throws {
31 notes = try Folder()
32 work = try notes.write("work.org", "* A\n* B\n:PROPERTIES:\n:ID: b1\n:END:\n* C\n")
33 session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r")))
34 session.open(work.path)
35 stateFile = notes.url.appendingPathComponent("clock.json")
36 defaults = UserDefaults(suiteName: "orgstar-clock-\(UUID().uuidString)")!
37 }
38
39 func offset(_ heading: String) -> Int {
40 (session.document!.text as NSString).range(of: heading).location
41 }
42
43 @Test func historyIsKeptAndPersisted() throws {
44 let clock = ClockModel(file: stateFile, defaults: defaults)
45 for (i, heading) in ["* A", "* B", "* A", "* C"].enumerated() {
46 _ = clock.clockIn(session, path: work.path, at: offset(heading), now: start.addingTimeInterval(Double(i * 600)))
47 }
48 #expect(clock.history.map(\.title) == ["C", "A", "B"])
49 #expect(clock.interruptedTask?.title == "A")
50 let reloaded = ClockModel(file: stateFile, defaults: defaults)
51 #expect(reloaded.history == clock.history && reloaded.running == clock.running)
52
53 // Headings move and are renamed: entries are found by ID, else position and title.
54 session.reveal(0)
55 _ = session.perform(PrecomputedEdit(EditResult(baseRevision: session.document!.revision, edits: [TextEdit(range: 0..<0, replacement: "* New\n")])), path: work.path, at: 0, expect: nil)
56 #expect(clock.locate(clock.history[2], session)?.offset == offset("* B"))
57
58 defaults.set(2, forKey: "clockHistoryLength")
59 _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(3000))
60 #expect(clock.history.map(\.title) == ["B", "C"])
61 }
62
63 @Test func oldClockFilesStillLoad() throws {
64 let running = ClockModel.Running(path: work.path, start: "2026-09-10 Thu 00:26", heading: "A", startedAt: start)
65 try JSONEncoder().encode(running).write(to: stateFile)
66 let clock = ClockModel(file: stateFile, defaults: defaults)
67 #expect(clock.running == running && clock.history.isEmpty)
68 }
69
70 @Test func selectingRecentEntries() async throws {
71 let clock = ClockModel(file: stateFile, defaults: defaults)
72 let none = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers([]).ask)
73 #expect(none.messages == ["No recent clock"])
74 _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
75 _ = clock.clockIn(session, path: work.path, at: offset("* B"), now: start.addingTimeInterval(600))
76 _ = clock.markDefault(session, path: work.path, at: offset("* C"))
77 let choices = try #require(clock.selection(session))
78 #expect(choices.map(\.key) == ["d", "i", "c", "1", "2"])
79 #expect(choices.map(\.task.title) == ["C", "A", "B", "B", "A"])
80
81 let answers = ScriptedAnswers(["2"])
82 let outcome = await clock.clockIn(session, target: .select, paths: [work.path], ask: answers.ask, now: { start.addingTimeInterval(1200) })
83 #expect(answers.asked == ["Clock-in on task: "])
84 #expect(clock.running?.heading == "A" && outcome.messages.last?.hasPrefix("Clock starts at") == true)
85 let same = await clock.clockIn(session, target: .last, paths: [], ask: ScriptedAnswers([]).ask)
86 #expect(same.messages == ["Clock continues in \"A\""])
87 let aborted = await clock.clockIn(session, target: .select, paths: [], ask: ScriptedAnswers(["q"]).ask)
88 #expect(aborted.messages == ["Abort"])
89
90 _ = clock.clockOut(session, now: start.addingTimeInterval(1800))
91 let goto = clock.goto(session)
92 #expect(goto.location?.offset == offset("* A") && goto.message == "No running clock, this is the most recently clocked task")
93 let recent = await clock.gotoRecent(session, ask: ScriptedAnswers(["2"]).ask)
94 #expect(recent.location?.offset == offset("* B"))
95 }
96
97 @Test func danglingClocksAreResolvedBeforeClockingIn() async throws {
98 let other = try notes.write("other.org", "* Old\n:LOGBOOK:\nCLOCK: [2026-09-09 Wed 09:00]\n:END:\n")
99 let clock = ClockModel(file: stateFile, defaults: defaults)
100 let opened = clock.openClocks(session, paths: [other.path])
101 #expect(opened.map(\.heading) == ["Old"])
102 let answers = ScriptedAnswers(["x", "K", "30"])
103 let outcome = await clock.clockIn(session, target: .caret, paths: [other.path], ask: answers.ask, now: { opened[0].startedAt.addingTimeInterval(7200) })
104 #expect(answers.asked.first == "Dangling clock started 120 mins ago" + ClockResolution.keyHint)
105 #expect(answers.asked.count == 3 && answers.asked[2] == "Keep how many minutes (default 120): ")
106 #expect(outcome.messages.count == 2)
107 #expect(clock.running?.heading == "A")
108 let text = try #require(ClockModel.text(other.path, session))
109 #expect(text.contains("CLOCK: [2026-09-09 Wed 09:00]--[2026-09-09 Wed 09:30] => 0:30"), "\(text)")
110 // Resolving before a clock-in never leaves time over.
111 #expect(clock.leftover == nil)
112 }
113
114 @Test func idleTimeIsSubtractedAndOfferedToTheNextClock() async throws {
115 let clock = ClockModel(file: stateFile, defaults: defaults)
116 _ = clock.clockIn(session, path: work.path, at: offset("* A"), now: start)
117 let idle = start.addingTimeInterval(20 * 60)
118 let answers = ScriptedAnswers(["S"])
119 _ = await clock.resolveIdle(session, idleSince: idle, ask: answers.ask, now: { idle.addingTimeInterval(45 * 60) })
120 #expect(answers.asked == ["Clocked in & idle for 45.0 mins" + ClockResolution.keyHint])
121 #expect(clock.running == nil && clock.leftover == idle)
122 #expect(session.document!.text.contains("=> 0:20"))
123
124 let next = ScriptedAnswers(["y"])
125 let c = try #require(ClockTask.at(offset("* C"), in: session.document!.text, path: work.path))
126 _ = await clock.clockIn(session, target: .task(c), paths: [], ask: next.ask, now: { idle.addingTimeInterval(50 * 60) })
127 #expect(next.asked == ["You stopped another clock 50 mins ago; start this one from then? (y or n) "])
128 #expect(clock.running?.heading == "C" && clock.running?.startedAt == idle && clock.leftover == nil)
129 }
130}