Skip to content

Commit a52c442

Browse files
author
ComputelessComputer
committed
Cache timeline insights before rendering
Precompute timeline sections off the main thread and render cached insight groups with stable identities. Add a regression test covering cached timeline section construction.
1 parent a38fd22 commit a52c442

2 files changed

Lines changed: 85 additions & 38 deletions

File tree

Sources/OpenbirdApp/TodayView.swift

Lines changed: 59 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -160,24 +160,24 @@ struct TodayView: View {
160160
switch timelineContent {
161161
case .empty:
162162
EmptyView()
163-
case .journal(let document, let refreshedAt, let recentItems, let timelineItems):
163+
case .journal(let document, let refreshedAt, let recentSection, let timelineSection):
164164
switch activeTimelineMode {
165165
case .topic:
166-
journalTimeline(document: document, refreshedAt: refreshedAt, recentItems: recentItems)
166+
journalTimeline(document: document, refreshedAt: refreshedAt, recentSection: recentSection)
167167
case .timeline:
168-
timelineCard(items: timelineItems)
168+
timelineCard(section: timelineSection)
169169
case nil:
170170
EmptyView()
171171
}
172-
case .raw(let items):
173-
timelineCard(items: items)
172+
case .raw(let section):
173+
timelineCard(section: section)
174174
}
175175
}
176176

177177
private func journalTimeline(
178178
document: JournalMarkdownDocument,
179179
refreshedAt: Date,
180-
recentItems: [TimelineItem]
180+
recentSection: TimelineSection
181181
) -> some View {
182182
VStack(alignment: .leading, spacing: 20) {
183183
VStack(alignment: .leading, spacing: 0) {
@@ -218,28 +218,26 @@ struct TodayView: View {
218218
.frame(maxWidth: .infinity, alignment: .leading)
219219
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 24))
220220

221-
if recentItems.isEmpty == false {
221+
if recentSection.isEmpty == false {
222222
VStack(alignment: .leading, spacing: 12) {
223223
Text("Recent Activity")
224224
.font(.headline)
225225
Text("Summary last refreshed at \(OpenbirdDateFormatting.timeString(for: refreshedAt)). Newer captured activity is shown below.")
226226
.font(.subheadline)
227227
.foregroundStyle(.secondary)
228-
timelineCard(items: recentItems)
228+
timelineCard(section: recentSection)
229229
}
230230
}
231231
}
232232
}
233233

234-
private func timelineCard(items: [TimelineItem]) -> some View {
235-
let insights = TimelineInsightBuilder.build(from: items)
236-
234+
private func timelineCard(section: TimelineSection) -> some View {
237235
return LazyVStack(alignment: .leading, spacing: 0) {
238-
ForEach(insights.indices, id: \.self) { index in
236+
ForEach(Array(section.insights.enumerated()), id: \.element.id) { index, insight in
239237
if index > 0 {
240238
Divider()
241239
}
242-
timelineInsightRow(insights[index])
240+
timelineInsightRow(insight)
243241
.padding(24)
244242
}
245243
}
@@ -428,8 +426,8 @@ struct TodayView: View {
428426
let journal = model.todayJournal
429427
let rawEvents = model.rawEvents
430428
let installedApplications = model.installedApplications
431-
let rawItemsTask = Task.detached(priority: .userInitiated) {
432-
Self.buildTimelineItems(
429+
let rawSectionTask = Task.detached(priority: .userInitiated) {
430+
Self.buildTimelineSection(
433431
rawEvents: rawEvents,
434432
installedApplications: installedApplications
435433
)
@@ -443,13 +441,13 @@ struct TodayView: View {
443441
}
444442

445443
guard let journal else {
446-
let rawItems = await rawItemsTask.value
444+
let rawSection = await rawSectionTask.value
447445

448446
guard Task.isCancelled == false else {
449447
return
450448
}
451449

452-
timelineContent = rawItems.isEmpty ? .empty : .raw(rawItems)
450+
timelineContent = rawSection.isEmpty ? .empty : .raw(rawSection)
453451
return
454452
}
455453

@@ -465,26 +463,29 @@ struct TodayView: View {
465463
return
466464
}
467465

468-
let rawItems = await rawItemsTask.value
466+
let rawSection = await rawSectionTask.value
469467

470468
guard Task.isCancelled == false else {
471469
return
472470
}
473471

474472
guard parsedJournal.hasSummaryContent else {
475-
timelineContent = rawItems.isEmpty ? .empty : .raw(rawItems)
473+
timelineContent = rawSection.isEmpty ? .empty : .raw(rawSection)
476474
return
477475
}
478476

479-
let recentItems: [TimelineItem]
477+
let recentSection: TimelineSection
480478
if parsedJournal.hasNewerActivity {
481479
timelinePreparationStatus = .buildingRecentActivity
482-
recentItems = Self.recentTimelineItems(
483-
from: rawItems,
480+
let recentItems = Self.recentTimelineItems(
481+
from: rawSection.items,
484482
matching: Set(parsedJournal.uncompiledRawEvents.map(\.id))
485483
)
484+
recentSection = await Task.detached(priority: .userInitiated) {
485+
TimelineSection.build(from: recentItems)
486+
}.value
486487
} else {
487-
recentItems = []
488+
recentSection = .empty
488489
}
489490

490491
guard Task.isCancelled == false else {
@@ -495,8 +496,8 @@ struct TodayView: View {
495496
timelineContent = .journal(
496497
document: parsedJournal.document,
497498
refreshedAt: journal.updatedAt,
498-
recentItems: recentItems,
499-
timelineItems: rawItems
499+
recentSection: recentSection,
500+
timelineSection: rawSection
500501
)
501502
}
502503

@@ -517,16 +518,16 @@ struct TodayView: View {
517518
)
518519
}
519520

520-
nonisolated private static func buildTimelineItems(
521+
nonisolated private static func buildTimelineSection(
521522
rawEvents: [ActivityEvent],
522523
installedApplications: [InstalledApplication]
523-
) -> [TimelineItem] {
524+
) -> TimelineSection {
524525
let groupedRawEvents = ActivityEvidencePreprocessor.groupedMeaningfulEvents(from: rawEvents)
525526
let applicationsByBundleID = Dictionary(uniqueKeysWithValues: installedApplications.map {
526527
($0.bundleID.lowercased(), $0)
527528
})
528529

529-
return groupedRawEvents
530+
let items = groupedRawEvents
530531
.filter { $0.isExcluded == false }
531532
.map { event in
532533
let bundlePath = applicationsByBundleID[event.bundleId.lowercased()]?.bundlePath
@@ -552,6 +553,8 @@ struct TodayView: View {
552553
appName: event.appName
553554
)
554555
}
556+
557+
return TimelineSection.build(from: items)
555558
}
556559

557560
nonisolated private static func recentTimelineItems(
@@ -568,19 +571,37 @@ struct TodayView: View {
568571
}
569572
}
570573

574+
struct TimelineSection: Sendable {
575+
let items: [TimelineItem]
576+
let insights: [TimelineInsightGroup]
577+
578+
static let empty = TimelineSection(items: [], insights: [])
579+
580+
var isEmpty: Bool {
581+
items.isEmpty
582+
}
583+
584+
static func build(from items: [TimelineItem]) -> TimelineSection {
585+
TimelineSection(
586+
items: items,
587+
insights: TimelineInsightBuilder.build(from: items)
588+
)
589+
}
590+
}
591+
571592
private enum TimelineContent: Sendable {
572593
case empty
573-
case journal(document: JournalMarkdownDocument, refreshedAt: Date, recentItems: [TimelineItem], timelineItems: [TimelineItem])
574-
case raw([TimelineItem])
594+
case journal(document: JournalMarkdownDocument, refreshedAt: Date, recentSection: TimelineSection, timelineSection: TimelineSection)
595+
case raw(TimelineSection)
575596

576597
var isEmpty: Bool {
577598
switch self {
578599
case .empty:
579600
return true
580-
case .journal(let document, _, _, let timelineItems):
581-
return (document.leadingBlocks.isEmpty && document.sections.isEmpty) && timelineItems.isEmpty
582-
case .raw(let items):
583-
return items.isEmpty
601+
case .journal(let document, _, _, let timelineSection):
602+
return (document.leadingBlocks.isEmpty && document.sections.isEmpty) && timelineSection.isEmpty
603+
case .raw(let section):
604+
return section.isEmpty
584605
}
585606
}
586607

@@ -596,10 +617,10 @@ private enum TimelineContent: Sendable {
596617
switch self {
597618
case .empty:
598619
return false
599-
case .journal(_, _, _, let timelineItems):
600-
return timelineItems.isEmpty == false
601-
case .raw(let items):
602-
return items.isEmpty == false
620+
case .journal(_, _, _, let timelineSection):
621+
return timelineSection.isEmpty == false
622+
case .raw(let section):
623+
return section.isEmpty == false
603624
}
604625
}
605626

Tests/OpenbirdAppTests/TimelineInsightBuilderTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,32 @@ import Testing
33
@testable import OpenbirdApp
44

55
struct TimelineInsightBuilderTests {
6+
@Test func buildsTimelineSectionWithCachedInsights() {
7+
let base = Self.makeDate(hour: 8, minute: 44)
8+
let items = [
9+
Self.makeItem(
10+
startedAt: base,
11+
endedAt: base.addingTimeInterval(60),
12+
appName: "KakaoTalk",
13+
title: "황보민경",
14+
bullets: ["Asked whether the medicine was helping"]
15+
),
16+
Self.makeItem(
17+
startedAt: base.addingTimeInterval(2 * 60),
18+
endedAt: base.addingTimeInterval(3 * 60),
19+
appName: "WhatsApp",
20+
title: "Demo thread",
21+
bullets: ["Replied with the latest build"]
22+
),
23+
]
24+
25+
let section = TimelineSection.build(from: items)
26+
27+
#expect(section.items.count == 2)
28+
#expect(section.insights.count == 1)
29+
#expect(section.insights[0].kind == .communication)
30+
}
31+
632
@Test func mergesAdjacentCommunicationActivityIntoOneInsight() {
733
let base = Self.makeDate(hour: 8, minute: 44)
834
let items = [

0 commit comments

Comments
 (0)