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
- Location:
Create performance UI test
- Location:
Minuta/UITests/Flows/PerformanceTests.swift - Measures: app launch, scroll, filter toggle, export generation
- Outputs timing to console
- Location:
Establish baseline metrics
- Record times before any optimization
- Run on consistent hardware (simulator or device)
Metrics to Track
| Operation | Target | Baseline | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 | Phase 6 |
|---|---|---|---|---|---|---|---|---|
| App launch to interactive | <1s | 4.6s | 4.0s | 3.7s | 2.4s | 1.35s | 1.23s | 2.0s |
| Scroll (6 swipes) | smooth | 36.2s | 18.3s | ~19s | 11.0s | 7.0s | 8.2s | 8.8s |
| Toggle tag filter | <100ms | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
| Generate PDF report | <2s | TBD | TBD | TBD | TBD | TBD | TBD | TBD |
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
onChangefor 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
@Statevariable
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:
onAppearon “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
- 802-scroll-profiling - Deep scroll performance analysis
- 910-backlog - Performance items in backlog
- 913-repository-review - Code quality review