Performance Optimization Plan

App becomes slow with ~500 records. This plan addresses the root causes.

Phase 0: Benchmark Infrastructure

Create test environment to measure performance before/after each fix.

Tasks

  • Create large test fixture (500-1000 records)

    • Location: Minuta/UITests/Fixtures/performance/
    • Script: scripts/generate-perf-fixtures.swift
    • Generates records spread over 6 months with various tags
  • Create performance UI test

    • Location: Minuta/UITests/Flows/PerformanceTests.swift
    • Measures: app launch, scroll, filter toggle, export generation
    • Outputs timing to console
  • Establish baseline metrics

    • Record times before any optimization
    • Run on consistent hardware (simulator or device)

Metrics to Track

OperationTargetBaselinePhase 1Phase 2Phase 3Phase 4Phase 5Phase 6
App launch to interactive<1s4.6s4.0s3.7s2.4s1.35s1.23s2.0s
Scroll (6 swipes)smooth36.2s18.3s~19s11.0s7.0s8.2s8.8s
Toggle tag filter<100msTBDTBDTBDTBDTBDTBDTBD
Generate PDF report<2sTBDTBDTBDTBDTBDTBDTBD

Note: Scroll target revised from ”<1s” to “smooth” - 6 swipes through 750 records takes ~6-8s due to iOS swipe animation physics (each swipe ~1s). The goal is smooth 60fps scrolling, not raw speed.

Phase 6: Nested Grid with progressive loading (pagination). Initial load 50 records, load 50 more on scroll.

  • Nested Grid refactor caused regression (7.4s launch, 23s scroll)
  • Progressive loading fixed it: 2.0s launch, 8.8s scroll
  • Fixed period column sizing bug on filter switch (2025-12-31): Added fixed width constraint

Cumulative improvement: Launch 57% faster (4.6s -> 2.0s), Scroll 76% faster (36.2s -> 8.8s)

Baseline captured: 2025-12-29 on iPhone 17 Simulator


Phase 1: Quick Wins

High impact, low risk changes.

1.1 Cache duration for completed records

Location: TimeRecord.swift:58-61

Problem: Date() called on every duration access, even for completed records.

Current:

public var duration: TimeInterval {
    let end = endTime ?? Date()
    return end.timeIntervalSince(startTime)
}

Fix: Only call Date() for running timers:

public var duration: TimeInterval {
    guard let end = endTime else {
        return Date().timeIntervalSince(startTime)
    }
    return end.timeIntervalSince(startTime)
}

1.2 Static DurationFormatter in TodaySectionView

Location: TodaySectionView.swift:44

Problem: Creates new DurationFormatter() instance on every format call.

Fix: Use static instance like HistorySection does.

1.3 Cache sorted tags in TagFilterView

Location: TagFilterView.swift:11-23

Problem: Sorts tags with localizedCaseInsensitiveCompare on every render.

Fix: Move to @State with onChange(of: tags) trigger.


Phase 2: Smart Refresh

Replace blanket reloads with targeted updates.

2.1 File modification date checking

Location: ContentView.swift:95-102, MinutaApp.swift:264-280

Problem: Full reload every 60 seconds regardless of changes.

Fix:

  • Check file modification dates in background
  • Only reload records whose files actually changed
  • Use FileManager.attributesOfItem(atPath:)[.modificationDate]

Implementation:

// Track last known modification times
private var lastModificationDates: [String: Date] = [:]

func checkForChanges() async -> [String] {
    // Return list of paths with newer modification dates
}

func refreshChangedRecords(_ paths: [String]) async {
    // Reload only specific records, update cache incrementally
}

2.2 Targeted updates after image operations

Location: RecordEditorViews.swift:446-458

Problem: loadData() called after every image add/delete.

Fix: Update only the affected record in AppState without full reload.

2.3 Skip unchanged tag rebuilds

Location: MinutaApp.swift:259-262

Problem: rebuildTagsByID() called even when tags unchanged.

Fix: Compare tag count/IDs before rebuilding, or track dirty flag.


Phase 3: Smarter Caching

Memoize expensive computations.

3.1 Cache filtered records with dependency tracking

Location: HistorySection.swift:102-119

Problem: Double filter pass on every update.

Fix:

  • Store filtered result in @State
  • Update via onChange for records, selectedFilters, showUntagged

3.2 Cache total durations

Location: TodaySectionView.swift:42-45, HistorySection.swift:157-160

Problem: reduce() recalculated on every format call.

Fix:

  • Compute once when records change
  • Store in @State variable

3.3 Maintain sorted cache in storage layer

Location: FileStorageService.swift:224-232

Problem: Sorts entire array on every loadAllRecords() call.

Fix:

  • Keep cache sorted by insertion
  • Binary search insert for new records
  • Return cache directly without re-sorting

Phase 4: Structural Improvements

Fix SwiftUI layout issues that prevent proper virtualization.

4.1 Replace Grid with List rows in HistorySection

Location: HistorySection.swift:26

Problem: Grid creates all rows upfront - not lazy. With 750 records, this creates 750+ views immediately.

Fix: Use ForEach directly in the Section - List handles virtualization natively.

Before (all rows created at once):

Section("History") {
    Grid {
        ForEach(records) { record in
            GridRow { ... }
        }
    }
}

After (List virtualizes rows):

Section("History") {
    ForEach(records) { record in
        HStack { ... }
    }
}

Result: Launch 44% faster (2.4s -> 1.35s), Scroll 37% faster (11s -> 7s)


Execution Checklist

Phase 0

  • Generate 500-1000 record fixture
  • Create PerformanceTests.swift
  • Run baseline benchmark
  • Document baseline metrics

Phase 1

  • Fix duration property
  • Fix DurationFormatter instantiation
  • Cache sorted tags
  • Run benchmark, record improvement

Phase 2

  • Remove 60-second auto-refresh (replaced with event-driven updates)
  • Fix image operation reloads (use updateRecordInState)
  • Add tag rebuild skip logic
  • Run benchmark, record improvement

Phase 3

  • Cache filtered records (HistorySection)
  • Cache total durations (TodaySectionView, HistorySection)
  • Optimize storage layer sorting (maintain sorted cache)
  • Run benchmark, record improvement

Phase 4

  • Replace Grid with ForEach in HistorySection for List virtualization
  • Run benchmark, record improvement

Phase 5: Advanced Optimizations

Selective loading for real-world performance.

5.4 Date-Range Loading from Disk (IMPLEMENTED)

Problem: loadAllRecords() loads everything, then filters by date.

Solution: Only enumerate directories in the selected date range.

Location: FileStorageService.swift

Implementation:

  • Added loadRecordsFromDisk(from:to:) that only reads year/month folders in range
  • If cache is populated, filters from cache (fast path)
  • Otherwise, reads only relevant directories from disk

Impact: For “Last 7 days” view, loads only 1-2 month directories instead of all 6 months.

Benchmark result: Launch 1.23s, Scroll 8.2s (similar to Phase 4 - improvement shows in real-world use, not synthetic benchmark where cache is already warm).

5.1 Pagination with Infinite Scroll (REVERTED for List)

Problem: Pagination with onAppear triggers state updates during scroll.

Finding: Adding pagination made scroll performance WORSE (16s vs 8s) because each page load causes SwiftUI view hierarchy to rebuild. For 750 records with List virtualization, pagination adds overhead without benefit.

Conclusion: List virtualization (Phase 4) is sufficient. Pagination not needed unless records exceed ~2000+.

Future Optimizations (if needed)

  • 5.2 Pre-formatted Record Cache: Cache formatted strings per record
  • 5.3 Simplified Row View: Use AttributedString or Canvas
  • 5.5 Background Index: Lightweight index file for metadata

Phase 6: Nested Grid with Progressive Loading

After the nested Grid refactor (sticky labels, date picker redesign), performance regressed significantly:

  • Launch: 1.23s -> 7.4s
  • Scroll: 8.2s -> 23.1s

Root cause: Grid is not lazy - it creates all children upfront. With 750 records in nested Grid structure, this created 750+ views immediately.

6.1 Progressive Loading (IMPLEMENTED)

Location: HistoryGrid.swift

Solution: Limit initial render, load more on scroll.

  • Initial load: 50 records
  • Load more: 50 records per batch
  • Trigger: onAppear on “Load More” row at bottom

Implementation:

@State private var displayedRecordCount: Int = 50

private var visibleMonthGroups: [MonthGroup] {
    let visibleRecords = Array(records.prefix(displayedRecordCount))
    return groupRecordsByMonth(visibleRecords)
}

Result: Launch 2.0s, Scroll 8.8s

Why it works now (vs Phase 5):

  • Phase 5 used List (already lazy) + pagination = overhead without benefit
  • Phase 6 uses Grid (not lazy) + pagination = limits eager view creation

Execution Checklist (Phase 6)

  • 6.1 Progressive loading in HistoryGrid
  • Run benchmark, record results

Related