Record Editor
Unified editor for both running timers and completed records.
Modes
Running Timer Mode
- Live ticking duration via
TimelineView - Blue circle stop button
- Tag selector (sorted by last usage — see below)
- Comment text field
- Auto-saves on edit (no Save button)
- Tags created only when stopped
Tag Sort Order
The tag selector lists active (non-archived) tags sorted by most recent usage. “Last usage” is the most recent startTime of any record that references the tag; never-used tags fall to the bottom alphabetically (case-insensitive). The map is built once on loadData from a full loadAllRecords() scan and updated incrementally on startTimer and updateRecord. Computed via AppState.activeTagsByLastUsage. The history Tag Filter view continues to use unsorted activeTags.
Completed Record Mode
- Start/End date pickers
- Duration display (calculated)
- Tag selector
- Multiline comment (TextEditor, 60-120pt)
- Image gallery with add/remove
- Cancel/Save buttons
- Close (X) button at top
Completed records open in a sheet (RecordEditorSheet, HistoryGrid.swift). On Mac Catalyst its width is fitted to the content and clamped to 640-800pt by WidthClampedFittedSizing, and to the main window’s width when the window is narrower: a sheet is never wider than its window, and content laid out at 640pt in a 400pt window was cut off at both sides, Save included. PresentationSizingContext carries no container size, so the sheet reads the main window scene’s width itself. UI test: RecordEditingTests.testEditSheetMinWidthOnMac (runs in the 400pt test window).
Image Support
Uses RecordImageViews components:
- Thumbnail gallery: loads again whenever the record’s image list changes (
.task(id: record.images)), loaded images cached by filename, so an image added to an open editor replaces its spinner without a save - “Add photo” link (opens picker)
- “Browse files” link (document picker on Mac)
- Full-screen viewer with pinch-to-zoom: a
.sheet(item:)that carries the tapped image (separate index and image state left the sheet blank on a running timer), narrowed to the window with.fitsMainWindowWidth() - Context menu: View, Share, Delete
- A picked image shows a spinner tile in the gallery at once (
pendingImages), before it is read: a full-size photo, possibly downloaded from iCloud, takes seconds. Add Photo and Browse Files are disabled until it is stored - An added image is stored as JPEG, longest side at most 1920 pixels.
ImageUtilities.jpegData(contentsOf:)decodes the file straight at that size with ImageIO (orientation applied), off the main thread; the picker hands over the file (loadFileRepresentation), never a full-sizeUIImage
Validation
- End time must be after start time
- Shows error if invalid range
Write Order
Every write of the editor sends the whole record, so two of them in flight at once would have the later one undo the earlier one. Two rules keep them apart:
- One write at a time. Saves (auto-save, Save, the save that Stop does first) and image add/remove run through a
SerialWriteQueue(Sources/Services/SerialWriteQueue.swift) held by the editor. Each operation reads the editor’scurrentRecordwhen its turn comes, not when it was requested: an auto-save that fires during an image add is written with the image, and an image added during a save keeps the saved comment and start time. - Auto-save never overtakes a stop or a delete. The auto-save waits 300 ms after the last keystroke and writes the record without an end time. Delete, Save and Stop cancel the pending one, and it goes through
AppState.autoSaveRunningTimer(_:), which writes nothing once the timer has leftrunningTimersor while a stop or delete of it is in flight. The second check covers a stop from another place (mini window, Stop All, tray) while this editor’s auto-save is pending; a stop by a Shortcut or a widget button is announced toAppStatebefore it is written (.minutaTimersStopping, see WidgetData) and refused the same way. Without it a stopped timer was written back as running and a deleted one was re-created.
Not covered: a stop by another process (the CLI, another device) is unknown to the app until its next reload, and a stop from the mini window uses its own copy of the record, so a keystroke of the last 300 ms in the main window can be lost.
Error Alerts
The editor shows its own alerts and stays open under them:
- Save Error: an explicit save (Save, or Stop on a running timer) that storage rejected, for example “The record cannot be changed: it may be in a month file that cannot be read (2026-04). Try again once the file is readable.” A running timer whose save failed keeps running. Auto-save failures are quiet; the next explicit save reports. If only a new tag could not be created, the rest of the edit is saved with the record’s current tag and the alert reads “The tag was not saved.” followed by the error.
- Delete Error: a delete that storage rejected, for example “The record cannot be deleted: it may be in a month file that cannot be read (2026-04, 04.automerge). Try again once the file is readable.”
- Image Error: an image that could not be added or removed (the screenshot is not attached, or stays attached)
An explicit save returns an EditorSaveOutcome: saved, savedWithoutTag (the tag could not be created) or notSaved. Save closes the editor only on saved. Stop goes on to stop the timer on saved and on savedWithoutTag; on notSaved the timer keeps running.
What Stop has to report after the timer is gone is shown by the hosting window, not by the row (TimerAlert, passed through onTimerAlert, shown by ContentView and MiniTimerView): “Stop Error” with the reason when the stop itself failed, or “Save Error” with the tag message, once, when the timer stopped but the tag was not created. Without onTimerAlert the editor uses its own Save Error alert.
Delete, Save, Stop and the running timer’s cancel button are disabled while one of them waits for storage.
An untouched tag field does not change the record’s tag (TagFieldDecision): while tags are unavailable, a record whose tag cannot be shown keeps its tagId when other fields are edited. The exception is a dead tag id: when tags are available and the record’s tagId matches no tag, a save clears it unless the field names a tag to use instead. See 416-storage-warnings.
Callbacks
var onDelete: (() async -> String?)? // why the record was not deleted, nil when it was
var onDismissEdit: (() -> Void)?
var onRecordUpdated: ((TimeRecord) -> Void)?
var onTimerAlert: ((TimerAlert) -> Void)? // shows what Stop has to report in the hosting window
var startInEditMode: Bool = false Related
- 202-time-record - Data model
- Tag combo box - Tag selection
- 301-file-storage - Image storage
- 403-app-state - Save operations