AppState

Central state management for the app. Passed as @EnvironmentObject to all views.

Definition

@MainActor
class AppState: ObservableObject {
    @Published var tags: [Tag] = []
    @Published var tagsByID: [UUID: Tag] = [:]
    @Published var runningRecords: [TimeRecord] = []
    @Published var todayRecords: [TimeRecord] = []
    @Published var isLoading = false
    @Published var errorMessage: String?
    @Published var pendingDeletion: (record: TimeRecord, timer: Timer)?
}

Storage warnings

private(set) var storageWarnings: [UnreadableStorageFile] = []
private(set) var tagsUnavailable = false
PropertyDescription
storageWarningsStorage files the last loadData() could not read (storage.unreadableFiles(), read after all the loads). Their records are missing from the lists. Feeds the banner and the Settings section
tagsUnavailabletags.automerge is unreadable: tags holds the last known value and storage refuses every tag change

loadData() loads tags separately from records: an unreadable tags file sets tagsUnavailable and leaves tags as it was, while today’s records and running timers still load. An unreadable month file does not fail the load either; it ends up in storageWarnings. Both properties are reset by reinitializeStorage().

Loads can overlap, and a cache invalidation makes storage forget which files were unreadable. A private counter, loadGeneration, is incremented by every loadData(), every cache invalidation and reinitializeStorage(); a load publishes storageWarnings only if the counter still has the value the load started with, so an overtaken load does not publish a list with files missing.

Concurrent loads

loadData() is started from launch, scene activation, external changes, the widget’s .minutaDataChanged, pull to refresh, the mini window and Settings, and loads finish in any order. The lists (tags, todayRecords, runningTimers, tagLastUsedAt) are published only by a load that is not older than one already published (publishedGeneration) and that started after the state last changed its lists itself (localChangeGeneration, set by noteLocalChange() after every write the state applies locally: timer start and stop, tag create, rename, archive, delete, merge, record update, delete and undo). A load overtaken by such a change may have read the files from before the write, so it publishes nothing and starts a load again. A failure is reported only by a load that would have published.

Running timers are taken from the same loadAllRecords() pass that feeds the tag usage, one pass over every month per load. isLoading stays true while any load runs.

Every published load bumps historyRevision: another process may have changed any month, not only today. ContentView reloads History through one .task(id:) keyed on the date range, historyRevision, todayRecords, storageWarnings and whether a record editor or the share sheet is open (a reload would dismiss it; closing it reloads what was skipped). A newer reload cancels the one in flight, so a slow answer for a previous range is dropped.

tagRemovalBlockedMessage is the reason tags cannot be deleted or merged right now, nil when they can: set while storageWarnings contains an unreadable month file or recovery file (Tags can't be deleted or merged while April 2026 can't be read: hidden entries would keep a tag that no longer exists.). Details: 416-storage-warnings.

Error notices

private(set) var errorNotices: [ErrorNotice] = []

Failures shown to the user as toasts, see 417-error-toast. reportError(_:) adds one and sets errorMessage; dismissError(_:) and expireErrorNotices(now:) remove them. Methods that return or throw their failure to a view with an alert (stopTimer, softDeleteRecord, deleteTag, renameTag, updateRecordThrowing) set only errorMessage, which nothing but the DEBUG state view displays.

Key Methods

Data Loading

MethodDescription
loadData()Reload all data from 301-file-storage. Captures the storage and the tracking service at its start and publishes nothing once the folder has been switched: a load that began in the old folder would otherwise put that folder’s tags, records and tag usage into the state of the new one. A failure is reported as an error toast that stays until a load succeeds. Overlapping loads: see Concurrent loads
reinitializeStorage()Reset storage location and reload. Clears the lists, the tag usage, the storage warnings and a load-failure toast

Timer Operations

MethodDescription
startTimer(tagId:comment:)Start new timer via 303-time-tracking; a failure is reported as an error toast
stopAllTimers()Stop every running timer; failures are reported as one error toast
startTimer(tagName:comment:)Start a timer with a typed tag name, creating the tag when it is new. Throws without starting anything when the tag cannot be resolved or created; an empty name starts an untagged timer
stopTimer(_:)Stop a running timer. Returns why it was not stopped, nil when it was; errorMessage is set too

Record Operations

MethodDescription
updateRecord(_:)Save changes to a 202-time-record; returns false when storage rejected them
updateRecordThrowing(_:)Same, for callers that show the failure themselves (the record editor’s Save Error alert); errorMessage is set too. Storage refuses the edit of a record it cannot find while a month file is unreadable (recordMayBeInUnreadableMonth)
autoSaveRunningTimer(_:)The record editor’s debounced auto-save. Writes nothing and returns false when the timer is no longer in runningTimers or a stop or delete of it is in flight (406-record-editor)
deleteRecord(_:)Permanently delete a record
softDeleteRecord(_:)Delete from storage now, keep image files for a 60s undo window (412-deletion-toast). Returns why the record was not deleted, nil when it was. A second call for the same record while the first is still in storage joins it and returns its outcome; a record that already has a pending deletion is left alone
undoDelete(_:)Write the deleted record back while its toast is up, through TimeTrackingService.restoreRecord(_:) (the record is no longer in storage, so updateRecord could be refused). The record returns to todayRecords only if it starts today; historyRevision is bumped so History reloads
confirmDelete(_:)Dismiss the toast and purge the kept image files

Tag Operations

MethodDescription
createTag(name:color:)Create new 201-tag; returns nil and reports an error toast on failure
createTagThrowing(name:color:)Same, for callers that must not carry on without the tag (HEY import, tagId(forName:))
deleteTag(_:)Remove tag. Loads all records first and refuses while a month file or recovery file is unreadable. Returns why the tag was not deleted, nil when it was
toggleArchiveTag(_:)Archive/unarchive tag; a failure is reported as an error toast
renameTag(_:to:)Rename with merge if duplicate; refused while tagsUnavailable, and the merge is refused while a month file or recovery file is unreadable. Returns why nothing was renamed or merged, nil when it was (also nil for an empty or unchanged name)
tagId(forName:)Find or create tag by name; nil for an empty name, throws when the tag cannot be created (replaces resolveTagId(from:), which returned nil in both cases)
findTagId(from:)Find an existing tag by name, never creates one

Auto-Refresh

Data reloads every 60 seconds via timer:

  • Keeps UI in sync with filesystem changes
  • Handles external edits (e.g., iCloud sync)

Environment Usage

Must be passed explicitly to sheets:

.sheet(isPresented: $showSettings) {
    SettingsSheet()
        .environmentObject(appState)
}