Storage Warnings
A storage file can be unreadable for a while: a sync service left it truncated, half-downloaded or empty, or the app has no permission to open it. Storage keeps working around such a file and never changes it (see AutomergeStorageService). This page covers what the app shows.
Before this, a load error was visible only in the Debug state view, and one unreadable file hid every record.
State
Two properties on AppState drive everything:
private(set) var storageWarnings: [UnreadableStorageFile] = []
private(set) var tagsUnavailable = false storageWarningsis whatstorage.unreadableFiles()returned at the end of the lastloadData(): one entry per unreadable month file, recovery file or tags file. It is read after all the loads, because storage reports only what its loads have observed. It is not updated when the storage folder was switched while the load ran (the list would describe the old folder), nor when the load was overtaken, see below.tagsUnavailableis set when loading tags failed withAutomergeStorageError.tagsFileUnreadable.tagsthen keeps its last known value, so records still show their tag names; after a fresh launch with an unreadable tags file there is no last known value and the list is empty.
loadData() loads tags separately from records. The tags load returns a Result, because a throwing async let awaited first would cancel the record loads. Today’s records, running timers and the tag usage map load whether or not the tags file is readable.
Both are reset by reinitializeStorage().
Load generation. loadData() can run several times at once (launch, the 60-second refresh, an external change, a widget action), and invalidating storage’s cache makes it forget which files were unreadable. A load that finished its reads before a newer load or an invalidation started would then publish a list with files missing. AppState keeps a counter, loadGeneration, incremented by every loadData(), every cache invalidation (invalidateStorageCache()) and reinitializeStorage(). A load remembers the value it started with and publishes storageWarnings only if the counter is still the same, checked before and after it asks storage; an overtaken load publishes nothing and the newer one publishes its own list.
Timing: loadData() runs on launch, on the 60-second refresh and after a detected external change. A file that becomes unreadable or readable changes its modification date or its size, so the external change monitor picks it up on its next probe (5 seconds while the app is active) and the warning appears or clears without user action. A file that could not be opened is tried again every 60 seconds.
ContentView reloads the History records when storageWarnings changes (unless a record editor or an export is open): a month that became unreadable or readable changes which records exist.
Banner
StorageWarningBanner (Minuta/Sources/Views/Components/StorageWarningBanner.swift) is one row at the top of the main window, shown while there is anything to warn about. It sits in a top safeAreaInset inside the navigation stack, so it is under the toolbar and pushes the list down instead of covering it. Tapping it opens the Settings sheet, where each file is listed.
Layout: an orange exclamationmark.triangle SF Symbol, a title and a subtitle, a chevron. Accessibility identifier: storageWarningBanner. The mini timer window does not show it.
The two lines come from StorageWarningSummary, built from storageWarnings and tagsUnavailable. Months are counted once, whether their month file, their recovery file or both are unreadable.
| Situation | Title | Subtitle |
|---|---|---|
| only the tags file | Tags can't be read | Entries are not changed. Tags can't be edited for now. |
| one month, its month file | April 2026 can't be read | Its entries are hidden. New entries for that month are kept separately. |
| one month, month file and recovery file | April 2026 can't be read | Its entries are hidden, and new entries for that month can't be saved. |
| one month, only its recovery file | Some entries for April 2026 can't be read | Entries kept separately for that month are hidden. |
| several months | 2 months can't be read | Some entries are hidden. Open Settings for details. |
| tags and one or more months | Tags and 1 month can't be read, Tags and 2 months can't be read | Some entries are hidden. Open Settings for details. |
The month name is formatted for the user’s locale (wide month and year). User-facing text says “entries kept separately”; the recovery file is not named in the UI outside the path shown in Settings.
Settings: Storage problems
SettingsSheet shows a section with the header Storage problems after the Data section while storageWarnings is not empty. One row per file:
- a title: the month name (
April 2026) for a month file,April 2026, entries kept separatelyfor a recovery file,Tagsfor the tags file; - the path below the data folder, for example
records/2026/04.automerge; - the reason reported by storage;
- for a file that could not be opened:
The file could not be opened. Minuta tries again every minute.; - for a month file:
New entries for this month are kept separately.when the month has a recovery file,No entries are kept separately for this month yet.otherwise; - a
Copy Pathbutton that puts the file’s full path on the pasteboard (identifierstorageProblemCopyPath).
The section footer is the instruction:
Minuta did not change these files. If a file is still syncing, wait for it to finish. If it is lost, move or delete it in Finder or Files; the entries kept separately then take its place.
There is no button that removes or replaces a file. The user does that in Finder or Files; on the next write to that month the recovery document becomes the month’s document.
Tags while the tags file is unreadable
Storage refuses every tag change, so the UI does not offer one:
- Settings, Tags section:
Add Tagis disabled, the swipe actions and the context menu of a tag are not offered, and the section footer readsTags can't be added or changed until the tags file is readable again. See Storage problems below.(identifiertagsUnavailableNote). - The add-tag sheet’s confirm button is disabled.
- The tag menu in the tag filter: the section title becomes
Tags can't be edited right nowand Rename, Archive and Delete are disabled. AppState.renameTag(_:to:)returnsTags can't be changed until the tags file is readable again.without changing anything; the tag filter shows it in itsTag Erroralert. A rename into an existing name is a merge that retags records before it deletes the tag; with tag changes refused it would stop halfway.- Import from HEY does not open the file picker and shows
Import is not available while tags can't be read: the imported entries would lose their categories. See Storage problems.The import itself also creates all tags before any record and stops if one cannot be created, instead of importing those entries untagged. Its records are written withTimeTrackingService.restoreRecord(_:): they are new, andupdateRecordwould be refused while a month file is unreadable.
Timers still work:
- the play button and a new timer with an empty tag field start an untagged timer;
- a typed name that matches a last known tag uses that tag;
- a typed name that would need a new tag does not start anything.
NewTimerSheetcallsAppState.startTimer(tagName:comment:), which throws; the sheet stays open and shows an alert titledTimer Not Startedwith the error text. Before, the timer started silently without the tag; - a widget button that starts a timer with a tag keeps the tag.
WidgetIntentService.start(tagId:)normally checks the id against the tags and starts an untagged timer for an unknown one; while the tags file is unreadable the id cannot be checked and is used as passed, since the widget showed that tag.
Tags while entries are hidden
Deleting a tag, and merging one into another (a rename into an existing name), is safe only when every record can be seen: a record inside an unreadable file would keep the id of a tag that is gone. So both are refused while any month file or recovery file is unreadable (monthsWithHiddenRecords). The tags file itself can be readable; adding, renaming to a new name and archiving still work.
The message, from AppState.tagRemovalBlockedMessage:
Tags can’t be deleted or merged while April 2026 can’t be read: hidden entries would keep a tag that no longer exists.
Several months are joined as a list (April 2026 and May 2026).
- Settings, Tags section: the Delete swipe action is not offered, Delete in the context menu is disabled, and the section footer shows the message followed by
See Storage problems below.(identifiertagRemovalBlockedNote). Archive and Unarchive stay. - The tag menu in the tag filter: Delete is disabled. Rename stays enabled, because a plain rename is allowed; a rename that turns out to be a merge is refused.
- The property reflects the last load.
AppState.deleteTag(_:)and the merge insiderenameTag(_:to:)therefore look again themselves: they callstorage.loadAllRecords()and askunreadableFiles()right before acting, and refuse if a file has become unreadable since. deleteTag(_:)andrenameTag(_:to:)return why nothing was done, nil on success (errorMessageis set too). The tag filter shows the reason in an alert titledTag Errorwith anOKbutton; Settings shows it as the red error text of the sheet.
Record editor alerts
RecordEditor shows two alerts of its own, each with an OK button, and stays open under them. A third kind is shown by the window that hosts a running timer. Details of the editor: Record Editor.
Save Error. An explicit save (the Save button, or Stop on a running timer) that storage rejects shows the error text, for example when a month’s file and its recovery file are both unreadable:
Records for 2026-04 cannot be saved: 04.automerge and 04.recovery.automerge are both unreadable. Neither file was changed.
Or when the record is on screen but its month file has become unreadable since, so storage can no longer find it and refuses the edit instead of writing a stale copy:
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.
Auto-save of a running timer stays quiet on failure; the next explicit save reports.
An explicit save ends in one of three ways (EditorSaveOutcome):
| Outcome | Meaning | Save button | Stop button |
|---|---|---|---|
saved | the record was saved | closes the editor | stops the timer |
savedWithoutTag | the typed tag could not be created; the rest of the edit was saved with the tag the record already has | editor stays open under Save Error | stops the timer, then reports the tag once |
notSaved | storage rejected the record | editor stays open under Save Error | the timer keeps running under Save Error |
The savedWithoutTag text is The tag was not saved. followed by the storage error. Stop does not keep the timer running for it: creating the tag would fail the same way on every try.
Stop Error and TimerAlert. A running timer’s row disappears when the timer stops, so it cannot show what Stop has to report. TimerAlert (a title and a message) is handed to the hosting window through RecordEditor.onTimerAlert and shown there by the timerAlert(_:) modifier with an OK button: on ContentView for the Running section, on MiniTimerView for the mini window (its rows and its embedded editor).
Stop Error: the edit was saved but stopping failed.AppState.stopTimer(_:)returns the reason, nil when the timer was stopped. The stop button of a mini window row calls it directly and reports the same way.Save Error: the timer was stopped, but the typed tag was not created (savedWithoutTag). Shown once, after the stop.
An editor without onTimerAlert falls back to its own Save Error alert for both.
Delete Error. A delete that storage rejects shows the reason and leaves the record in place. The case this was added for is a record that lives inside an unreadable month file:
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.
For this, onDelete is (() async -> String?)?: it returns why the record was not deleted, nil when it was. AppState.softDeleteRecord(_:) returns the same value, and HistorySection, RunningTimersSection, TodaySectionView and MiniTimerView pass it through and close the editor only on success.
One action at a time. Delete, Save, Stop and the running timer’s cancel button are disabled while one of them waits for storage (isActionInFlight); a second tap would start the same operation again and act on its answer before the first one’s. Across windows the same is handled in AppState.softDeleteRecord(_:): a second delete of a record whose first delete is still in storage joins it (softDeletesInFlight) and returns its outcome, so no editor closes on a delete that then fails. A record that already has a pending deletion is not deleted again.
Undo. AppState.undoDelete(_:) writes the record back with TimeTrackingService.restoreRecord(_:), not updateRecord: storage no longer holds the record, and updateRecord refuses a record it cannot find while a month file is unreadable. See Deletion toast.
The tag field does not clear tags
The editor used to write the tag resolved from the text field on every save. With tags unavailable, or with a tag that no longer exists, the field is empty, and any edit would have cleared the record’s tagId.
TagFieldDecision.decide(input:baseline:existingTagId:createTag:) now compares the field with the text it showed when the editor opened (the baseline):
| Field | Decision |
|---|---|
| unchanged | keep the record’s tag |
| changed to empty | clear the tag |
| changed to the name of an existing tag | use that tag |
| changed to a new name, explicit save | create the tag, then use it |
| changed to a new name, auto-save | keep the record’s tag (auto-save does not create tags) |
Only a change made by the user decides anything, with one exception: a dead tag id. When the tags are available and the record’s tagId matches none of them (TagFieldDecision.isDeadTag), nothing can show that tag again, so decide gets hasDeadTag: true and the two “keep the record’s tag” rows become “clear the tag”: a save removes the id unless the field names a tag to use instead. This is never done while the tags file is unreadable, because the known tags are then only the last ones read and the tag may still exist.
Actions without an alert
Starting a timer when the current month’s file and its recovery file are both unreadable has no editor or row to show an alert in. It is reported as an error toast, like every other action without an alert of its own.
Tests
Minuta/Tests/AppStateStorageWarningTests.swift:loadDatawith an unreadable month or tags file, timers with tags unavailable, the deletion error,TagFieldDecision, the banner texts.Minuta/UITests/Flows/StorageWarningTests.swift: corrupts files in the test storage folder while the app runs and checks the banner, the Settings section, the recovery file on disk, the two editor alerts and the error toast of a timer that could not start.
Related
- AutomergeStorageService - month states, the recovery document,
unreadableFiles(), the typed errors - Cross-process safety - the same feature across the app, the CLI and several devices
- CLI reference - the stderr warning and the
doctorrows - AppState -
storageWarnings,tagsUnavailable - Settings Sheet - where the Storage problems section lives