Backlog

For completed tasks, see 911-completed.

Priority Legend

  • [P1] - High priority: bugs, blockers, quality issues affecting UX
  • [P2] - Medium priority: improvements, polish, technical debt
  • [P3] - Low priority: future enhancements, ideas, nice-to-have

Bugs [P1]

  • Record editor opens again after a save moves the record to another month in History (2026-10-15)

    • Location: HistoryGrid.swift (HistoryMonthRow)
    • Root cause: each month row presented the sheet when the edited id was among its records. Save publishes the moved record before the editor clears editingRecordId; when the record stays in the History range (it overlaps it), the new month’s row opened the editor again (iPhone, full height). Seen as the flaky RecordEditingTests.testMoveRecordToPreviousMonth (“Editor should close after Save”), failing on 1.0.8 CI and in about half of local runs, on 1.0.7 code too
    • Fix: the row that held the record at tap time keeps it (presentedRecord) and is the only one that presents the sheet while the id is set
  • Fix progressive loading not working (load more broken) (2026-01-02)

    • Location: HistoryGrid.swift:287-324 (LoadMoreTrigger)
    • Root cause: SwiftUI Grid is not lazy, so onAppear fires immediately on render, not on scroll
    • Fix: Replaced onAppear with GeometryReader viewport detection
    • Result: Grid height dropped from 3904px to 1986px (50% reduction)
    • Related: 803-load-more-bug
  • Fix cache race condition in FileStorageService (2026-01-03)

    • Location: FileStorageService.swift:384-428
    • Root cause: updateRecord() deleted old file before writing new, causing data loss on error
    • Fix: Write new file first, then delete old file, then update cache atomically
  • Fix AppState tag mutation inconsistency (2026-01-03)

    • Location: MinutaApp.swift:379-397, 447-469
    • Root cause: loadData() had optimization that skipped updates if tag count/IDs matched, missing property changes
    • Fix: Always update tags and rebuild dictionary on load; use direct assignment instead of optional chaining
  • Fix concurrent image operations (2026-01-03)

    • Location: RecordEditorViews.swift:453-509
    • Root cause: Rapid image adds could race on currentRecord
    • Fix: Added isImageOperationInProgress guard to prevent concurrent add/delete operations
  • Fix crash when opening editor for a record without tag and comment (2026-07-02)

    • Repro: start timer with no tag/comment, stop it, tap the completed record — app crashed on Mac Catalyst (SIGSEGV from an uncaught exception during sheet window creation, UINSSheetManager scene hosting path)
    • Root cause: with an empty tag input, TagSelector shows all tags, and its horizontal chips ScrollView reports its full content width (~3400pt for 34 tags) to MinWidthFittedSizing’s sizeThatFits(.unspecified) — the editor sheet took that width, blowing past the screen and crashing AppKit’s sheet-open animation. Same blowout risk applied to the image gallery.
    • Fix: replaced MinWidthFittedSizing with WidthClampedFittedSizing — clamps sheet width to 640–800pt and re-measures height at the clamped width (HistoryGrid.swift)
    • Regression test: BareRecordEditorTests.testOpenEditorForBareRecord with new bare-record fixture (34 tags + one untagged, comment-less, 11-second record); asserts the editor opens and Save/Cancel stay hittable. Passes on iPhone sim and Mac Catalyst.
  • UI test runs overwrite the CLI storage pointer (2026-07-03)

    • The app wrote storage-path.txt on every launch, including test launches with --test-storage=, so running UI tests pointed the CLI at a (soon deleted) temp dir until the real app was launched again
    • Only Mac Catalyst runs clobbered the real pointer: the unsandboxed Catalyst app resolves getpwuid() to the real home, while simulator processes resolve it inside the simulator container
    • Fix: publishResolvedStoragePath() skips the pointer write when isTestMode (StorageLocationManager.swift); the .minuta-version marker is still written into the test folder
    • Regression test: StoragePointerTests.testTestStorageLaunchDoesNotOverwriteCLIPointer — captures the pointer before launch, asserts it is unchanged after a --test-storage= launch. Verified failing before the fix (Catalyst) and passing after (Catalyst + iPhone sim)
  • Debug OKLCH colors

    • Investigate hue distribution across alphabets
    • Check color consistency between views
  • Fix view of tags in edit form (2026-07-28)

    • Tags display incorrectly in RecordEditor
    • Location: RecordEditorViews.swift
    • Resolved by WidthClampedFittedSizing (see the bare-record editor crash entry above): the tag chips ScrollView reported its full content width to the sheet’s sizing, so the editor stretched past the screen and the tag row rendered wrong. Clamping sheet width to 640–800pt and re-measuring height fixed the display.
  • Handle floating/undocked virtual keyboard (iPad)

    • Floating play button positioned wrong when the iPad keyboard is undocked or in floating (minimized) mode
    • The undocked/floating keyboard is not part of the safe area, so keyboard-avoidance offsets are computed from a full-height keyboard that isn’t there
    • Also check editor sheets and the tag/comment fields — content can be pushed or clipped by the phantom inset
    • Fix: read the actual keyboard frame (UIResponder.keyboardWillChangeFrameNotification) and intersect it with the window instead of relying on SwiftUI’s default keyboard avoidance
  • Fix window not hiding immediately on app close (Mac) (2026-07-28)

    • Window stays visible briefly after closing the app
    • Should hide immediately when user closes
  • Investigate empty block above running timers (2026-03-24)

    • Unexplained empty space appearing over working timers section
    • Need to identify the cause and remove

Test Coverage [P1]

Current: 619 unit tests passing (Shared package, 2026-10-15) + 120 app unit tests (MinutaTests target, iPhone simulator, 2026-10-15)

  • Add unit tests for StorageLocationManager (2026-07-03)

    • Wired the previously dormant Minuta/Tests/ into a new MinutaTests unit-test target (hosted in the app, project.yml), added to the scheme and a CI step
    • Added injection seams to StorageLocationManager.init (UserDefaults suite, launch arguments, StoragePathPublishing) — production defaults unchanged
    • 8 tests: --test-storage= parsing (valid/empty), default-location fallback, failed-bookmark-restore reset, useDefaultLocation bookkeeping, publish rules (test mode skips the CLI pointer but writes the marker), accessibility checks
    • Security-scoped bookmark round-trips intentionally untested — those APIs don’t work meaningfully in a unit-test host; covered by real app usage
  • Consolidate UI tests into multi-check journeys per app launch (2026-07-09, done 2026-07-10)

    • Problem: 52 UI test methods, each relaunching the app in setUpWithError (launch + fixture copy + settle is the dominant cost; full suite ~25 min per platform, run twice for both platforms)
    • Done: merged compatible checks into journey methods, one per flow — Filter 10→2, RecordEditing 6→2, Settings 5→2, ReportFlow 7→1, ReportWithImages 3→1, TagManagement 3→1, TimerFlow 2→1, DocumentPicker 2→1, UpdatePrompt 5→4 (the two default-launch checks merged; the relaunch-based ones own their launch by design). 52 methods → 23.
    • Each scenario runs inside XCTContext.runActivity for readable failure reports; scenarios restore the state they change (filter selections, calendar presets, sheet dismissal) so later scenarios start from the shared baseline
    • Kept isolated (own their launch): UpdatePromptTests relaunch tests (launch-time pill/defaults state), StoragePointerTests (launch-time pointer behavior), PerformanceTests (measure blocks), BareRecordEditorTests (dedicated fixture), and testEditSheetMinWidthOnMac (leaves the editor sheet open for measurement)
    • All snapshot() names preserved — the website gallery (TestClass--name.png) gets the same file set
    • Verified green on both platforms 2026-07-10 (Mac Catalyst 24/24, iPhone sim all passing)
  • Add UI test for image attachment (2026-07-12)

    • ImageAttachmentTests.testAddImageToRecord (empty fixture): creates a record via the timer, opens its editor, taps Add Photo, picks a stock photo through PHPicker, asserts the editor’s Images gallery appears, saves, asserts the thumbnail shows in the record row; snapshots each step
    • PHPicker automation notes (it is a remote view controller, but its elements are exposed through the host app’s a11y tree): grid cells are Images with identifier PXGGridLayout-Info and labels like Photo, 30 March 2018, 12:14 — match “Photo,” with the comma to skip the Photos-app icon in the privacy banner; cells report not-hittable, so tap via coordinate; first library load on a fresh simulator can exceed 20s
    • Query evaluation around the picker can take ~30s per attempt, so waits are 60s; enumerating the standard fixture’s history alongside the picker’s remote hierarchy made queries time out entirely, hence the empty fixture
    • Record-row thumbnails (CompactImagePreview, HistoryGrid.swift) now carry a recordRowImageThumbnail accessibility identifier for the row assert
    • iPhone sim only: on Catalyst, Add Photo opens the machine’s real Photos library (not isolated, not reliably populated), so the test body is #if !targetEnvironment(macCatalyst)
  • Fix flaky launch wait in DocumentPickerStabilityTests under full-suite load (observed 2026-07-13, fixed 2026-07-14: bumped to 15s)

    • Failed in the first fully unattended Catalyst full-suite run (after DevToolsSecurity -enable + automationmodetool enable-automationmode-without-authentication); passes rerun in isolation (409s wall time) — load-related timing flake, not a regression
    • DocumentPickerStabilityTests.swift:23: openBrowseFilesButton() asserts floatingPlayButton.waitForExistence(timeout: 5) right after launch. The 5s budget covers Catalyst app launch, fixture-to-Automerge conversion, initial data load, and the runner’s first accessibility snapshot — fine on an idle machine, but marginal mid-suite when everything crawls (the failure surfaced at t=26s because even the query evaluation runs slow under load; it still resolved to “not found” within the app’s slow startup). Bump to 15s like other launch waits.
  • Fix flaky PNG/SVG preview waits in ReportFlowTests under full-suite load (observed 2026-07-13, fixed 2026-07-14: both scenarios use tapPreviewAndWaitForDone(timeout: 60))

    • Failed in the same full-suite run as the DocumentPickerStabilityTests flake above; passes rerun in isolation (522s wall time) — load-related timing flake, not a regression
    • ReportFlowTests.swift:90 “PNG preview should open”: PNG (and SVG, line 71) previews wait only 15s for the Done button. PDF hit the same flake on 2026-05-14 and got 60s + a one-shot retry via tapPreviewAndWaitForDone(timeout: 60); extend that helper to the PNG and SVG scenarios (CSV at 5s has never flaked — no WebKit render pass).
  • Fix FilterTests.testCalendarJourney “Current Month” ambiguous query (observed 2026-08-06, both platforms)

    • FilterTests.swift:349: app.buttons["Current Month"] matches the history section header (identifier historySection, label “Current Month”) instead of the calendar’s Current Month preset button; the header is not hittable at that point and the tap fails after scroll-to-visible retries
    • Pre-existing, not a regression: fails identically on commit 78a48d7 (verified via worktree build); date-dependent — last green in July, so the ambiguity or the header’s label/hittability shifts with the current month/date layout
    • Fix: scope the preset query to the calendar container or give the preset buttons their own accessibility identifiers (calendarPresetCurrentMonth etc.), matching how historySection already carries an identifier
  • Make the Mac UI suite pass in the 400x600pt test window (window changed 2026-10-15, done 2026-10-16)

    • The Catalyst test window shrank from 783x971pt to 400x600pt (testWindowSize 520x779 UIKit points in MinutaApp.swift SceneDelegate). RecordEditingTests.isMac checks the platform at compile time instead of window.frame.width > 500
    • App bug found: in a window narrower than 640pt the record editor sheet was laid out at 640pt and cut off at both sides, Save included. RecordEditorSheet now clamps the sheet to the main window’s width; testEditSheetMinWidthOnMac asserts Save and Delete lie inside the sheet (failed before the fix)
    • FilterTests.testCalendarJourney reads the total through readTotal() (scrolls it into view and back); StorageWarningTests scrolls with the new MinutaUITests.scrollMainList(down:) (wheel events on Catalyst, where drags do not scroll a list)
    • Result: Mac 37/37, iPhone 25 passed + 1 skipped (Mac-only)
  • Add App Intents/Shortcuts integration tests

    • StartTimerIntent, StopTimerIntent have no coverage of their own; since 2026-10-15 their logic is WidgetIntentService.start(tagName:comment:) / stopAll(), which WidgetIntentServiceTests covers. perform() itself (result strings) is untested
    • Location: AppIntents.swift
  • Add view tests for multiplatform project

    • Location: Minuta/Tests/ (unit-test target MinutaTests exists since 2026-07-03)
    • ViewInspector for SwiftUI testing
    • ContentView, SettingsSheet tests
  • SVGReportRenderer internal-method coverage via public API (2026-05-14)

    • Added 6 tests exercising truncateText (long tag names in legend, short names not truncated) and wrapText/wrapTextWithNewlines (long comments, explicit newlines, single overlong word, empty comment) through the public SVGReportRenderer.render(_:) entry point. renderTimeSeriesChart already covered by testSVGRendererIncludesTimeSeriesChart.
    • Result: Shared package tests now 450 (was 444).

Audit Findings (2026-01-03) [P1]

Dead Code:

  • Remove unused dayCellSize constant (2026-01-03)

    • Removed from CalendarDatePicker.swift
  • Remove unused UIKit import from ContentView (2026-01-03)

    • Location: ContentView.swift:2
    • No UIKit types used in file
  • Remove unused UIKit import from SettingsSheet (2026-01-03)

    • False positive: UIPasteboard is used for copy-to-clipboard functionality
  • Remove unused DateRangePickerView.swift (2026-01-03)

    • Location: Views/History/DateRangePickerView.swift
    • 114-line file never referenced - CalendarDatePicker is used instead
  • Remove unused Color(oklch:) initializer (2026-01-03)

    • Location: Color+Hex.swift:30-40
    • Never called; only Color(hex:) is used
    • Also removed unused contrastingTextColor(for: OKLCH) overload

Maintainability:

  • Extract layout constants in CalendarDatePicker (2026-01-03)

    • Location: CalendarDatePicker.swift:75-91
    • Created LayoutMetrics enum with named constants for cellSize, fonts, padding, opacity, etc.
  • Refactor duplicated date range logic in CalendarDatePicker (2026-01-03)

    • Added endOfDay() and setDateRange(from:to:) helper methods
    • Reduced duplicate “add day, subtract second” pattern from 5 occurrences to 1
  • Clean up verbose comments in CalendarDatePicker (2026-01-03)

    • Removed redundant section labels and implementation detail comments
    • Kept useful explanatory comments (corner radius logic, week selection criteria)

UI Tests:

  • UI test failures - History section not found, Export sheet not opening, Missing fixture data, Tag management flow (2026-05-14)

    • The “16 failures” list from 2026-04-15 is stale; targeted re-run on 2026-05-14 (iPhone 17 sim, iOS 18) showed all those tests passing.
  • Fix PDF preview flake on standard fixture (2026-05-14)

    • Affected: ReportFlowTests.testPDFReportPreview, ReportFlowTests.testExportSheetStaysDuringGeneration
    • Root cause: Done button wait used timeout: 15, but PDF generation through WKWebView.pdf() over the 138-record standard fixture runs ~16–60s on the iOS 18 simulator with high variance (PNG/SVG/CSV complete in ~5–10s; PDF-with-images on a 2-record fixture is unaffected).
    • Fix: bumped both Done-button waits to timeout: 60 and added a one-shot retry path (tapPreviewAndWaitForDone(timeout:) helper) that dismisses any “Export Error” alert and re-taps previewButton. Verified: when the first 60s wait misses, the retry succeeds (134s total).
  • Mac Catalyst UI tests: pre-existing failures (discovered 2026-05-14; resolved 2026-07-10)

    • Update 2026-07-10: full Catalyst suite is green (24/24 including all PerformanceTests) after the journey consolidation plus two Catalyst-specific fixes: historyDurationRecord() regex-matches labels in Swift instead of an NSPredicate MATCHES query (numeric labels in the Catalyst a11y snapshot made the predicate throw NSInvalidArgumentException), and the settings sheet is closed via its Done button (swipe-down on the main window does not dismiss a Catalyst sheet). Test launches also pin the window to 783x971pt (SceneDelegate sizeRestrictions) so runs no longer inherit the user’s last window size.
    • Update 2026-07-03: fixes for clusters 1 and 2 have landed since the re-run — toolbar bar buttons now carry accessibilityIdentifiers plus identifier-or-label test predicates (b4df387), section headers matched case-insensitively via sectionHeader(), coordinate scrolls window-anchored via dragScroll(), document picker flakes and the TCC prompt fixed (db416f2, a00971f). Needs a fresh full Catalyst run to confirm what remains — likely just cluster 4 (PerformanceTests).
    • Update 2026-07-02: cluster 3 below is resolved — testEditSheetMinWidthOnMac passes since WidthClampedFittedSizing (commit 58c27bf). Remaining failures are clusters 1, 2, 4 plus DocumentPickerStabilityTests (2) and TagSelectorTests.testTappingChipCommitsFullNameToField; testViewExistingRecords fails with a coordinate-scroll NSInternalInconsistencyException (point.x != INFINITY).
    • Failures cluster around:
      1. Catalyst NSToolbar a11y missing — app.buttons["settingsButton"] / ["pinButton"] not found. The SwiftUI toolbar (ContentView.swift:62-73) is wrapped in #if !targetEnvironment(macCatalyst); the Catalyst replacement is SceneDelegate’s NSToolbar (MinutaApp.swift:140+) which sets item.label = "Settings"/“Pin” but never sets an accessibility identifier. Tests must query app.buttons["Settings"]/["Pin"] on Catalyst — or NSToolbarItems should expose the identifiers used by the iOS path.
      2. “Running” header not found after floatingPlayButton.tap() on Mac — affects RecordEditingTests.testAddCommentToTimer, testTapToEditRecord, TimerFlowTests.testStartAndStopTimer, etc. Needs screenshot inspection during failure (Mac may render the running section differently or below the fold).
      3. testEditSheetMinWidthOnMac regressed (611pt vs 640pt min) — resolved 2026-07-02 by WidthClampedFittedSizing (640–800pt clamp with height re-measure, commit 58c27bf); passes in the full-suite run.
      4. 3 PerformanceTests fail at ~24s (testExportSheetPerformance, testReportGenerationPerformance, testTagFilterPerformance) — needs investigation.
    • Note: iPhone-sim tests pass for the same flows because iOS uses the SwiftUI toolbar path which does have identifiers wired up. The Mac CI lane has likely been red since the 7992a0a add mac pin button (wip - not working) commit.

Audit Findings (2026-10-02)

Full-project audit (8 auditors). Test runs that day: Shared 476/476 passed, iPhone UI tests 18 passed + 1 expected Mac-only skip, MinutaTests 36/36 passed, Mac Catalyst UI tests did not build (first entry under “Build, tests, CI”). Each entry carries its own priority tag. “Reproduced” means run against a temp storage folder with the built CLI; “by reading” means traced in code and not executed.

Data integrity:

  • Record edit across a month boundary duplicates the record [P1] (reproduced; fixed 2026-10-02)

    • Fixed: AutomergeStorageService keeps an id-to-months index that survives cache eviction; updateRecord and deleteRecord refresh it under the lock from changed mtimes, write the target month, move the image files, then remove the id from the other months. deleteRecord also handles a stale start time. minuta doctor got a duplicate records check (warning)
    • Existing duplicates are not collapsed on read (no reliable “newer copy” signal): saving one copy removes the others, deleting removes only that month’s copy
    • Tests: 10 unit tests in AutomergeStorageServiceTests / AutomergeStorageConcurrencyTests, CLI testRecordsEditStartAcrossMonths_noDuplicate and testDoctor_reportsRecordInTwoMonths, UI RecordEditingTests.testMoveRecordToPreviousMonth (both platforms; verified failing with the move disabled)
    • Not done, see follow-ups below: fixed-calendar bucketing and the timezone edge
    • Location: AutomergeStorageService.swift:263-287; updateRecord is saveRecord, which writes into the month document of the new startTime and never deletes the key from the old month document
    • Repro: minuta records add --start 2026-04-22..., then records edit <id> --start 2026-03-22...: the same ID is in 03.automerge and 04.automerge, records list shows it twice. Reachable from the editor (RecordEditorViews.swift) and the CLI (RecordsCommand.swift:263,292); a timezone change near a month boundary has the same effect
    • Images are keyed by start month too (imageURL, :331), so attachments stay in the old records/YYYY/MM/ folder
    • Fix: updateRecord looks up the old month (or takes the previous record), deletes from the old document and moves the images under the same lock; consider bucketing by a fixed calendar instead of Calendar.current
    • Tests first: AutomergeStorageServiceTests.testUpdateRecordAcrossMonthMovesRecord (plus year boundary) and a UI test in RecordEditingTests; the legacy store already has testUpdateRecordWithDateChangeMovesFile
    • Also decide how to repair folders that already contain duplicates (minuta doctor check)
  • External-change probe is always true with 13+ month files [P1] (confirmed by a failing test; fixed 2026-10-02)

    • Fixed: eviction no longer drops recordDocumentMtimes; saveRecordDocument takes the document explicitly (it used to skip the write silently when the document had been evicted); a failed save discards the mutated cached document
    • Tests: testHasExternalChanges_falseAfterLoadAllWith13Months, ..._detectsWriteToEvictedMonth, ..._detectsDeletedEvictedMonthFile, testFailedSaveRecord_unsavedRecordNotServedLater, testFailedDeleteRecord_recordStillServedLater
    • Location: AutomergeStorageService.swift:209-217 (eviction also drops recordDocumentMtimes) and :383-389 (hasExternalChanges treats a month file without an mtime entry as new)
    • loadAllRecords() always leaves at least one month evicted once there are more than 12, so the 5s probe fires invalidate + conflict resolution + full reload forever
    • Fix: track observed mtimes separately from the document cache and never evict them; fix the stale comment at :13 (“12 covers ~2 weeks of daily records”, documents are per month)
    • Test: unit test with 13 month files asserting hasExternalChanges() is false after a full load; add the “cached month file deleted” case
  • Conflict resolution writes outside the storage lock [P1] (fixed 2026-10-02)

    • Fixed: each conflict group is merged inside StorageFileLock.withExclusiveLock; the primary is written before merged copies are deleted (the old order lost a merged copy when a later sibling was corrupt); a corrupt copy stays on disk and is reported in errors; the scan takes no lock
    • Tests: testValidCopiesMergedDespiteCorruptSibling, testResolveConflictsWaitsForStorageLock, testNoConflicts_doesNotTakeLock
    • Location: ConflictResolutionService.swift:106-119; mergeConflictFiles reads, merges, deletes conflict copies and rewrites the primary without StorageFileLock.withExclusiveLock
    • Runs on every scene activation and external-change reload (MinutaApp.swift:669,830,866-880) and from minuta conflicts resolve, so it can clobber a concurrent app/CLI write
    • Test: hold the lock on a second fd and assert resolveConflicts blocks, or race it against saveRecord
  • CSV export does not escape tag names [P1] (reproduced; fixed 2026-10-02)

    • Fixed: every field goes through escapeField; columns follow ExportOptions.Column.allCases, so the header is Date,Start Time,End Time,Duration,Tag,Comment (it was alphabetical: Comment,Date,Duration,End Time,Start Time,Tag); duplicate tag ids no longer trap in ExportService and ExportCommand
    • Tests: testExportEscapesCommaAndQuoteInTagName, testExportHeaderExactOrder, testExportSelectedColumnsKeepCanonicalOrder, testExportRecordSpanningMidnight, testExportDuplicateTagIdsDoesNotCrash, CLI testExportCSV_duplicateTagIdsInStorage_doesNotCrash
    • Location: ExportService.swift:88-93; only the comment goes through csvParser.escapeField, a tag named A, B shifts the columns
    • Same pass: pin the header with an exact-order test (code emits Start Time / End Time, CLAUDE.md documents Start / End; the audit also reported the columns come out sorted by raw value), and guard Dictionary(uniqueKeysWithValues:) at :16 against duplicate tag IDs
    • Tests: testExportEscapesCommaAndQuoteInTagName, exact header order, record spanning midnight, running record row
  • Undo delete of a record with images [P1] (reproduced by a UI test; fixed 2026-10-02)

    • Fixed: soft delete removes the record but keeps its image files (TimeTrackingService.deleteRecord(_:keepingImages:), purgeImages(of:)); files are purged on expiry, swipe-dismiss or storage switch; undoDelete is a no-op once the entry is gone and re-inserts it when the re-save fails; a second delete of the same record while one is pending is ignored; operations in flight across a storage switch no longer touch the new folder
    • Also fixed on review: deleteRecord deletes image files only after the document write succeeded; the editor writes the saved value back to currentRecord (a start-date change followed by adding a photo used to revert the date)
    • Tests: UI RecordDeletionTests.testDeleteUndoJourney (both platforms), 10 app-target tests in AppStateSoftDeleteTests, Shared tests in TimeTrackingServiceIntegrationTests
    • The UI test fixture conversion now moves image files along with the date shift (with-images never showed real images before)
    • softDeleteRecord deletes from storage immediately (MinutaApp.swift:1092-1108) and deleteRecord removes the image files (AutomergeStorageService.swift:290); undo re-saves the record but the files are gone
    • Reproduce with a UI test first; then either defer the image deletion until the toast expires or restore the files on undo
  • HybridStorageService.invalidateCache() returns before invalidating [P2] (fixed 2026-10-02: now async, awaits both stores)

    • Location: HybridStorageService.swift:115-120 wraps the work in an unstructured Task; AppState.reloadFromExternalChanges awaits it and reloads straight away (MinutaApp.swift:827-832,872)
    • Fix: make it async and await both inner calls
  • Coalesce AppState.loadData and fix stale history after external edits [P2] (by reading) (done 2026-10-15)

    • Was: History reloaded only on the date range, todayRecords, historyRevision (undo of a delete) and storageWarnings, so a record of an earlier day added, edited or deleted by the CLI or a sync stayed stale until the range changed (reproduced by the UI test below, which failed before)
    • Now: every published load bumps historyRevision; ContentView reloads History through one .task(id:) keyed on the range, revision, today’s records, warnings and whether an editor or the share sheet is open (closing it reloads what was skipped), and a newer reload cancels an older one. Lists are published only by a load not older than the last published one and started after the state’s last local change (noteLocalChange()), otherwise it loads again; a failure is reported only by such a load; isLoading counts loads in flight; running timers come from the same loadAllRecords() pass as the tag usage (one full scan per load instead of two)
    • Tests: UI ExternalChangeTests.testHistoryFollowsExternalChanges (failed before), AppStateLoadTests.testLoad_bumpsHistoryRevision (failed before), AppStateLoadTests.testLoadOvertakenByStartAndStop_keepsTheChange (a guard: the stale publish needs a load’s reads to straddle a write in storage and did not reproduce on the old code)
    • Left: a seam to inject storage into AppState, to reproduce the stale publish deterministically
    • loadData (MinutaApp.swift:882-917) is entered from at least six places with no coalescing or generation token: a slow older load can overwrite optimistic state or write the previous folder’s data after reinitializeStorage; isLoading flips false while another load runs; each load scans the full history twice (tagLastUsedAt + getRunningTimers)
    • ContentView.swift:106-113 reloads history only on date range or todayRecords change, so an external edit to an older record or a tag merge leaves history stale; the two date onChange handlers spawn unordered Tasks
    • Fix: load-generation counter or single in-flight task; dataRevision bumped in loadData driving one .task(id:) keyed on (start, end, revision)
  • mtime cache trusts an unchanged mtime on write paths [P2] (by reading) (done 2026-10-15)

    • Was: saveRecord / updateRecord / deleteRecord reused the cached month document when the stamp (mtime and size) was equal and wrote it over the file; on exFAT, SMB or sync folders with 1s granularity an external write in the same tick with the same size was lost. The backlog’s test idea (restore the mtime) alone did not reproduce it: the size changed too
    • Now: every record write and the consolidation in refreshIndexLocked read the month from disk under the lock (workingDocument(fresh: true)). Tests: AutomergeStorageConcurrencyTests.test{Save,Update,Delete}AfterSameStampExternalWrite_keepsTheExternalRecord (a same-size file with the same mtime; all three failed before)
    • Left: the id-to-months index is still refreshed by stamp, so a record another process moved to another month within the same tick and size is not cleaned from the old month (a duplicate, reported by minuta doctor)
  • Widget publish path is not isolated in test mode or on Catalyst [P2] (by reading) (done 2026-10-15)

    • Was: AppState.syncWidgetData ran under --test-storage= and on Catalyst and reached the real App Group container and suite (WidgetDataService.swift, RecentTagsStore.swift); app unit tests wrote their tags into the real recent-tags list
    • Now: WidgetDataService.forApp(testLaunch:) publishes nothing for a test launch and on Catalyst, RecentTagsStore.forApp(testLaunch:) uses its own suite for a test launch; used by AppState and WidgetIntentService.live(). Tests: WidgetIntentServiceTests.testTestLaunch_publishesNothingAndKeepsRecentTagsOutOfTheAppGroup, AppStateEditorSaveTests.testTestLaunch_doesNotTouchTheAppGroupRecentTags (failed before)

Build, tests, CI:

  • Mac Catalyst UI tests do not build: MinutaWidgets signing [P1] (fixed 2026-10-02)

    • Fixed: removed MinutaWidgets: all from the Minuta scheme’s build targets; the widget still builds on iOS through the app’s platformFilter: iOS dependency (the simulator build still embeds the .appex), and Catalyst builds of the scheme no longer build or sign it. Also fixes scripts/run-mac.sh; the Release MinutaArchive Catalyst build used by release-brew.sh was verified to build without the widget
    • NSFontPanelAttributes cleared; full Mac suite 32/32 on 2026-10-02
    • ./scripts/run-uitests.sh --mac stops before any test: No profiles for 'tools.minuta.app.dev.widgets' were found ... (in target 'MinutaWidgets')
    • Cause: the Minuta scheme builds MinutaWidgets (project.yml:199); under the Catalyst destination the iOS-only target resolves to the iphoneos SDK with the Debug bundle ID override tools.minuta.app.dev.widgets (project.yml:107), for which no development profile exists (only the holder’s four AppStore/MacAppStore profiles are installed)
    • Not run since: MiniTimerTests, DocumentPickerStabilityTests, UpdatePromptTests, SettingsTests.testPinButtonToggle, testEditSheetMinWidthOnMac, all Mac screenshots
    • Fix options: keep the widget out of Catalyst test builds (separate test scheme without MinutaWidgets, or a destination filter on the dependency), same root cause as the scripts/run-mac.sh item in “Streamline the build, test and deploy process”; do not paper over it with -allowProvisioningUpdates
    • After the build is fixed: defaults read tools.minuta.app NSFontPanelAttributes is "1, 8", so the phantom Fonts panel is primed; clear it before the first full Mac run
  • Run Catalyst in CI [P1] (workflow updated 2026-10-02; verified by running the same commands locally, not yet by a CI run)

    • .github/workflows/build.yml got three Catalyst steps (build, MinutaTests, MinutaUITests) with ad-hoc signing: CODE_SIGN_IDENTITY=- (an unsigned Catalyst runner is blocked by Gatekeeper) and ENABLE_HARDENED_RUNTIME=NO (library validation rejects an ad-hoc signed test bundle). TrayIconManagerTests now runs (48 app unit tests on Catalyst)
    • Open questions are in the follow-ups below
    • .github/workflows/build.yml covers iPhone only, which is how the break above went unnoticed; add the Catalyst UI suite (or at least a Catalyst build) and MinutaTests on Catalyst, since TrayIconManagerTests is #if targetEnvironment(macCatalyst) and currently runs nowhere
    • Related: “Fix GitHub Actions” under Infrastructure
  • Harden Minuta/scripts/run-uitests.sh [P2] (done 2026-10-19)

    • set -e only and the xcodebuild pipeline ends in || true; pass/fail is decided by grepping /tmp/uitest-output.log, shared by iPhone and Mac runs
    • Seen 2026-10-19: run-uitests.sh StorageWarningTests ... appended a second Tests, matched nothing, ran 0 tests and printed “All tests passed!”
    • Done: set -euo pipefail; exit status is xcodebuild’s (PIPESTATUS), and 1 when the result bundle counts no test; the class name takes Tests or not; log per platform /tmp/uitest-output-{iphone,mac}.log (UITEST_LOG, which test-gate.sh points into the release logs); simulator from MINUTA_SIMULATOR; build.yml names it once (IOS_DESTINATION); usage examples name real methods
    • Same day: StorageWarningTests.testUnreadableMonthJourney failed on CI after release 1.2.0. The on-screen keyboard of the new timer’s tag field covered the lower half of the screen and the scroll drags typed on it; whether it shows depends on the simulator’s hardware keyboard state. scrollMainList(down:) now closes it first (MinutaUITests.dismissKeyboard())
    • Use set -euo pipefail, capture ${PIPESTATUS[0]}, per-platform log, simulator name from one env-overridable variable (also hardcoded three times in build.yml); the usage example at :7 names a testStartTimer method that does not exist
  • CLISmokeTests moves the real storage-path.txt aside [P2]

    • Location: Shared/Tests/MinutaCLITests/CLIPlaceholderTests.swift:33-54; a crash mid-test loses the pointer and a running app/CLI sees it missing
    • Make CLIConfigService.configDirectory() injectable; this also unlocks the StorageResolver pointer tests below
  • PerformanceTests cannot fail on performance [P2]

    • PerformanceTests.swift documents targets (:35 under 1.0s, :102, :164, :213) but asserts none; the 2026-10-02 run measured launch 1.96s and export sheet 5.7s and passed
    • testTagFilterPerformance (:103-160) printed “No tag filter buttons found” and passed, so it measures nothing
    • Its 13 snapshot() screenshots are published to the website; decide whether they belong there
  • FilterTests.tapPreset hides a missed reset [P2]

    • FilterTests.swift:71-91, 318-328 (introduced in 7957e14): returns false when the preset is not hittable and the “Current Month” resets discard the result; 03-today-selected / 04-current-week-selected snapshots have never existed in git
    • Fix together with “Fix FilterTests.testCalendarJourney Current Month ambiguous query” above
  • Tests that pin CSVParser bugs as expected behaviour [P2] (done 2026-10-17)

    • CSVParserTests.swift:98-105 (escaped "" quotes stripped: export then import loses quotes) and :166-170 (a CRLF comment is not quoted, producing a broken row)
    • Fixed with the RFC 4180 parser: "" is one literal quote, quoted content is kept verbatim (only unquoted fields and whitespace outside the quotes are trimmed), a leading UTF-8 BOM is dropped; escapeField checks scalars (CRLF is quoted) and quotes leading/trailing spaces. The pinning tests now assert the correct behaviour, plus BOM and round-trip tests
  • Replace placeholder tests with real ones [P2]

    • Minuta/Tests/MinutaTests.swift:5-8 is XCTAssertTrue(true); MinutaSharedTests.swift:6 asserts MinutaShared.version equals itself (the constant is a stale "1.0.0")
    • Give them real assertions (see the AppState coverage entry below); do not delete
  • Coverage: storage, locking, conflicts [P2]

    • True cross-process lock test: spawn N parallel minuta records add / tags add on one folder, assert nothing is lost; openFailed on a read-only folder (StorageFileLock.swift:42-58)
    • Conflict tests on real AutomergeTimeRecord / AutomergeTag documents (edited on A + deleted on B; endTime set on both), corrupted primary, group with only conflict copies, false positives from the iCloud pattern \s+\d+$ (ConflictResolutionService.swift:10-15, 94-104)
    • ExternalChangeMonitor.swift:50-80 (cadence switching, in-flight coalescing, checkNow); UI test writing a record into the --test-storage folder while the app runs
  • Coverage: CLI [P2]

    • StorageResolver.swift:35-45: pointer to an accessible folder, pointer to an inaccessible path exits 2, empty flag/env falls through, ~ expansion, env end to end
    • No integration test for: records image export, stop --id (invalid UUID exits 3, not running exits 1), records show by 8-char prefix and ambiguous prefix, records edit --start/--clear-tag/--clear-comment/--clear-end, records list filters other than --today, tags list --archived, tags add --color, tags rename onto an existing name, tags recolor with bad hex, export csv -o, export report --tags/--title, status --json, fish completion
    • DoctorCommand.swift:29-165 failure paths: unwritable folder, corrupt tags.automerge, orphan images, conflict files, unreadable marker, held lock
    • CLIIntegrationTests.swift:165-169 redeclares CLIExit privately and can drift from the real values
  • Coverage: dates, DST, import [P2]

    • No DST or non-UTC tests anywhere: WidgetTimelineBuilder.swift:54-68, WidgetData.swift:73-79, AutomergeStorageService.swift:410-423, RecordFilters.swift:51-107 (zero unit tests; needs an injected calendar)
    • HEY import (ImportService.swift:29-83, CSVParser.swift:9-27): end before start (the CLI aborts mid-import at ImportCommand.swift:94 after writing some records), UTF-8 BOM, quoted field with a newline, DST-gap times, --resume after a tag rename, --json
  • Coverage: app target [P2]

    • AppState (MinutaApp.swift:692-1218): renameTag into mergeTag, softDeleteRecord / undoDelete, resolveTagId, stopAllTimers, reloadFromExternalChanges
    • Widget extension has no tests: WidgetDataReader.read fallback, MinutaEntry.isStale, timeline provider (MinutaWidgets.swift:8-99); WidgetDataService.swift:93-120 nil URL / write failure, :72 record started yesterday and finished today not counted; WidgetIntents.swift:56-106 invalid UUID and nil host
    • UpdateCheckService: fetcher non-200 and bad JSON (:25-37), isVersion with suffixes (1.0.5-beta parses to 1.0.0, :213-222), setEnabled toggling bypasses the 24h throttle (:160-167)
    • Pure UI logic: CalendarSectionHeader.swift:17-150, HistoryVisibility.swift:11-48, Color+Hex.swift:12-58
    • Related: “Add App Intents/Shortcuts integration tests” above
  • Tag hues exceed 360 for Indic and Thai alphabets [P2] (by reading)

    • TagColorGenerator.swift:138-185 vs the sizes at :33-39: code-point ranges are wider than the declared size (Devanagari, Bengali, Telugu, Kannada, Malayalam, Tamil: 53 code points vs 46/43/41/41/41/35; Thai 46 vs 44), so late letters wrap and collide
    • Test: parameterised over every alphabet’s first and last letter, 0 <= hue < 360 and monotonic order; Bengali, Tamil, Telugu, Kannada, Malayalam have no tests today
    • Related: “Debug OKLCH colors” under Bugs
  • UI test hygiene [P3]

    • 94 Thread.sleep calls across UI flows (FilterTests 22, PerformanceTests 17, RecordEditingTests 13): replace with waits
    • Leftover print("=== A11Y DUMP ===") block in SettingsTests.swift:21-25; fix-history comments in RecordEditingTests.swift:63,117-118
    • Rename CLIPlaceholderTests.swift (contains CLISmokeTests), Phase2IntegrationTests.swift / Phase3IntegrationTests.swift (named after plan phases); TagManagementTests.swift holds a second class TagSelectorTests

Release and scripts:

  • Site would advertise 1.0.4 while only 1.0.3 artifacts exist [P1] (resolved 2026-10-03 by releasing 1.0.5 (7) / CLI 0.1.3 everywhere: notarized Homebrew artifacts, tap ed3711d, site deployed with version.json 1.0.5, TestFlight iOS and Mac App Store uploads; the release orchestrator now enforces artifacts before the site)

    • 7ee2710 bumped all three targets to 1.0.4 (6) for the Mac App Store upload; minuta.tools/src/lib/appVersions.js:15 feeds the first MARKETING_VERSION into /downloads/version.json and the download link, dist/ and the server hold Minuta-1.0.3.zip
    • Until fixed, do not run deploy.sh from HEAD: Homebrew users would see “Update available” pointing at a missing file
    • Fix: either ship 1.0.4 through release-brew.sh (artifacts, tap, then site), or give the site its own “latest Homebrew release” source instead of project.yml
  • release-brew.sh fallback path and pipefail traps [P2] (fixed 2026-10-03 with the release orchestrator: empty-array-safe XCARGS, captured security output, no | head inside substitutions)

    • :134-140: under set -u, "${XCARGS[@]}" on an empty array aborts on macOS bash 3.2 (unbound variable), so the documented path without DeveloperID.xcconfig or with MINUTA_NOTARIZE=0 dies before building
    • :50-57 (also :33-36): SIGN_ID="$(security ... | grep ... | head -1 | sed ...)" exits 1 when nothing matches, set -e kills the script and the friendly error at :53-57 is unreachable; the script already documents this trap at :147-148
  • Script safety and duplication [P2] (deploy.sh part done 2026-10-03: set -euo pipefail and cd to its own directory)

    • scripts/generate-perf-fixtures.sh:9,14 and scripts/generate-standard-fixture.sh:16,20: rm -rf "$OUTPUT_DIR" on a path relative to the caller’s cwd; resolve the repo root from $0 and use set -euo pipefail
    • minuta.tools/deploy.sh has no cd "$(dirname "$0")" before rsync --delete build/; the remote host is duplicated in scripts/upload-brew-artifacts.sh:11
    • scripts/run-ipad.sh is byte-identical to run-iphone.sh; MARKETING_VERSION is repeated three times in project.yml while release-brew.sh:35 reads head -1
    • generate-standard-fixture.sh (260 lines) is referenced nowhere: document it or drop it
  • Personal data and large files in the repo and on the site [P2]

    • The holder’s full name is in the tracked Minuta/ExportOptions-mac.plist:22 (required there) and repeated in docs that are served publicly (702-testflight:246, 919-plan-mac-distribution:159, this backlog); the next deploy publishes it on 702. Refer to the plist instead of repeating the identity string
    • ImageAttachmentTests/03-photo-picker.png (1.8 MB) is stored twice (snapshots + site screenshots); it is a system photo-picker shot, the same class as the Finder panels banned on Mac
    • The four DocumentPickerStabilityTests Finder-panel screenshots removed in d43bac2 are still in history; matters only if the repo goes public
  • Build config leftovers and stale config comments [P3]

    • Minuta/TestFlight/MinutaWidgets.macOS.entitlements is unused: TestFlight-mac.xcconfig maps the widget to TestFlight/MinutaWidgets.entitlements
    • project.yml:205-207 and CLAUDE.md describe MinutaArchive as “without MinutaTray”, but release-brew.sh:132-133,156 relies on it building and signing Tray as an implicit dependency
    • TrayIconManager.swift:59-60 says the bundle is embedded under Resources (project.yml uses copy: plugins); project.yml says Catalyst avoids the group.* app group while Minuta.macOS.entitlements includes it
    • .gitignore:58-61, 69-70 dead report-generator entries; --keep-animations debug flag (MinutaApp.swift:217-221) is undocumented
    • Keep version bumps in their own commits (1.0.3 and 1.0.4 bumps rode in feature commits)

CLI:

  • -q/--quiet and -v/--verbose are advertised but not implemented [P2] (done 2026-10-15)

    • Was: declared in Minuta.swift and the help; read only by doctor and the unreadable-file warning
    • Now: informational stderr goes through CLIGlobalOptions.info (silenced by -q; error: lines stay), -v prints the storage folder, its source and the .minuta-version state through CLIGlobalOptions.debug. Tests: MarkerAndOutputIntegrationTests (-q on tags add, records add, export csv; errors kept; -v output)
  • Exit code 5 can never be returned [P2] (reproduced) (done 2026-10-15)

    • Was: requireVersionMarkerForWrites always wrote the marker (garbage and newer versions such as 2 overwritten with 1); strictRequireVersionMarker, the only thrower of code 5, had no callers
    • Decided: absent marker is created (new folder), older upgraded; newer version or not a number refuses the write with exit code 5, marker and data untouched; reads work. StorageVersionMarker.writeCurrent (app too) never downgrades or overwrites such a marker; doctor FAILs on it. CLIExit.versionMarkerAbsent renamed versionMarkerIncompatible, strictRequireVersionMarker removed. Tests: MarkerAndOutputIntegrationTests (fresh folder, newer, garbage, reads, doctor; failed before), 3 in StorageSafetyTests
    • Left: the app itself does not refuse to write into a folder marked with a newer version
  • stop handles IDs differently from every other command [P2]

    • StopCommand.swift:13,30 takes --id as an option and requires a full UUID; records show/edit/delete/image take a positional ID and accept an 8-char prefix (RecordsCommand.swift:352-376); status prints only 8-char prefixes, so its output cannot be fed to stop --id
    • --all (:16-17) is declared and never read
  • CLI help text contradicts behaviour [P2]

    • RecordsCommand.swift:231 “Pass an empty string to reopen the timer” vs :268-272 rejecting it (only --clear-end works)
    • RecordFilters.swift:9 mentions a --tz flag that does not exist
    • ExportCommand.swift:90-93 says --from / --to default to the current month, :193-198 requires both if either is given
  • Unify range filters across CLI commands [P2]

    • export report --prev-month (ExportCommand.swift:96) vs --last-month (RecordFilters.swift:43); export report re-declares --from/--to/--tags/--untagged instead of using the shared RecordFilters group, so it lacks --today, --this-week and the rest
  • Regularize --json output shapes [P2]

    • Tag mutations return {"tags":[one]}, record mutations {"record":{...}}; import hey emits "records": <count> where elsewhere records is an array; delete payloads differ between records and tags
    • The JSON writer is copy-pasted seven times while RecordOutput.writePayload is private; document the shapes in 311-cli
  • Align app and CLI behaviour for the same operations [P2]

    • Rename collision: the app merges into the existing tag (MinutaApp.swift:1037-1039), the CLI rejects it (TagsCommand.swift:198-202)
    • Colour validation: tags recolor validates hex, tags add --color stores anything (TagsCommand.swift:155)
    • HEY import: the app creates records through updateRecord inside the view (SettingsSheet.swift:298-334), the CLI uses createManualRecord with --resume dedupe (ImportCommand.swift:81-104); move “apply parsed import” into ImportService and call it from both
    • TimeTrackingService: createTag validates but does not dedupe, getOrCreateTag dedupes but does not validate or trim and throws TagError.emptyName for “not found after create” (:41-67); use [Tag].findByName instead of the eight inline case-insensitive compares
  • CLI plain-mode output conventions [P3]

    • stdout carries started <uuid>, a bare uuid, or stopped <uuid> (dur) depending on the command; records edit and tag rename print nothing; “no running timers” goes to stderr in stop and stdout in status; StartCommand is the only command without the execute() split
    • Stale comments: CLIExit.swift:4 points at docs/300-services/304-cli/ (it is 311-cli); StorageResolver.swift:50-53 says it normalises via getpwuid but uses expandingTildeInPath

Code quality:

  • Remove leftover image-debug trace logging [P2]

    • About 60 info-level ENTER/EXIT step logs: RecordEditorViews.swift:467-513, MinutaApp.swift:1138-1158, FileStorageService.swift:402-525 (the read-only legacy store), RecordImageViews.swift:302-332, TimeTrackingService.swift:170-191
    • Collapse to one line per operation at .debug; unify message prefixes while there
  • Collapse HybridStorageService and retire the migration scaffolding [P2]

    • checkAndMigrate() only sets migrationComplete = true; all six call sites call it first, so the legacy merge branches (HybridStorageService.swift:27-75) are unreachable; migration (:7,15) is never read; AppState.migrationChecked (MinutaApp.swift:698,777,885-891) can never fire
    • Legacy calls still made: legacy.deleteRecord (:87) logs a spurious “file not found” on every delete; legacy.loadImage / deleteImage resolve to the same path as Automerge; legacy.invalidateCache clears an empty cache
    • MigrationService.swift (140 lines) is reachable only from MigrationServiceTests; removing it takes those tests with it, which needs an explicit decision. Keep the LocalFileStorageService reader: MinutaUITests.swift:214 converts JSON fixtures with it
    • Update CLAUDE.md “Architecture” and “Data Storage” afterwards
  • Route Shortcuts intents through WidgetIntentService [P2] (done 2026-10-15)

    • Was: AppIntents.swift re-implemented start / stop-all inline, never called RecentTagsStore.track, never published the widget snapshot (relied on a live AppState hearing .minutaDataChanged), and silently started untagged for an unknown tag name while the CLI and UI create the tag. A stop by a Shortcut or a widget button did not reach AppState before its reload, so an editor’s pending auto-save reopened the timer (reproduced in AppStateEditorSaveTests)
    • Now: both intents call WidgetIntentService (start(tagName:comment:) creates an unknown tag, stopAll()); stops post .minutaTimersStopping / .minutaTimersStopped, and AppState drops those timers from runningTimers until a load that started after the stop. Tests: 3 in WidgetIntentServiceTests, 3 in AppStateEditorSaveTests
    • Left: AppState.startTimer / stopAllTimers is still a separate copy of start and stop (it updates its own lists without a reload)
  • Split MinutaApp.swift [P2]

    • 1218 lines holding MinutaApp, TimerCommands, AppDelegate, a 350-line SceneDelegate, WindowPinManager, NSWindowBridge, AppContainer, RootView, PendingDeletion and a 530-line AppState; split into AppState.swift, SceneDelegate.swift, WindowPinManager+NSWindowBridge.swift with no behaviour change
    • Extends “Split AppState into smaller services” below
  • Centralize storage layout, test-mode detection and AppKit bridging [P2]

    • StorageLayout in MinutaShared: "tags.automerge", "records", the "%04d-%02d" key and the year/month walk are repeated in AutomergeStorageService, DoctorCommand, ConflictResolutionService, AutomergeExplorerView, FileStorageService; the default Documents/Minuta path is built in four places
    • LaunchEnvironment.isUITest: "--test-storage=" is re-parsed in six files with different #if DEBUG gating (MinutaApp.swift:170,185,203,257, MiniWindowManager.swift:80, TrayIconManager.swift:21, UpdateCheckService.swift:101, StorageLocationManager.swift:88-89); WindowPinManager still writes the real windowPinned key in test launches
    • NSWindowBridge: two copies of the AppKit-via-KVC code (MiniWindowManager.swift:112-160 vs MinutaApp.swift:535-582), raw style-mask numbers and button types
  • Widget views: dedupe and consistent stale hint [P2]

    • MinutaWidgets.swift:162-450, 638-662: small and medium views duplicate the running/idle column, the Start/Stop capsule styling is copy-pasted about seven times, Color(hex:) duplicates Views/Color+Hex.swift:12
    • isStale is honoured only in the small widget (:211,285) while 205-widget-data:78 says small and medium; decide and align
    • WidgetData.swift:12 hardcodes “up to 4” next to maxRecentTags
  • RecordEditor debounce and structure [P2]

    • RecordEditorViews.swift:12-397: one ~390-line view with three display modes and 12 @State properties; the 300ms debounce is a literal (:415) and nothing visibly cancels or flushes saveTask on dismiss (verify)
    • Extends “Split RecordEditorViews” below
  • Pass environment to sheets explicitly [P3]

    • Project rule; only ContentView.swift:78-86 follows it. Missing at SettingsSheet.swift:247 (ExportView), :260 (AddTagSheet), TodaySectionView.swift:38 and HistoryGrid.swift:229 (RecordEditorSheet)
  • Remove dead code [P2]

    • Shared/Tests/MinutaSharedTests/Mocks/MockTimeTrackingService.swift (171 lines, never used)
    • OKLCH.swift:13-51 (BrandHue, BrandVariation, brandColor, purpleVivid, purpleDeep, orangeVivid, blueVivid)
    • AutomergeUtilities.swift:150-154 unused AutomergeError cases; HistoryGrid.swift:20-24 GridDebugInfo; WidgetData.swift:66-69 runningTimersCount; CalendarDatePicker.swift:87 presetButtonWidth; AutomergeExplorerView.swift:126 modifiedDate
    • StorageLocationManager.swift:6 duplicate appGroupIdentifier; DateFormatters.swift:52-53 dayOfMonth alias; MigrationService.swift:125 cleanupJSONFiles
    • Unused imports (heuristic): OSLog in HistoryGrid.swift:3, PhotosUI in RecordEditorViews.swift:2; .gitkeep in Shared/Sources/MinutaShared/Utilities/ and Minuta/Sources/Resources/
    • Needs a decision: grid geometry preferences with no reader (GridDebugPreferenceKey, GridCellDebugInfo, HistoryGridFramePreferenceKey, HistoryGrid.swift:14-45; kept deliberately per the HistoryGrid entry, but nothing calls onPreferenceChange); single-conformer protocols never used as a type (ExportServiceProtocol, ImportServiceProtocol, StorageLocationManagerProtocol, TimeTrackingServiceProtocol); accessibility identifiers no test queries (cancelTimerButton, miniTimerWindow, saveFileButton, useDefaultLocationButton)
  • Comment and naming cleanup [P3]

    • WebKitReportService.swift: the same narrating comments over an identical 8-line block three times (:25-37, 50-65, 78-90), :202 “simple polling approach” above delegate-continuation code
    • Misleading “legacy” labels on live code: ReportData.swift:3,89, ReportService.swift:78 (chartData is read by SVGReportRenderer.swift:154)
    • Roughly 100 narrating “what” comments in sources (e.g. MinutaApp.swift:598,779,1069, ReportService.swift:96,119,122, MinutaWidgets.swift:518,529, WidgetDataService.swift:59,75) and numbered step narration in tests
    • Naming drift: StopTimerIntent stops all timers; WidgetIntentService.start/stop/stopAll vs the protocol’s startTimer/...; notification "miniWindowStateChanged" vs "Minuta..."-prefixed siblings; minutaTrayButton vs pinButton / pipButton; let helper = RangeDescriptionHelper(...); storageURL isolation differs between storage actors
  • Site template leftovers [P3]

    • Check ~/Projects/sveleton first, removal diverges from the template: minuta.tools/200.html placeholder, scripts/{pug-to-html,remove-lang-ts,fix-title-shorthand,fix-allowfullscreen}.js, nested minuta.tools/.github/workflows/ci.yml (never runs), dev dependencies flowbite, flowbite-svelte, @zerodevx/svelte-img, fast-glob, @sveltejs/adapter-node
    • Unused component graph (~1,470 lines) rooted at src/lib/publication/Publication.svelte, plus AnchorHeading, CallToAction-black, SharePostLink, LinkBlock, Nav/navSection.svelte.js; readNextPosts in +layout.server.js:10 is computed for every page and read only by the unused ReadNext.svelte
    • src/lib/shaders/ + the three dependency (288 lines, zero importers)
    • minuta.tools/.claude/settings.local.json is tracked; minuta.tools/README.md:9-10 says Node 21 / pnpm 9 (it is 24 / 10)

Documentation:

  • Fix the record storage layout everywhere [P1] (fixed 2026-10-02 in all listed files)

    • Docs say records/YYYY/MM/YYYY-MM-DD_HHmmss.automerge, one file per record; the code stores one document per month, records/YYYY/MM.automerge, and the MM/ directory holds only images (AutomergeStorageService.swift:33-37)
    • Wrong in: CLAUDE.md:169,191, README.md:3,66-67, PROJECT_SPEC.md:7, 505-automerge-storage:17,56-63, 202-time-record:24,40,43, 101-overview:55-62, 301-file-storage:45,47; 312-automerge-storage-service and 506-cross-process-safety are correct
  • Fix the TestFlight guides [P1] (fixed 2026-10-02)

    • Left open: a “What to Test” text for builds after 1.0.2 (4); 919:99 still shows the short installer cert name in the template; checklist item 2 (delete bundle IDs from the personal team) contradicts section 1 in both guides
    • TESTFLIGHT-ORG.md:77 and 702-testflight:84 show subpath: "$(CONTENTS_FOLDER_PATH)/PlugIns", which breaks codesign; project.yml:44-49 has destination: plugins with no subpath
    • TESTFLIGHT-ORG.md:252 still says widgets are disabled; newest “What to Test” text is for 1.0.2 (4)
    • Mac widget entitlements described as MinutaWidgets.macOS.entitlements (702 :228, ORG :262) while the snippets and TestFlight-mac.xcconfig:16 use MinutaWidgets.entitlements; 919:99 quotes the short installer cert name
    • Dangling references to deleted TESTFLIGHT-HOLDER.md sections: TESTFLIGHT-ORG.md:265,283,305, 702 :231,249,271, 919:24, this backlog
    • 702 :280 and ORG :314 name ExportOptions.plist (it is ExportOptions-ios.plist)
  • Fix the CLI reference [P1] (fixed 2026-10-02)

    • Left for the CLI entries above, since the code is what is wrong: 311-cli still describes -v/-q as working and exit code 5 as returned; --prev-month vs --last-month
    • 311-cli:105,217 documents --tag for records list, export csv, export report; the flag is --tags (RecordFilters.swift:19-20, ExportCommand.swift:99-100)
    • 311-cli:314 lists source strings that differ from StorageResolver.swift:8-11 in three of four values; 311:240 calls --with-images with PNG “irrelevant”, it is a hard exit-3 error
    • Sandbox caveat missing from 311-cli:26,47, 305-storage-location, 506-cross-process-safety:129-137: a sandboxed Mac build never writes storage-path.txt
    • Undocumented: stop --all, full-UUID requirement of --id, default subcommands, accepted date formats, --json shapes beyond records list / tags list
  • Bring CLAUDE.md in line with the code [P2]

    • AppState is @Observable, not an ObservableObject (:218)
    • “Auto-refresh data every 60 seconds” (:371) no longer exists; only ExternalChangeMonitor
    • Migration: :173-175 and :211-213 say auto-migrated / orchestrates, :177 says complete and disabled
    • “No network requests - app is fully offline” (:193) vs the carve-out at :373
    • CSV export header (:342); iOS Simulator run command (:112) installs from a path the build never produces
    • UI Structure: “floating editor overlay” (:272) vs sheet (:287); “collapsible DisclosureGroup” (:269), no DisclosureGroup in Sources
    • src/routes/assets/ is not stale (:163): app-icon.png is tracked, written by generate-app-icons.sh:20 and imported by +page.svelte:13
    • Fixtures list (:47, five exist); structure tree omits Sources/Services/, MinutaCLI, Automerge/, scripts/; CI section omits the MinutaTests step; “Tag creation happens on timer start or record save” omits AddTagSheet, HEY import, tags add
  • Privacy policy claims no network requests of any kind [P2] (done 2026-10-15)

    • Was: minuta.tools/src/routes/privacy/+page.svelte (dated April 14, 2025) and the landing page (“no network requests”) predated the opt-in update check (UpdateCheckService.swift)
    • Now: the policy has a “Network requests” section describing the single opt-in request (Mac builds from the site and Homebrew, off until enabled, at most daily, version.json, nothing but a standard request; iOS, Mac App Store, TestFlight and the CLI make none), dated October 15, 2026; the landing page says the only request is an update check you can turn on
  • Retire or rewrite PROJECT_SPEC.md [P2]

    • :22-237 describe the pre-implementation design (TimeTracker/ target, tags.json, per-record JSON, CloudKit sync, SyncService, tab bar, Touch Bar, Live Activities); README.md:59 links it as the product spec
  • Rewrite stale UI and service pages [P2]

    • 403-app-state:10,14-25,70-77 (ObservableObject, @Published, runningRecords, 60s refresh); same error in the docs index :135, 101-overview:16, 402-ios:28
    • 404-storage-location and 305-storage-location list selectStorageLocation(), resetToDefault(), a “Reset to Default” button and LocalFileStorageService(baseURL:), none of which exist
    • 412-deletion-toast:23,39-40 says deletion is deferred and mentions swipe to delete; deletion is immediate and undo re-saves
    • 408-date-range-picker:12-38 (six presets, range arrows), 407-history-section:66-68,93 (inline editor, context menu; grid borders not mentioned), 413-mac-titlebar-design:12-24 (says no native toolbar), 405-settings-sheet:16-42 (omits Updates, Menu bar, Debug, Reset Colors rows)
    • 802-scroll-profiling and 803-load-more-bug:278-335 present removed load-more code as the final state; 102-mvvm-analysis diagram still shows LocalFileStorageService and JSON files
    • 315-update-check:24 and the project.yml comment above line 64 say MANUAL_DISTRIBUTION is on in every configuration; both TestFlight xcconfigs blank it
  • Rewrite the testing and CI docs [P2]

    • 603-ui-testing-implementation:84-90 uses scheme TimeTracker and method testStartAndStopTimer (real: Minuta, testTimerJourney); :149-159 advises matching by label, tests do the opposite; 602-ui-automation-plan still argues for swift-snapshot-testing and has 17 TimeTracker hits (69 across 10 pages)
    • 601-testing-guide: 253 unit / 17 UI tests (now about 476 and 31), test tree lists 8 of about 25 files; no page lists the 15 UI test classes, the bare-record / with-images / performance fixtures, the fixture JSON-to-Automerge conversion or MinutaTests
    • 703-ci:14-19 describes two jobs, “398 tests”, “UI tests are not yet part of CI”; build.yml is one job with four steps
    • The “Current: 458 unit tests” line under Test Coverage in this backlog
  • Document undocumented features [P3]

    • Services with no 300-services page: WidgetIntentService, WidgetDataService, RecentTagsStore, HybridStorageService, StorageFileLock, StorageVersionMarker, CLIConfigService, StorageLockError
    • UI: Reset Colors and Automerge Explorer rows, the current calendar picker (pill, week toggles, drag-select), tag filter collapse and drag multi-select, tag selector keyboard navigation, pull-to-refresh, inline image thumbnails, toast swipe-down dismiss, Cmd+N and Cmd+. shortcuts
  • Docs hygiene [P3]

    • About 20 of 81 pages have a garbage auto-derived description (table rows, Date: ..., code, wiki-link residue), which feeds <meta description> and the OG image
    • Point-in-time AI review reports published as docs (913-repository-review, 912-audit-2025-12-25, 102-mvvm-analysis, 912-execution-plan): archive or mark as historical; log pages 901-904 and 912-audit are dated 2024, the first commit is 2025-12-09
    • Duplicate numbering: 100-architecture / 500-architecture, 500-architecture / 500-reports, two 912-*, two 913-*; 401-macos is a 16-line stub the index links as “400-ui”; boilerplate section descriptions
    • Index omissions: 300-services (312-315), 200-models (205), 700-integrations (703), root index (315, 415, 915-919)
    • Broken link 505-automerge-storage:67 (should be /docs/500-architecture/archive/505-automerge-migration-plan/); 801-optimization-plan:22 names generate-perf-fixtures.swift (it is .sh)
    • Stale entries in this backlog: “Automerge for conflict-free sync” and “Add drag-to-select for tag filters” are shipped; the Mac distribution entry still lists “Blocked on the holder”; 914-plan-cli-tool has no “shipped” status and points to 304-cli; 701-deel has no “not implemented” status

Follow-ups from the P1 fixes (found 2026-10-02):

  • Restored record lands in Today regardless of its date [P2] (done 2026-10-13)

    • undoDelete (MinutaApp.swift) inserts the restored record into todayRecords unconditionally (it also counts toward Today’s total), and nothing re-adds it to HistorySection’s local list until the next reload; visible in the RecordDeletionTests 04-restored snapshot
    • Now: the record returns to todayRecords only if it starts today, and AppState.historyRevision makes ContentView reload History. Tests: AppStateSoftDeleteTests.testUndoOfOlderRecord_notInsertedIntoToday, testUndoOfTodayRecord_backInToday, UI RecordDeletionTests.testUndoRestoresOlderRecordToHistoryOnly (skipped on the 25th and 26th, when a fixture record is of today)
  • Duplicate tag ids crash the app and reports [P2] (done 2026-10-07)

    • Was: document keys that differ only in letter case parse to the same UUID, so duplicates are reachable from storage, and Dictionary(uniqueKeysWithValues:) trapped in AppState.rebuildTagsByID (the app crashed on load), ReportData.build (export report), stop, import hey (by id with --resume and by lowercased name), the two report image providers and the legacy FileStorageService cache
    • Now: every lookup is built with uniquingKeysWith (first wins), as ExportService already was; no uniqueKeysWithValues is left in the sources. Tests: AppStateDuplicateTagTests, ReportServiceTests.testBuildWithDuplicateTagIdsDoesNotCrash, three in CLIIntegrationTests
    • Left: storage still hands out both tags (the app lists the twin as a second tag); whether to normalize key case on load is open
  • Unparseable month file [P2] (done 2026-10-05, with the tags file included)

    • Was: one unreadable MM.automerge made loadAllRecords throw (nothing loaded in the app, most CLI commands exited 1), hasExternalChanges() stayed true forever (full reload every 5s), nothing could be saved into that month, and a zero-byte file was read as an empty document and overwritten by the next save. A release build showed none of it: errorMessage is only visible in the Debug view
    • Now: an unreadable or zero-byte file is never overwritten, renamed or deleted. Reads skip it; new records for that month go to MM.recovery.automerge, which is merged into the month file and removed once the file reads again (merge plus a fix-up for records that exist as two independent objects). The per-file stamp is (mtime, size); files that could not be opened are retried every 60s. Edits and deletes of records hidden inside an unreadable file are refused with a named error instead of being queued
    • tags.automerge unreadable: no recovery file; the app keeps its last known tags, tag edits are refused, entries keep their tags. Also fixed on the way: the editor erased a record’s tag on any save while tags were missing, a timer with a tag that could not be created started untagged, the in-app HEY import imported untagged
    • UI: banner under the toolbar plus a “Storage problems” section in Settings; alerts for refused save, stop, delete and tag operations. CLI: one stderr warning on commands that read records; doctor names each unreadable file, keeps duplicate records and image integrity meaningful, adds a recovery files row and an unreadable JSON array; export, records list --csv, import hey --resume and tags delete refuse when an unreadable month is in range
    • ConflictResolutionService: no longer overwrites a zero-byte base file; a lone conflict copy with no base file is promoted instead of being ignored
    • Docs: 312-automerge-storage-service, 506-cross-process-safety, 416-storage-warnings, 311-cli
    • Not done, see “Follow-ups from the unreadable-file work” below
  • Soft-delete leftovers [P2]

    • Quitting (or iOS killing the app) inside the 60s undo window leaves the deleted record’s image files as orphans; only minuta doctor reports them. Consider purging on termination or a launch-time sweep limited to ids the app itself deleted
    • Deleting a record another process moved to a different month within the last 5s keeps a stale start time in the pending entry: the purge looks in the old month folder, and undo restores the record into the old month while its files sit in the new one
    • softDeletesInFlight is not cleared on a storage switch; the reinitialize purge has no test
  • Record editor save edge cases [P2] (done 2026-10-13)

    • Done 2026-10-05: onRecordUpdated fires only after a successful save; a failed save or stop shows an alert and keeps the editor open; buttons are disabled while an action is awaited
    • Was: a save and an image add/remove could overwrite each other in storage, since each writes the whole record from its own snapshot. Now they run one at a time through SerialWriteQueue and read the record at their turn
    • Was: the debounced auto-save was not cancelled on Stop or on the delete of a running timer, so it wrote the stopped timer back as running or re-created the deleted one (both reproduced at AppState level). Now Delete, Save and Stop cancel it, and it goes through AppState.autoSaveRunningTimer, which refuses a timer that left runningTimers or is being stopped or deleted (covers a stop from the mini window, Stop All and the tray)
    • Tests: AppStateEditorSaveTests (9). No UI test: XCUITest takes about 4 seconds between a keystroke and the next tap, far outside the 300 ms window, and nothing can slow storage down for the image case; the editor’s use of the queue is covered only by the existing editor and image UI tests
    • Left: a stop through Shortcuts or the widget does not go through AppState, so until the next reload an auto-save can still reopen that timer (fixed 2026-10-15, see “Route Shortcuts intents through WidgetIntentService”); a stop from the mini window uses its own copy of the record and can drop a keystroke typed in the main window in the last 300 ms
  • Storage move leftovers [P3]

    • Timezone: loadRecords opens only the months of the range under the current calendar, so a boundary record bucketed in another timezone can be missed; widen monthsBetween by a day on each side or bucket by a fixed calendar
    • A month document emptied by a move stays on disk as an empty file; deleteRecord for an id found nowhere still writes the start-month document; deleteRecord over several months can fail after an earlier month’s copy is already gone
    • Every updateRecord / deleteRecord walks the records directory under the lock; negligible per call but multiplied in tag merge and the in-app HEY import (which calls updateRecord for brand-new records, SettingsSheet.swift:329)
  • Catalyst CI open questions [P2]

    • Done 2026-10-13: the Catalyst UI-test step runs only on a manual run (workflow_dispatch, gh workflow run build.yml); on every push it took over the interactive session of the runner, which is the development machine. Not verified in Actions until the change is pushed
    • Ad-hoc signing is unverified on a machine without an Xcode account for the team; each ad-hoc build is a new code identity, so macOS privacy grants do not persist between runs
    • /private/tmp/MinutaUITests/ is left behind between runs
  • UI test findings [P2]

    • FilterTests “Navigate previous month” is a silent no-op: it looks for buttons labelled chevron.left under if leftButtons.count >= 2, but the chevrons are labelled Back / Forward and the month toggle is not a button on Catalyst
    • The historySection identifier on the History Section overrides its descendants’ identifiers (shareButton, recordRowImageThumbnail, historyRecordDuration)
    • dragScroll at dx: 0.5 over the expanded calendar selects a date range instead of scrolling (its doc comment claims otherwise); the historyDurationRecord() comment is outdated (Today rows carry the identifier too)
    • AppStateSoftDeleteTests fixture record starts at now minus 2h, so one todayRecords assertion depends on the clock between 00:00 and 02:00
    • BareRecordEditorTests.testOpenEditorForBareRecord failed once on Mac Catalyst as the first test of a full run (2026-10-07): “Failed to get matching snapshot” for the Total: label right after waitForExistence; passed 4 of 4 alone
    • Fixed 2026-10-13: StorageWarningTests.testStartTimerErrorIsShown failed on Mac Catalyst on every run that day (the click on the error toast’s x did nothing), also on the sources that passed on 2026-10-07. The x had a 7 by 7 point hit area; it is now padded. Why the same click hit it a week earlier is not established
  • Small leftovers [P3]

    • scripts/release-brew.sh:128-130 comment still says the Minuta scheme builds MinutaWidgets on Catalyst
    • Docs: 301-file-storage “Record Caching” section describes the legacy cache as current; 403-app-state snippet still shows @Published var pendingDeletion; 412-deletion-toast Related points at 402-ios for the toast container (it is in ContentView); 311-cli synopsis minuta doctor [--json] omits -v, and its duckdb example sums the H:MM Duration string; 305-storage-location:64 still calls the Mac App Store build “Planned”; 914-plan-cli-tool:272,309 shows --tag as the filter flag

Follow-ups from the unreadable-file work (found 2026-10-05):

  • Errors that are still invisible in release builds [P2] (done 2026-10-07)

    • Was: AppState.errorMessage is shown only in the DEBUG DebugStateView, so every action without an alert of its own failed silently
    • Now: those actions call AppState.reportError and the message shows as an error toast (417-error-toast) in the main window, Settings, the CSV export sheet and the mini window: start (play button, Cmd+N, tray, mini window), Stop All (Cmd+., tray), the mini-window drop button, tag create/archive/unarchive, Reset Colors, undo of a delete, loadHistoryRecords, the Settings CSV export, a failed loadData (stays until a load succeeds). Image add/remove in the editor shows an “Image Error” alert like the editor’s other failures
    • Left: a tray command that fails while no window is open is gone after 10 seconds unseen; App Intents report through the intent’s own error
  • Phantom Fonts panel keeps coming back in Mac UI tests [P2] (found 2026-10-05, fixed 2026-10-06)

    • Cause: with a non-Latin keyboard layout active (Russian), XCUITest typeText reaches the app as Command chords, so typing a comment started timers (Cmd+N), minimized the window (Cmd+M) and opened Fonts (Cmd+T); tests kill the app, so AppKit window restoration brought the panel and the minimized window back on every later launch. The NSFontPanelAttributes default was a red herring
    • Fix: the UI test base class switches to an ASCII-capable layout per test on Catalyst and restores it, and launches with -ApplePersistenceIgnoreState YES; the app’s Format menu is removed in AppDelegate.buildMenu
  • Unreadable-file leftovers [P3]

    • No in-app action to give up on a lost original; the user moves or deletes it in Finder or Files and the recovery file takes its place on the next write
    • A stale update of a record that lives only in an unreadable recovery file next to a readable original is still accepted; saveRecord with an existing id whose copy sits in an unreadable month leaves a duplicate after repair; deleteRecord with a stale start time pointing at a readable month while the record sits in an unreadable one is a silent no-op
    • Lone-copy promotion in ConflictResolutionService applies to any .automerge file in the folder whose name matches a conflict pattern
    • WidgetIntentService.publish() still loads tags, so with unreadable tags and no live app a widget start does not refresh the widget snapshot
    • CLI: --this-month-style ranges end at the next month’s first instant, so an unreadable next-month file also blocks an export; with unreadable tags records show --json gives "tag": null with only the stderr line as a signal; a default records list with no date filter walks every month from 1970 to 2100
  • loadData across a storage-folder switch [P2] (done 2026-10-07)

    • Was: it read trackingService from self after its first await while storage was captured before it, so a switch during the load read tags and records from the new folder and tag usage from the old one; tags, todayRecords, runningTimers and tagLastUsedAt were assigned without the folder guard that storageWarnings had
    • Now: both services are captured together and nothing is published once the folder has changed (migrationChecked, isLoading and the load error included); reinitializeStorage also clears tagLastUsedAt. Test: AppStateErrorNoticeTests.testLoadOvertakenByStorageSwitch_doesNotPublishOldFolder
  • NewTimerSheet is unreachable [P3]

    • showingNewTimer is never set to true; either wire the sheet up or remove it
  • Home-directory path in published screenshots [P2] (done 2026-10-07)

    • Confirmed: the iPhone snapshots of Settings (SettingsTests 02-settings-open, TagManagementTests 02-settings-open) showed the simulator path under the developer’s home directory in the “Data Folder” row
    • Now: simulator test storage lives in /private/tmp/MinutaUITests/, the folder Mac Catalyst already used, so the row shows that path on both platforms
    • Left: the old images are in git history, and the live site keeps them until the next deploy

Code Quality [P2]

  • Remove debug cellBorder() from HistoryGrid (2026-09-07)

    • Was only wrapped in #if DEBUG, so Debug builds (and every screenshot) showed gray cell outlines that Release/TestFlight builds did not. Removed the gray cellBorder() and its six call sites for good; the colored gridBorder() outlines (green around a month’s days grid, orange around a day’s records) lost their #if DEBUG and are part of the design in all builds
  • Split AppState into smaller services

    • Location: MinutaApp.swift (320+ lines)
    • Handles: state, tags, records, deletion undo, tag merging
    • Consider: TagManagementService, RecordManagementService
  • Split RecordEditorViews

    • Location: RecordEditorViews.swift:1-450 (450+ lines)
    • Mixed concerns: display, editing, formatting, image operations
    • Extract image handling to separate component
  • Consolidate history reload triggers (2026-01-03)

    • Merged two .task modifiers into one for initial load
    • Added comments explaining each reload trigger
    • Location: ContentView.swift:113-126

Performance [P2]

  • Layout optimization (2026-07-12)

    • Covered by the HistoryGrid work below: row-per-month restructure (List cell reuse), sticky labels via onGeometryChange, removed dead DEBUG debugInfo state that re-evaluated the whole page every scroll frame
    • Scroll confirmed smooth after the fixes; further ideas tracked in the HistoryGrid entry below
  • Improve HistoryGrid performance (2026-07-09)

    • Root cause of year-range slowness: the whole grid was a single List row containing eager nested Grids, so a year selection built every record view upfront (the progressive-loading LoadMoreTrigger had been removed by then)
    • Fix: HistoryGrid restructured into HistoryMonthRow — HistorySection emits one List row per month, so the List’s native cell reuse only builds on-screen months. Columns still align across months via fixed HistoryColumnSizes widths. Editor sheet moved into the month row owning the edited record.
    • Grid geometry reporting (GridDebugPreferenceKey and friends) kept in release — part of the design, not debug-only
    • Follow-up (scroll felt janky after the restructure): sticky labels moved from GeometryReader+onChange to onGeometryChange; removed dead DEBUG debugInfo state in ContentView that re-evaluated the whole page on every scroll frame (its preference emission in the grid stays — part of the design)
    • Remaining ideas if profiling still shows hotspots: one List row per day instead of per month (bounds cell-build cost — the likely fix if big months still hitch on scroll-in), incremental re-grouping on single-record edits (updateFilteredRecords() re-groups everything), debounce filter changes
    • Related: 801-optimization-plan Phase 6, 802-scroll-profiling, 803-load-more-bug
  • Investigate slow year report generation

    • Year range report is noticeably slow (about 500 records)
    • Profile SVGReportRenderer and WebKitReportService
  • Add pagination for large record sets

    • Location: FileStorageService.swift:218-256
    • All records loaded into memory - problematic for years of data

UI/UX Improvements [P2]

  • Add 300ms debounce to record saving while editing (2026-06-01)
    • Already implemented for running timers: scheduleSave() cancels the prior saveTask and sleeps 300ms before saveChanges(createTag: false) (RecordEditorViews.swift:412-419), wired to tagInput/editedComment/editedStartTime onChange handlers (:386-394). createTag: false avoids creating tags mid-typing (tags created on timer stop).
    • Completed records intentionally keep explicit Save/Cancel (Cancel discards). Extending auto-save there is a separate behavior change (Cancel/tag-creation semantics) — not part of this item.
  • Edit record sheet — remaining polish (verified done 2026-07-03)

    • Both sub-items were already implemented: TabEscapingTextView caps growth at maxHeight: 200 and switches to internal scrolling past the cap; TagSelector auto-focuses the text field in its onAppear (two runloop hops to survive Catalyst’s UIFocusSystem race)
    • Location: TabEscapingTextView.swift, TagComboBox.swift (TagSelector)
  • Better controls for date/time selection in record editor

    • Current text fields are bare; need proper date/time pickers
    • Consider inline pickers or popovers for start/end time editing
  • Keyboard accessibility

    • Arrow keys for tag navigation, Tab for all controls
    • Mac: Shift-Tab for tab character, Tab for navigation
  • Arrow key navigation in CalendarDatePicker

    • Navigate between year/month/week/day/preset buttons with arrow keys
    • Enter/Space to select
    • Location: CalendarDatePicker.swift
  • Tab focus on tag filter buttons

    • Tags in TagFilterView should be focusable via Tab key
    • Location: TagFilterView.swift
  • Highlight records spanning over 1 day

    • Records whose start and end fall on different days should be visually distinguished in the history list
  • Support drag and drop for image attachments

    • Drop images onto RecordEditor from Finder, Photos, browsers
  • Apply color theme to whole project

    • Consistent theming, design tokens, dark mode consistency
  • Save everything on Cmd+S

  • Cleanup settings from dev info

    • Settings sheet exposes developer-facing details that shouldn’t be in a user-facing screen; move them under Debug (DEBUG builds only) or remove
    • Location: SettingsSheet.swift
  • Create app logo (2026-06-01)

    • Hand-drawn stopwatch (assets/logo-source.png) over the brand gradient. Source assets/app-icon-source.svg; generated via ./scripts/generate-app-icons.sh (app icon, in-app stopwatch button, web hero). Favicon built from the same SVG.

Infrastructure [P2]

  • Fix GitHub Actions

    • Figure out good way to run and fix setup
  • Monorepo structure (turborepo or bazel)

    • Build and screenshots cached, triggered on code changes
    • Use code hash as cache key

Publishing [P2]

  • App autoupdate (done 2026-07-03)

    • Implemented as check + notify only (no in-app install, no Sparkle): UpdateCheckService fetches https://minuta.tools/downloads/version.json (prerendered at site build from project.yml), compares versions, and the toolbar pill / Settings About row link to the downloads page
    • Settings “Check for updates” toggle off by default; 7-day discovery pill ( enable updates | x ) next to the pin button, x dismisses forever; zero network before opt-in (single carve-out from the “no network requests ever” rule)
    • Gated by MANUAL_DISTRIBUTION compilation condition (Catalyst UI only; an App Store build would drop the flag — currently on in all configs since no App Store pipeline exists)
    • Covered by UpdateCheckServiceTests (unit, both platforms) and UpdatePromptTests (UI, Catalyst-only, not run in CI)
    • Docs: 315-update-check
  • Mac distribution under the holder’s team: Developer ID (Homebrew) + Mac App Store (TestFlight) (planned 2026-09-08; Homebrew part shipped 2026-10-02 as 1.0.3 / CLI 0.1.2, iOS 1.0.3 (5) with widgets in TestFlight; Mac App Store build 1.0.4 (6) uploaded 2026-10-02 after the Mac Installer .p12 arrived; remaining: verify the macOS build in TestFlight)

    • Plan: 919-plan-mac-distribution (supersedes 917-plan-mac-testflight); holder request in TESTFLIGHT-HOLDER.md
    • Homebrew build today is signed with the personal team’s Apple Development cert and carries get-task-allow; spctl rejects it, the cask hides it by stripping quarantine. Daler’s Apple Distribution cert cannot fix this (App Store only)
    • Blocked on the holder: Developer ID Application .p12, App Store Connect API access (notarytool key), Mac Catalyst capability on the App IDs, Mac App Store profile tools.minuta.app MacAppStore, Mac Installer Distribution certificate; macOS platform added to the App Store Connect record
    • Done (2026-09-08): release-brew.sh signs with Developer ID + notarizes + staples when the untracked Minuta/DeveloperID.xcconfig and the minuta-notary keychain profile exist, loud warning + old path otherwise; Minuta/TestFlight/Minuta.macOS.entitlements and MinutaWidgets.macOS.entitlements; sandbox guard for the CLI pointer in StorageLocationManager.publishResolvedStoragePath() (+ unit test); Minuta/ExportOptions-mac.plist; xcconfig templates in the plan; guide section 8 rewritten (App Group stays group.*)
    • 2026-10-02: certs + profiles imported, minuta-notary is an app-specific-password profile (Team Keys page errors for the Admin account), both xcconfigs created, first notarized release shipped (cask postflight dropped, tap c840b23), Mac archive verified, export fails on the missing Mac Installer Distribution identity; execution log in the plan
    • Follow-up: CLI discovery pointer under App Sandbox, now its own entry below
  • CLI discovery pointer from the sandboxed Mac App Store build (added 2026-10-02)

    • Today StorageLocationManager.publishResolvedStoragePath() skips the pointer when APP_SANDBOX_CONTAINER_ID is set, because <realHome>/Library/Application Support/minuta/storage-path.txt is outside the container; the Homebrew CLI then falls back to ~/Documents/Minuta instead of the folder picked in the App Store app
    • Write the pointer into the App Group container instead: FileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.tools.minuta.apps")/storage-path.txt (~/Library/Group Containers/group.tools.minuta.apps/ on macOS, allowed under sandbox with the existing application-groups entitlement); CLIConfigService gets a second writer, the sandbox guard in StorageLocationManager picks the group path rather than skipping
    • StorageResolver (Shared/Sources/MinutaCLI/Runtime/StorageResolver.swift): add the group-container pointer after the Application Support one (--storage -> MINUTA_STORAGE -> App Support pointer -> Group Containers pointer -> ~/Documents/Minuta); minuta doctor reports which one resolved
    • Caveat to document: on macOS 15 a Terminal process reading another app’s Group Container may trigger the one-time “wants to access data from other apps” prompt for Terminal; acceptable for a CLI, no entitlement exceptions
    • Tests: StorageLocationManagerTests (sandboxed launch writes the group pointer), CLI StorageResolver tests with a fake group pointer
    • Docs: 506-cross-process-safety, 311-cli discovery order, CLAUDE.md
  • CLI install hint in Settings for the App Store build (added 2026-10-02)

    • The Mac App Store build cannot ship the CLI: every executable in the bundle must be sandboxed, and a sandboxed binary launched from Terminal gets its own container and cannot read the user-picked storage folder. The CLI stays on Homebrew / direct download (notarized, unsandboxed), as other App Store apps do
    • Add a “Command-line tool” row to Settings > Data (or About) in the Catalyst build: one-line brew install ramil-k/minuta/minuta (plus brew trust ramil-k/minuta for Homebrew 7, see the entry above) with a Copy button and a link to the downloads page; detect an installed CLI via storage-path.txt being read is not possible, so keep it static
    • Show it in both Mac distributions (Homebrew users may not know the CLI exists); hide on iOS
    • With the group-container pointer above, the Homebrew CLI finds the App Store app’s folder automatically, which is what makes this a complete “ships with CLI” story
    • UI test (Catalyst): the row is present and the copy action puts the command on the pasteboard
  • Streamline the build, test and deploy process (added 2026-10-02, done 2026-10-03)

    • Done: scripts/release.sh --app X.Y.Z [--cli X.Y.Z] runs preflight, the test gate (all tests including UI by default), version bump + release X.Y.Z commit, Homebrew build/upload/tap, TestFlight iOS, Mac App Store, site deploy and push, with --only/--skip, --no-ui-tests/--no-tests, --no-push, --dry-run, --resume; step scripts in scripts/release/; notarized CLI cache; CLI rebuilt only when its version changes; docs at 704-release. The Catalyst CI items and run-mac.sh were fixed earlier the same week
    • First real run: 1.0.6 (8) / CLI 0.1.4 on 2026-10-06, all four channels, 1h19m in total (tests 49 min, site build 15 min). The test gate stopped the first attempt before anything was uploaded (Mac UI tests, keyboard layout issue), and --resume continued after the fix
    • Open: release-brew.sh regenerates scripts/cli-completions, so the completions land in the tree after the release commit instead of inside it
    • Today a release is a hand-run sequence spread over release-brew.sh, upload-brew-artifacts.sh, the tap clone, deploy.sh, two xcodebuild archive + -exportArchive invocations (iOS, Mac) with three untracked xcconfigs, version bumps in three places (project.yml x3 targets, Minuta.swift), and manual checks; the 1.0.3/1.0.4 run on 2026-10-02 took four archive reruns and three release-brew.sh reruns, each re-notarizing the CLI
    • One entry point, e.g. scripts/release.sh <channel> [--version X.Y.Z --build N] with channels brew, ios, mac: bump versions in every place from one flag (and fail if project.yml targets disagree), run the gate (swift test, MinutaTests, iOS + Catalyst builds) once, then archive/export/notarize per channel; --dry-run stops before upload/push/deploy
    • Make reruns cheap: cache the notarized CLI tarball per source hash so an app-side retry does not resubmit the CLI; archive to per-channel paths so iOS/Mac runs do not clean each other’s DerivedData (bit the MinutaTests run on 2026-10-02)
    • Tap bump as a step: clone or reuse ~/Projects/homebrew-minuta, rewrite version/sha256/url from dist/SHA256SUMS, brew audit, commit, push; then deploy.sh; enforce the order artifacts -> tap -> site
    • Fold the TestFlight uploads in (release-testflight.sh from the entry below) once the API key exists, so no Xcode login is needed; until then keep the Xcode-login path
    • CI: run the gate on the self-hosted runner for every push (the Shared tests, MinutaTests, iOS build already exist in build.yml; add the Catalyst MinutaArchive build), release stays manual
    • Fix scripts/run-mac.sh (uses the Minuta scheme, which now drags MinutaWidgets in and fails on the personal team’s missing widget profile; switch to MinutaArchive or filter the dependency)
    • Docs: one “Release” page with the end-to-end checklist replacing the scattered steps in CLAUDE.md, TESTFLIGHT-ORG.md, 702-testflight and 919-plan-mac-distribution
  • Describe and automate the publishing process (mostly done 2026-10-03: scripts/release/testflight.sh ios|mac archives, checks signing and uploads; release docs at 704-release; left: upload and external-tester distribution through an App Store Connect API key, see the API key entry)

    • One script (scripts/release-testflight.sh) for iOS and Mac: archive MinutaArchive with the right xcconfig, generate ExportOptions.plist, codesign checks, build-number bump, upload
    • Upload without an Xcode login via an App Store Connect API key (Team Keys page errors for the Admin account as of 2026-10-02; notarytool runs on an app-specific password meanwhile, see 919-plan-mac-distribution)
    • A single docs page with the end-to-end checklist (holder materials, project state, archive, TestFlight groups, Test Information texts); today this is split across TESTFLIGHT-ORG.md, TESTFLIGHT-HOLDER.md and 702-testflight
    • Lessons from the 1.0.2 release run (2026-09-07), to bake into the script: check each command’s own exit code instead of piping xcodebuild through grep (with -quiet there are no “Test Suite” lines and pipefail fails the step); xcodebuild -exportArchive upload works with the Xcode Apple ID login; the Homebrew Catalyst build must use the MinutaArchive scheme (the Minuta scheme drags in MinutaWidgets, which the personal team cannot sign in Release); never codesign ... | grep -q under pipefail either, grep’s early exit SIGPIPEs codesign and the check fails on a correct signature (bit release-brew.sh on 2026-10-02); only re-upload the CLI tarball when the CLI version changed, or the formula sha256 breaks; the tap clone needs an SSH remote for a non-interactive push; a full UI-test run regenerates every site screenshot, so the following site build recomputes the AVIF cache (~25 min)
  • Document brew trust for Homebrew 7 (found 2026-10-02)

    • Homebrew 7 refuses to load formulae/casks from a third-party tap until brew trust ramil-k/minuta is run once (“Refusing to load cask ramil-k/minuta/minuta from untrusted tap”)
    • Add the command to the install snippets on the downloads page, the CLI page, 311-cli, the tap README and CLAUDE.md; mention it in the TestFlight/Homebrew release checklist
  • App Store Connect API key for notarytool and uploads (blocked 2026-10-02)

    • Users and Access > Integrations > App Store Connect API > Team Keys shows “An error has occurred. Try again later.” for the Admin account even though the holder enabled API access (Issuer ID 8b60b2ec-8676-41fa-92ca-820560cf3044)
    • Meanwhile minuta-notary is an app-specific-password keychain profile and -exportArchive uploads use the Xcode Apple ID login
    • To unblock: retry the page later / from another browser, or the holder creates the key (Minuta notary, role Developer) and sends .p8 + Key ID; then xcrun notarytool store-credentials minuta-notary --key ... --key-id ... --issuer ... and add -authenticationKey* flags to the export commands in the guides
  • Mac App Store export: Mac Installer Distribution .p12 (blocked 2026-10-02, resolved the same day: .p12 imported, export needs the full identity name in installerSigningCertificate, 1.0.4 (6) uploaded)

    • xcodebuild -exportArchive with ExportOptions-mac.plist fails: No certificate for team 'G86VRDBMDK' matching 'Mac Installer Distribution' found; the holder’s bundle had only the public mac_installer.cer
    • To unblock: the holder exports the Mac installer identity (the installerSigningCertificate value in Minuta/ExportOptions-mac.plist) with its private key (TESTFLIGHT-HOLDER.md), import it, rerun the export from the already verified Mac archive, add the macOS platform to the App Store Connect record
  • Research iOS app publishing from Uzbekistan

    • App Store Connect requirements, payments, tax implications
  • Trademark protection for “Minuta”

    • Research registration in software category
    • Consider jurisdiction (US, EU, local)

macOS Enhancements [P3]

  • Minified/PiP mode (Mac only) (2026-08-06)

    • Plan: 916-plan-pip-mini-timer — supersedes the same-window sketch below with a companion-window design; shipped as the mini timer window (pip toolbar button + tray icon), docs at 400-ui/415-mini-timer-window
    • Compact floating view showing only running timers
    • Toggle via pip icon in title bar toolbar (next to pin/settings)
    • Layout: small window with just timer list + play button
    • Behavior:
      • Remembers minified state separately from main window size
      • Works with existing pin (always-on-top) feature
      • Hides History section, Today section, settings button
      • Shows: running timers, floating play button, expand button
      • Expand button (pip.exit) returns to full view
    • Implementation:
      • Add isMinified state to WindowPinManager (rename to WindowStateManager?)
      • Add pip toolbar button in SceneDelegate
      • Conditionally render sections in ContentView based on minified state
      • Animate window resize transition
      • Store minified window frame separately in UserDefaults
    • Related: pairs well with pin button for “mini always-on-top timer”
  • Menu Bar app

    • Mostly shipped 2026-08-06 via 916-plan-pip-mini-timer: tray icon with play symbol, summon/hide mini window, quick start/stop menu
    • Remaining (deferred there): per-timer rows with ticking elapsed in the right-click menu, elapsed text next to the icon — needs timer snapshots across the tray bridge
  • Always-on-top widget (superseded by Minified mode above)

    • Floating window with current timer state
    • Minimal, non-intrusive design

Widgets [P3]

  • Home Screen / Desktop widgets (WidgetKit) (2026-01-05)

    • Small: running timer with stop button, or today summary with start button
    • Medium: running timer + quick start tags with interactive buttons
    • Large: running timers list with stop buttons, today summary, quick start tags grid
    • App Groups for shared data (group.tools.minuta.apps)
    • Timeline provider with 1-minute updates for running timers
    • Interactive buttons using App Intents (start/stop timers)
    • Note: Widget extension enabled 2026-08-29, iOS only (platformFilter: iOS); Catalyst builds ship without widgets
  • Time by Tag widget (2026-10-13)

    • Home screen widget, medium and large: hours per tag for a period chosen in Edit Widget (Today, This Week, This Month), total in the header, the rest folded into “Other”
    • The snapshot carries per-day totals by tag; the widget sums its period per timeline entry, so it rolls over without the app. Docs: 205-widget-data
    • Checked in the simulator’s widget gallery and through the published snapshot; the configured widget on a home screen is unverified until a TestFlight build (the simulator’s SpringBoard crashes when any widget is placed)
    • Not done: more periods (last month, last 7 or 30 days, custom dates), a small size, share bars, a Mac version
  • Lock Screen widgets (iOS) (2026-01-05)

    • Circular: gauge showing running timer progress or today’s hours
    • Rectangular: running timer with tag name and duration, or today summary
    • Inline: compact text showing timer or today’s total
  • Enable widget extension (2026-08-29)

    • App Group group.tools.minuta.apps and bundle ID tools.minuta.app.widgets registered under the TestFlight publishing team
    • Widget target enabled in project.yml (iOS only)
    • Committed entitlements stay empty (personal free team cannot hold the App Group); TestFlight builds swap in Minuta/TestFlight/*.entitlements via xcconfig, see Publishing to TestFlight
  • Re-enable widgets in TestFlight (done 2026-10-02)

    • Was blocked 2026-09-06: the holder’s widget profile was issued for App ID tools.minuta.app.widget (missing trailing “s”); the widget dependency and its MinutaArchive entry were commented out so the first TestFlight build shipped app-only
    • 2026-10-01: Daler registered tools.minuta.app.widgets (App Group group.tools.minuta.apps) and reissued tools.minuta.app.widgets AppStore (plus a reissued tools.minuta.app AppStore); profiles installed, dependency + scheme entry restored, ExportOptions-ios.plist committed with both profiles; see 919-plan-mac-distribution
  • Rework the widget data flow (added 2026-10-02, done 2026-10-02; two corrections to the original sketch below: Text(style: .timer) cannot hide seconds, so elapsed is H:MM from per-minute precomputed entries with .atEnd; and an intent defined in the app target does not launch a killed app, the app-side copies conform to ForegroundContinuableIntent instead, see 205-widget-data)

    • Today: the app writes a JSON snapshot (WidgetDataService -> widget-data.json in the App Group) and calls reloadAllTimelines(); the widget’s StartTimerFromWidgetIntent / StopTimerFromWidgetIntent only drop pending-timer.json into the group container, which AppState.processPendingWidgetRequests() consumes the next time the app becomes active, so a tap on the widget does nothing until the app is opened; running durations are rendered as static text from a single timeline entry with .after(60s), which WidgetKit throttles (40-70 refreshes/day budget), so the elapsed time goes stale
    • Intents: move the widget buttons to the app-target intents (StartTimerIntent / StopTimerIntent in AppIntents.swift, exposed to the extension through a shared AppIntentsPackage); the system then runs perform() in the app process in the background with full storage access (bookmarks, AutomergeStorageService, write lock), the intent writes the record, refreshes the snapshot and calls WidgetCenter.shared.reloadTimelines(ofKind:); delete pending-timer.json and processPendingWidgetRequests()
    • Rendering: show elapsed time with Text(timer.startTime, style: .timer) (ticks without timeline refreshes) and today’s total as a static value; build the timeline with a few entries (now, next midnight for the “today” rollover) and policy: .atEnd, no per-minute reloads
    • Reload triggers: only on data changes — after start/stop/edit/delete in AppState, after ExternalChangeMonitor reloads (CLI / sync drops), on scene activation; use reloadTimelines(ofKind:) for the affected widget kinds instead of reloadAllTimelines() everywhere
    • Snapshot: keep widget-data.json as the read model for the extension (it cannot resolve the app’s security-scoped bookmark), include lastUpdated and the storage folder display name so a stale snapshot can show “open Minuta to refresh”
    • Tests: unit tests for the intent path through an injected storage (both targets), a UI test that taps the widget intent in the simulator is not possible, so cover the AppState side
  • iPhone nightstand mode widget

Watch App [P3]

  • Apple Watch companion
    • Start/stop timers, show current state
    • Complication for quick access
    • WatchConnectivity for sync

Integrations [P3]

  • Default folder visible in Files on iOS (2026-09-06)

    • The container Documents/Minuta was invisible on the phone: no way to find, back up or move the data. Added UIFileSharingEnabled + LSSupportsOpeningDocumentsInPlace to Minuta/Sources/Info.plist; the folder now shows in Files > On My iPhone > Minuta and in Finder file sharing. Still local-only, see the next item
    • Shipped in 1.0.2: TestFlight build 1.0.2 (4) and Homebrew cask 1.0.2 (2026-09-07)
  • iCloud Drive as the default folder (planned 2026-09-06)

    • Plan: 918-plan-icloud-default
    • Blocked on the holder: iCloud capability + container iCloud.tools.minuta.app on the App ID, regenerated AppStore profile, and an Apple Development certificate + development profile for device iteration (iCloud is not available on the free personal team)
    • Ours: iCloud entitlements in Minuta/TestFlight/Minuta.entitlements, NSUbiquitousContainers in Info.plist (public scope, name Minuta), ubiquity container resolver with local fallback, .icloud placeholder download via NSMetadataQuery, NSFileCoordinator around reads/writes, NSFileVersion conflict merge through Automerge, one-time “Move to iCloud Drive” prompt for existing local-default users, CLI discovery of ~/Library/Mobile Documents/iCloud~tools~minuta~app/Documents
  • Automerge for conflict-free sync

    • CRDT library (automerge-swift v0.6.1)
    • Decision: Use Automerge over custom CRDT implementation
    • See 505-automerge-migration-plan for 11-phase implementation plan
  • Deel timesheet sync

  • Toggl Track import/export (done 2026-10-17)

    • Import Toggl CSV exports; export records in Toggl’s 13-column layout
    • Project alone becomes the tag name (decided 2026-10-16); Toggl Tags goes to metadata. Zero-duration rows import as 1-second records. Toggl export lives in Settings only: a format dropdown (CSV by default, Toggl) before the export button
    • Remaining columns (client, task, billable, project, tags) kept verbatim in a new TimeRecord.metadata: [String: String] — inert for now, so a later migration can promote them without a re-import
    • Done: CSVParser RFC 4180 fix, TimeRecord.metadata (Automerge map, per-key merge), TogglImportService, CLI import toggl and export csv --layout toggl, Settings “Import from Toggl” + Export Format menu. The reference file (547 rows) imports in full and exports back identical except User/Email and the 3 zero-length rows
    • Open: Toggl’s own importer may need Email filled in (the export leaves it empty); not tried against Toggl
    • See 915-plan-toggl-import-export for the full plan
  • Tags with a long shared prefix get the same color [P3] (observed 2026-10-17, done 2026-10-19)

    • The Toggl reference file’s projects avito-pii, avito-general, avito-onboarding, avito-edu, avito-hr all got #AC5463 from TagColorGenerator.color(for:): the hue is subdivided by the first letters only, so names sharing a 6-character prefix collide
    • Same for HEY categories and tags created by hand; “Reset Colors” does not help
    • Done: hues are spread around the circle in name order. A new tag takes the middle of the hue gap between its neighbours (TagColorGenerator.color(for:among:)), Reset Colors places all tags at 360/N steps (spreadColors). See 204-color-palette
    • 2026-10-20, after 1.2.1: colors are computed, not stored. Active tags are always at 360/N steps in name order, archived ones between their active neighbours, the stored color is ignored (TagColorGenerator.colored, applied in getAllTags and AppState.publishTags). Reset Colors, CLI tags recolor and tags add --color are gone
  • Image and PDF previews in Quick Look [P3] (done 2026-10-22)

    • Record images and the PDF/PNG export previews opened in small sheets (on the Mac a sheet from a sheet ignores presentationSizing) with hand-written zoom gestures
    • Done: the system Quick Look viewer (QuickLookPresenter): on the Mac the Quick Look panel over the dimmed window, on iPhone and iPad full screen; zoom, pan, paging between a record’s images, share. The viewer has no Delete; it stays in the thumbnail’s context menu. See 414-record-images
  • Billable flag on records [P3]

    • Bool on TimeRecord + checkbox in the record editor; surface in reports/export
    • Migration reads toggl.billable from metadata for already-imported records
    • Depends on the metadata field from Toggl import/export above
  • Client/Project as tag concepts [P3]

    • Decide the structure: tag groups, hierarchy, or a second tag axis on records
    • Migration source is toggl.client/toggl.project in metadata
    • Depends on the metadata field from Toggl import/export above
  • Google Calendar import

    • Convert calendar events to time records
  • System calendar auto-tracking

    • Auto-create records from meetings

Architecture [P3]

  • Introduce view model layer

    • Reduce direct AppState coupling from views
  • Implement repository pattern

    • Abstract storage implementation details
  • Stop storing tag colors

    • Tag colors are deterministically derived from tag names via TagColorGenerator (OKLCH-based)
    • Remove color field from the stored Tag model; compute on read instead
    • Drop migration logic that carries forward legacy stored colors
    • Location: Shared/Sources/MinutaShared/Models/Tag.swift, call sites in views
  • Create shared reports library

    • Extract report generation (SVG/PDF/PNG/CSV) into a reusable module consumable by both app and CLI
    • Location: Shared/Sources/MinutaShared/Services/ (ReportService, SVGReportRenderer, WebKitReportService)

Websites [P3]

  • Add downloads page to the website (2026-07-03)

    • Public page at /downloads/ (src/routes/downloads/) with Homebrew instructions and direct links to the release artifacts served from the same path; linked from header and footer nav
    • Versions read at prerender time from Minuta/project.yml and Shared/Sources/MinutaCLI/Minuta.swift (+page.server.js), so links track releases on each site deploy
    • Fixed deploy.sh: rsync --delete was wiping the server’s downloads/ artifacts on every site deploy (confirmed: the directory was gone on the server); added --filter='P downloads/*' and re-uploaded artifacts via scripts/upload-brew-artifacts.sh
  • Docs and promo sites

    • SvelteKit + IDS styles
    • Static generation with backlinks component
    • Prepare scaffold project with Daler

CalendarDatePicker [P3]

  • Use full month title instead of abbreviated (2026-01-03)

    • Changed from shortMonthName() (MMM) to monthName() (MMMM)
  • Round borders only for range start/end days (2026-01-03)

    • Added RoundedCorners shape for selective corner rounding
    • Start day: left corners rounded, End day: right corners rounded
    • Middle days: no corners, Single day: all corners
  • Use pressable button style for navigation (2026-01-03)

    • Added PressableButtonStyle with scale and opacity animation
    • Applied to all interactive elements
  • Use pressable button style for presets (2026-01-03)

    • Applied PressableButtonStyle to Today, Week, Month preset buttons
  • Add drag-to-select date range (2026-01-03)

    • Drag across calendar days to select a range
    • Handles dragging in both directions (forward and backward)
  • Toggle visual state for all selectable elements (2026-01-04)

    • Year/month/week labels and presets show “selected” visual when their range matches
    • Single rangeBinding(from:to:) factory method creates bindings for any date range
    • Added SelectableLabelToggleStyle, PresetToggleStyle, WeekNumberToggleStyle
    • All styles now use LayoutMetrics constants and PressableButtonStyle
  • Fix calendar control layout

    • Add folded variant (collapsed view)
    • Make year and month labels wider
    • Set default state to today (not expanded full calendar)
    • Fix overall layout alignment and spacing issues
  • Replace calendar toggler with simple button (2026-07-13)

    • Collapsed state: calendar-icon pill + underlined period label (link style), opens calendar on tap
    • Expanded state: same calendar-icon pill at top left (next to the From/To date pickers) collapses it
    • Removed FilterToggleButton; location: HistorySection.swift, CalendarDatePicker.swift
  • Keep calendar grid height stable across months (2026-07-13)

    • calendarGrid pads with empty rows up to LayoutMetrics.maxWeekRows (6), so 4- and 5-week months render as tall as 6-week ones
  • Pressable toggle style for the calendar pill button (2026-07-13)

    • Moved PressableButtonStyle to Components/PressableButtonStyle.swift (was private in CalendarDatePicker.swift) and applied it to the pill in HistorySection.swift
  • Keep the calendar button the same view across collapsed/expanded states (2026-07-13)

    • HistorySection now owns a single persistent pill button that toggles the picker; next to it the period label (collapsed) or the From/To date pickers (expanded)
    • CalendarDatePicker is grid-only now (dropped the header row and onDismiss)
  • Profile slow first open of the calendar and fix (2026-07-28)

    • First expand takes visibly long; subsequent opens are fast
    • Suspected: compact DatePicker (UIDatePicker/UICalendarView) lazy first-init cost on Catalyst; List section relayout secondary
    • Profile with Instruments (Time Profiler / SwiftUI), then fix (e.g. pre-warm a hidden DatePicker at startup or keep the picker mounted)
  • Animate calendar expand/collapse (2026-07-14)

    • Calendar grid is its own List row under if isCalendarExpanded; expand/collapse rides List’s native row insertion/removal animation (0.3s easeOut), the only row-height change List animates smoothly
    • Animating the height inside a row was a dead end: self-sizing UICollectionView cells resize discretely, so per-frame animatableData re-measure jitters (SwiftUI draws the interpolated height while the cell applies it a beat later); Apple DTS confirms there is no supported knob
    • Pill row keeps constant height (period label / date pickers swap in an always-mounted ZStack, flipped instantly via .transaction { $0.animation = nil }), so the pill never moves

TagFilterView [P3]

  • Animate tags filter expand/collapse

    • Add animation to the tag filter DisclosureGroup, similar to the calendar expand/collapse animation
    • Location: TagFilterView.swift, HistorySection.swift
  • Rework the filter overall

    • Rethink the whole filter area (calendar + tag filters + summary) — layout, interaction, visual design
    • Subsumes/relates to “Rearrange filter components” below
  • All tags unselected by default

    • Currently all tags are selected on first open; change default to no tags selected (show all records unfiltered)
  • Rearrange filter components

    • Review layout and order of CalendarDatePicker, tag filters, and summary controls
    • Improve visual hierarchy and discoverability
  • Add drag-to-select for tag filters

    • Press and drag across tags to toggle selection
    • Similar to calendar drag-to-select behavior

Inbox

Unsorted ideas. Review periodically.

  • Play button background gradient in OKLCH with hours marks
  • Logo: play button with gradient and hours/minutes marks, 3D watch concept
  • Sort new timers consistent with file system
  • Heatmap view for records (monthly calendar visualization)
  • Auto start/stop based on app activity (macOS, accessibility permissions)
  • Better timeline navigation (swipe gestures, scrubber)
  • Native Apple Charts in history view (iOS 16+ / macOS 13+)
  • Not sure if we need to show Today section when it falls within the selected date span — experiment with hiding it
  • Record a short demo video of real usage to show people after installation (user feedback); could live on the website / downloads page