diff --git a/.gitignore b/.gitignore index 81c3726..db574fe 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ dist/ # Xcode user state *.xcuserdatad *.xcuserstate + +# Local reports, audits, and scratch work +/.local/ diff --git a/OpenASO/App/RootSidebarView.swift b/OpenASO/App/RootSidebarView.swift index 5b27dff..7a5fb43 100644 --- a/OpenASO/App/RootSidebarView.swift +++ b/OpenASO/App/RootSidebarView.swift @@ -43,7 +43,9 @@ struct RootSidebarView: View { } private var keywordTrackCountsByAppStoreID: [Int64: Int] { - Dictionary(grouping: trackedKeywords, by: \.appStoreID).mapValues(\.count) + trackedKeywords.reduce(into: [:]) { counts, track in + counts[track.appStoreID, default: 0] += 1 + } } var body: some View { @@ -158,7 +160,8 @@ struct RootSidebarView: View { } private var appList: some View { - Group { + let keywordCounts = keywordTrackCountsByAppStoreID + return Group { if trackedApps.isEmpty && appFolders.isEmpty { ContentUnavailableView( "No Apps Yet", @@ -178,7 +181,7 @@ struct RootSidebarView: View { ) ) { ForEach(sortedApps(in: folder)) { trackedApp in - appRow(for: trackedApp) + appRow(for: trackedApp, keywordTrackCount: keywordCounts[trackedApp.appStoreID, default: 0]) } } label: { folderLabel(for: folder) @@ -200,7 +203,7 @@ struct RootSidebarView: View { } ForEach(unfiledApps) { trackedApp in - appRow(for: trackedApp) + appRow(for: trackedApp, keywordTrackCount: keywordCounts[trackedApp.appStoreID, default: 0]) } } .listStyle(.sidebar) @@ -323,10 +326,9 @@ struct RootSidebarView: View { } } - private func appRow(for trackedApp: TrackedApp) -> some View { + private func appRow(for trackedApp: TrackedApp, keywordTrackCount: Int) -> some View { let appStoreID = trackedApp.appStoreID let draggableID = String(appStoreID) - let keywordTrackCount = keywordTrackCountsByAppStoreID[appStoreID, default: 0] return HStack(spacing: 12) { AppIconView( diff --git a/OpenASO/App/RootView.swift b/OpenASO/App/RootView.swift index c0608aa..a658d57 100644 --- a/OpenASO/App/RootView.swift +++ b/OpenASO/App/RootView.swift @@ -11,7 +11,7 @@ struct RootView: View { var body: some View { NavigationSplitView { - RootSidebarView(selectedApp: $selectedApp) + RootSidebarView(selectedApp: appSelection) .navigationSplitViewColumnWidth(min: 280, ideal: 320) } detail: { RootDetailView(selectedApp: selectedApp) @@ -59,6 +59,17 @@ struct RootView: View { } } + private var appSelection: Binding { + Binding( + get: { selectedApp }, + set: { app in + var transaction = Transaction(animation: nil) + transaction.disablesAnimations = true + withTransaction(transaction) { selectedApp = app } + } + ) + } + private static var isRunningUnderTests: Bool { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil } diff --git a/OpenASO/Features/AppDetail/AppDetailView.swift b/OpenASO/Features/AppDetail/AppDetailView.swift index 18c46a3..5b1b8cd 100644 --- a/OpenASO/Features/AppDetail/AppDetailView.swift +++ b/OpenASO/Features/AppDetail/AppDetailView.swift @@ -66,20 +66,23 @@ struct AppDetailView: View { refreshToken: ratingsRefreshToken ) } else { - AppKeywordsView( - trackedApp: trackedApp, - searchText: searchText, - selectedStorefrontFilter: selectedStorefrontFilter, - selectedDateRange: keywordWorkspaceState.selectedDateRange, - selectedPlatformFilter: keywordWorkspaceState.selectedPlatformFilter, - popularityFilterRange: keywordWorkspaceState.popularityFilterRange, - difficultyFilterRange: keywordWorkspaceState.difficultyFilterRange, - positionFilterRange: keywordWorkspaceState.positionFilterRange, - changeFilterRange: keywordWorkspaceState.changeFilterRange, - showsOnlyChangedKeywords: keywordWorkspaceState.showsOnlyChangedKeywords, - refreshToken: keywordRefreshToken, - reportError: setErrorMessage - ) + KeywordWorkspaceTabs(selectedApp: trackedApp, trackedApps: trackedApps) { app, isActive in + AppKeywordsView( + trackedApp: app, + isActive: isActive, + searchText: searchText, + selectedStorefrontFilter: selectedStorefrontFilter, + selectedDateRange: keywordWorkspaceState.selectedDateRange, + selectedPlatformFilter: keywordWorkspaceState.selectedPlatformFilter, + popularityFilterRange: keywordWorkspaceState.popularityFilterRange, + difficultyFilterRange: keywordWorkspaceState.difficultyFilterRange, + positionFilterRange: keywordWorkspaceState.positionFilterRange, + changeFilterRange: keywordWorkspaceState.changeFilterRange, + showsOnlyChangedKeywords: keywordWorkspaceState.showsOnlyChangedKeywords, + refreshToken: keywordRefreshToken, + reportError: setErrorMessage + ) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift b/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift index 6d548b9..64a8888 100644 --- a/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift +++ b/OpenASO/Features/AppDetail/Keywords/AppKeywordsView.swift @@ -8,6 +8,7 @@ struct AppKeywordsView: View { @Query private var tracks: [TrackedAppKeyword] let trackedApp: TrackedApp + let isActive: Bool let searchText: String let selectedStorefrontFilter: StorefrontFilter let selectedDateRange: TrendDateRange @@ -24,6 +25,7 @@ struct AppKeywordsView: View { init( trackedApp: TrackedApp, + isActive: Bool = true, searchText: String, selectedStorefrontFilter: StorefrontFilter, selectedDateRange: TrendDateRange, @@ -37,6 +39,7 @@ struct AppKeywordsView: View { reportError: @escaping (String) -> Void ) { self.trackedApp = trackedApp + self.isActive = isActive self.searchText = searchText self.selectedStorefrontFilter = selectedStorefrontFilter self.selectedDateRange = selectedDateRange @@ -86,7 +89,8 @@ struct AppKeywordsView: View { dateRangeID: selectedDateRange.id, tracks: tracks.map { track in KeywordWorkspaceProjection.MaterializationID.TrackIdentity( - identityKey: track.identityKey + identityKey: track.identityKey, + notes: track.notes ) } ) @@ -146,6 +150,7 @@ struct AppKeywordsView: View { let storefronts = storefrontDefinitions KeywordWorkspaceContent( workspaceModel: workspaceModel, + isActive: isActive, targetMaterializationID: targetMaterializationID, filters: filters, hasTrackedKeywords: !queriedTracks.isEmpty, @@ -156,7 +161,8 @@ struct AppKeywordsView: View { reportError: reportError ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .task(id: targetMaterializationID) { + .task(id: isActive ? targetMaterializationID : nil) { + guard isActive else { return } await materializeKeywordRows( for: targetMaterializationID, tracks: queriedTracks @@ -168,6 +174,10 @@ struct AppKeywordsView: View { for targetMaterializationID: KeywordWorkspaceProjection.MaterializationID, tracks: [TrackedAppKeyword] ) async { + if workspaceModel.reuseMaterialization(for: targetMaterializationID, filters: filters) { + return + } + let platformTracks = tracks.filter { selectedPlatformFilter.matches($0.platform) } let storefrontLookup = Dictionary( uniqueKeysWithValues: storefrontDefinitions.map { ($0.code, $0) } @@ -217,6 +227,7 @@ private struct KeywordWorkspaceContent: View { @Environment(AppServices.self) private var services let workspaceModel: KeywordWorkspaceModel + let isActive: Bool let targetMaterializationID: KeywordWorkspaceProjection.MaterializationID let filters: KeywordWorkspaceProjection.Filters let hasTrackedKeywords: Bool @@ -263,14 +274,17 @@ private struct KeywordWorkspaceContent: View { } } .background { - KeywordDataUpdateListener( - workspaceModel: workspaceModel, - appStoreID: trackedAppStoreID, - dateRange: selectedDateRange, - reportError: reportError - ) + if isActive { + KeywordDataUpdateListener( + workspaceModel: workspaceModel, + appStoreID: trackedAppStoreID, + dateRange: selectedDateRange, + reportError: reportError + ) + } } - .task(id: filterID) { + .task(id: isActive ? filterID : nil) { + guard isActive else { return } await workspaceModel.updateFilter(id: filterID) { rows, filters in try await KeywordWorkspaceProjection.debouncedRows( rows, @@ -534,3 +548,49 @@ private extension Array { } } } + +// Keep the current app and two recent apps mounted so sidebar navigation does +// not reconstruct hundreds of table cells. Evicted views release their state. +private struct KeywordWorkspacePage: View { + let isActive: Bool + @ViewBuilder let content: Content + + var body: some View { + content + // Opaque pages stay mounted behind the active one. Opacity zero + // lets SwiftUI discard their native table views. + .background(.background) + .allowsHitTesting(isActive) + .accessibilityHidden(!isActive) + .zIndex(isActive ? 1 : 0) + } +} + +struct KeywordWorkspaceTabs: View { + let selectedApp: TrackedApp + let trackedApps: [TrackedApp] + @ViewBuilder let content: (TrackedApp, Bool) -> Content + + @State private var recentAppIDs: [Int64] = [] + + var body: some View { + let selectedID = selectedApp.appStoreID + let recentApps = recentAppIDs + .filter { $0 != selectedID } + .compactMap { id in trackedApps.first { $0.appStoreID == id } } + .prefix(2) + let pages = ([selectedApp] + recentApps).sorted { $0.appStoreID < $1.appStoreID } + + ZStack { + ForEach(pages) { app in + let isActive = app.appStoreID == selectedID + KeywordWorkspacePage(isActive: isActive) { + content(app, isActive) + } + } + } + .onChange(of: selectedID, initial: true) { + recentAppIDs = [selectedID] + recentAppIDs.filter { $0 != selectedID }.prefix(2) + } + } +} diff --git a/OpenASO/Features/AppDetail/Keywords/Insights/KeywordTrendSparklineView.swift b/OpenASO/Features/AppDetail/Keywords/Insights/KeywordTrendSparklineView.swift index 8751e0d..2e36cef 100644 --- a/OpenASO/Features/AppDetail/Keywords/Insights/KeywordTrendSparklineView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Insights/KeywordTrendSparklineView.swift @@ -1,4 +1,3 @@ -import Charts import SwiftUI struct KeywordTrendSparklineView: View { @@ -6,70 +5,78 @@ struct KeywordTrendSparklineView: View { let color: Color var body: some View { - let chartPoints = chartPoints - let areaGradient = LinearGradient( - gradient: Gradient( - colors: [ - color.opacity(isFlatTrend ? 0.08 : 0.28), - color.opacity(isFlatTrend ? 0.02 : 0.06), - color.opacity(0.0) - ] - ), - startPoint: .top, - endPoint: .bottom - ) - - Chart { - ForEach(chartPoints) { point in - AreaMark( - x: .value("Refresh", point.index), - yStart: .value("Baseline", rankScaleUpperBound), - yEnd: .value("Rank", point.rank) + Canvas { context, size in + guard points.count > 1, size.width > 0, size.height > 0 else { return } + + let lowerBound = CGFloat(rankScaleLowerBound) + let span = CGFloat(rankScaleUpperBound) - lowerBound + let plotPoints = points.enumerated().map { index, rank in + CGPoint( + x: CGFloat(index) / CGFloat(points.count - 1) * size.width, + y: (CGFloat(rank) - lowerBound) / span * size.height ) - .interpolationMethod(.cardinal) - .foregroundStyle(areaGradient) } - - ForEach(chartPoints) { point in - LineMark( - x: .value("Refresh", point.index), - y: .value("Rank", point.rank) + let line = Self.cardinalPath(through: plotPoints) + var area = line + area.addLine(to: CGPoint(x: size.width, y: size.height)) + area.addLine(to: CGPoint(x: 0, y: size.height)) + area.closeSubpath() + + context.fill( + area, + with: .linearGradient( + Gradient(colors: [ + color.opacity(isFlatTrend ? 0.08 : 0.28), + color.opacity(isFlatTrend ? 0.02 : 0.06), + color.opacity(0) + ]), + startPoint: .zero, + endPoint: CGPoint(x: 0, y: size.height) ) - .interpolationMethod(.cardinal) - .lineStyle( - StrokeStyle( - lineWidth: isFlatTrend ? 1.5 : 3, - lineCap: .round, - lineJoin: .round - ) + ) + context.stroke( + line, + with: .color(color.opacity(isFlatTrend ? 0.44 : 1)), + style: StrokeStyle( + lineWidth: isFlatTrend ? 1.5 : 3, + lineCap: .round, + lineJoin: .round ) - .foregroundStyle(color.opacity(isFlatTrend ? 0.44 : 1)) - } - } - .chartXScale(domain: 0 ... max(chartPoints.count - 1, 1)) - .chartYScale(domain: [rankScaleUpperBound, rankScaleLowerBound]) - .chartLegend(.hidden) - .chartXAxis(.hidden) - .chartYAxis(.hidden) - .chartPlotStyle { plotArea in - plotArea - .background(.clear) + ) } .accessibilityElement(children: .ignore) .accessibilityLabel("Rank trend") } + private static func cardinalPath(through points: [CGPoint]) -> Path { + var path = Path() + guard let first = points.first else { return path } + path.move(to: first) + for index in 1.. Bool { + guard loadedMaterializationID == id, + publication.completedMaterializationID == id + else { return false } + + materializationRequestGeneration &+= 1 + pendingMaterializationRequestGeneration = nil + filterRequestGeneration &+= 1 + desiredFilters = filters + publish( + materializedRows: publication.materializedRows, + rows: KeywordWorkspaceProjection.filteredRows(publication.materializedRows, filters: filters), + completedMaterializationID: id, + filters: filters + ) + return true + } + func prime( id: KeywordWorkspaceProjection.MaterializationID, rows: [KeywordWorkspaceRow], @@ -90,6 +112,7 @@ final class KeywordWorkspaceModel { guard requestGeneration == materializationRequestGeneration else { return nil } pendingMaterializationRequestGeneration = nil + loadedMaterializationID = id publish( materializedRows: materializedRows, rows: rows, @@ -230,6 +253,14 @@ final class KeywordWorkspaceModel { completedMaterializationID: KeywordWorkspaceProjection.MaterializationID, filters: KeywordWorkspaceProjection.Filters ) { + // A cache hit on navigation can produce exactly the visible snapshot. + // Keep its generation and native table hierarchy intact in that case. + guard publication.completedMaterializationID != completedMaterializationID + || publication.appliedFilterID?.filters != filters + || publication.materializedRows != materializedRows + || publication.rows != rows + else { return } + let nextGeneration = publication.materializationGeneration &+ 1 publication = Publication( materializedRows: materializedRows, diff --git a/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift b/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift index f3b2cfa..b67ec0a 100644 --- a/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift +++ b/OpenASO/Features/AppDetail/Keywords/KeywordWorkspaceProjection.swift @@ -4,6 +4,12 @@ enum KeywordWorkspaceProjection { struct MaterializationID: Hashable, Sendable { struct TrackIdentity: Hashable, Sendable { let identityKey: String + let notes: String + + init(identityKey: String, notes: String = "") { + self.identityKey = identityKey + self.notes = notes + } } let refreshToken: Int diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingPricingViews.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingPricingViews.swift index 966e3a4..eb77513 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingPricingViews.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordRankingPricingViews.swift @@ -422,8 +422,8 @@ struct KeywordPricingComparisonSheet: View { private var comparisonGrid: some View { ScrollView([.horizontal, .vertical]) { - Grid(alignment: .leading, horizontalSpacing: 0, verticalSpacing: 0) { - GridRow { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { Text("Ranked app") .frame(width: 300, alignment: .leading) ForEach(selectedStorefronts, id: \.code) { storefront in @@ -437,44 +437,39 @@ struct KeywordPricingComparisonSheet: View { .padding(.vertical, 9) Divider() - .gridCellColumns(selectedStorefrontCodes.count + 1) ForEach(apps) { app in - GridRow { - comparisonAppCell(app) - .frame(width: 300, alignment: .leading) - - ForEach(selectedStorefronts, id: \.code) { storefront in - KeywordRankingPriceCell( - result: pricingModel.price( - appStoreID: app.id, + VStack(spacing: 0) { + HStack(spacing: 0) { + comparisonAppCell(app) + .frame(width: 300, alignment: .leading) + + ForEach(selectedStorefronts, id: \.code) { storefront in + KeywordRankingPriceCell( + result: pricingModel.price( + appStoreID: app.id, + storefrontCode: storefront.code + ), + isLoading: pricingModel.isLoading, storefrontCode: storefront.code - ), - isLoading: pricingModel.isLoading, - storefrontCode: storefront.code - ) - .frame(width: 128, alignment: .trailing) + ) + .frame(width: 128, alignment: .trailing) + } } - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - app.id == trackedAppStoreID - ? Color.accentColor.opacity(0.09) - : Color.clear - ) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + app.id == trackedAppStoreID + ? Color.accentColor.opacity(0.09) + : Color.clear + ) - Divider() - .gridCellColumns(selectedStorefrontCodes.count + 1) + Divider() - if expandedAppStoreID == app.id { - GridRow { + if expandedAppStoreID == app.id { visibleProductDetails(for: app) - .gridCellColumns(selectedStorefrontCodes.count + 1) + Divider() } - - Divider() - .gridCellColumns(selectedStorefrontCodes.count + 1) } } } @@ -549,10 +544,12 @@ struct KeywordPricingComparisonSheet: View { .frame(width: 300, alignment: .leading) ForEach(selectedStorefronts, id: \.code) { storefront in - visibleProductStorefrontColumn( - app: app, - storefront: storefront - ) + VStack(alignment: .trailing, spacing: 0) { + visibleProductStorefrontColumn( + app: app, + storefront: storefront + ) + } .frame(width: 128, alignment: .trailing) } } diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift index 047e79d..9f2ff2b 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableCells.swift @@ -271,6 +271,7 @@ struct AppsInRankingCell: View { AppIconImageView( appStoreID: result.appStoreID, storefrontCode: row.track.storefront, + preferredIconURLString: result.iconURLString, size: 24, cornerRadius: 6, modelContext: modelContext, @@ -332,7 +333,8 @@ struct KeywordNotesCell: View { .lineLimit(2) } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, minHeight: 22, alignment: .leading) + .contentShape(.rect) .padding(.horizontal, 6) .padding(.vertical, 4) .background { diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift index 2c3a2c3..5a96c54 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableSupport.swift @@ -705,6 +705,7 @@ struct KeywordRankingAppSummary: Identifiable, Equatable, Sendable { let name: String let subtitle: String? let sellerName: String? + let iconURLString: String? init( position: Int, @@ -712,7 +713,8 @@ struct KeywordRankingAppSummary: Identifiable, Equatable, Sendable { bundleID: String?, name: String, subtitle: String?, - sellerName: String? + sellerName: String?, + iconURLString: String? = nil ) { self.id = appStoreID self.position = position @@ -721,6 +723,7 @@ struct KeywordRankingAppSummary: Identifiable, Equatable, Sendable { self.name = name self.subtitle = subtitle self.sellerName = sellerName + self.iconURLString = iconURLString } init(_ result: TrackedKeywordRankedResult) { @@ -735,13 +738,18 @@ struct KeywordRankingAppSummary: Identifiable, Equatable, Sendable { } init(_ ranking: RankingFact) { + self.init(ranking, iconURLString: nil) + } + + init(_ ranking: RankingFact, iconURLString: String?) { self.init( position: ranking.position, appStoreID: ranking.appStoreID, bundleID: ranking.bundleID, name: ranking.name, subtitle: ranking.subtitle, - sellerName: ranking.sellerName + sellerName: ranking.sellerName, + iconURLString: iconURLString ) } } diff --git a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift index e36bee1..f892f49 100644 --- a/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift +++ b/OpenASO/Features/AppDetail/Keywords/Table/KeywordTableView.swift @@ -104,10 +104,6 @@ struct KeywordTableView: View, Equatable { setChartSelection: setChartSelection, openAppleAdsSettings: openAppleAdsSettings ) - // Deliberate user sorts rebuild the virtualized table once. - // Refresh deltas keep this identity stable while the presentation - // model maintains the selected order as values change. - .id(presentationModel.tableIdentity) .contextMenu(forSelectionType: String.self) { selectedIDs in let contextRows = selectedRows(for: selectedIDs) if contextRows.isEmpty { @@ -588,7 +584,6 @@ final class KeywordTablePresentationModel { private(set) var rows: [KeywordTablePresentationRow] = [] private(set) var rowIDs: [String] = [] private(set) var rankingChartSeries: [KeywordRankingChartSeries] = [] - private(set) var tableIdentity = 0 @ObservationIgnored private(set) var sortCount = 0 func update( @@ -615,7 +610,6 @@ final class KeywordTablePresentationModel { if forceSort || membershipChanged || rows.isEmpty { rows = incomingRows.sorted(using: sortOrder) sortCount &+= 1 - tableIdentity &+= 1 } else { // Keep the active table sort correct as refresh deltas change values, // but retain the table's identity so SwiftUI only moves affected rows. diff --git a/OpenASO/Services/Persistence/BackgroundModelStore.swift b/OpenASO/Services/Persistence/BackgroundModelStore.swift index 28eb614..fe36dc3 100644 --- a/OpenASO/Services/Persistence/BackgroundModelStore.swift +++ b/OpenASO/Services/Persistence/BackgroundModelStore.swift @@ -1,12 +1,12 @@ +import Dispatch import SwiftData - @ModelActor actor BackgroundModelStore { private var worker: BackgroundModelContextWorker? func prepare() async { - _ = await contextWorker() + await contextWorker().prepare() } func read( @@ -34,26 +34,47 @@ actor BackgroundModelStore { try await contextWorker().fetchCount(descriptor) } - private func contextWorker() async -> BackgroundModelContextWorker { + private func contextWorker() -> BackgroundModelContextWorker { if let worker { return worker } - let modelContainer = modelContainer - let worker = await Task.detached(priority: .utility) { - BackgroundModelContextWorker(modelContainer: modelContainer) - }.value + let worker = BackgroundModelContextWorker(modelContainer: modelContainer) self.worker = worker return worker } } -@ModelActor private actor BackgroundModelContextWorker { + // SwiftData's default executor can run synchronous jobs on the caller's + // thread. Own a serial queue so database I/O cannot borrow the UI thread. + private nonisolated let executor = DispatchSerialQueue( + label: "com.thirdtech.openaso.persistence", + qos: .userInitiated, + target: .global(qos: .userInitiated) + ) + private let modelContainer: ModelContainer + private var context: ModelContext? + + nonisolated var unownedExecutor: UnownedSerialExecutor { + executor.asUnownedSerialExecutor() + } + + init(modelContainer: ModelContainer) { + self.modelContainer = modelContainer + } + + func prepare() { + _ = prepareModelContext() + } + private func prepareModelContext() -> ModelContext { - modelContext.autosaveEnabled = false - return modelContext + if let context { return context } + let context = ModelContext(modelContainer) + context.autosaveEnabled = false + self.context = context + return context } func read( diff --git a/OpenASO/Services/SearchRanking/KeywordInsightsService.swift b/OpenASO/Services/SearchRanking/KeywordInsightsService.swift index c9d09c6..77acf23 100644 --- a/OpenASO/Services/SearchRanking/KeywordInsightsService.swift +++ b/OpenASO/Services/SearchRanking/KeywordInsightsService.swift @@ -32,6 +32,7 @@ final class KeywordInsightsService { } private struct CrawlRecord: Sendable { + let persistentID: PersistentIdentifier let queryKey: String let observationKey: String let observedAt: Date @@ -231,7 +232,7 @@ final class KeywordInsightsService { in: modelContext ) let topResultsByCrawlKey = try fetchTopResultsByCrawlKey( - crawlKeys: latestCrawlKeys, + observationIDs: latestCrawlsByQueryKey.values.map(\.persistentID), in: modelContext ) let tracksByQueryKey = Dictionary(grouping: tracks, by: \.queryKey) @@ -335,6 +336,7 @@ final class KeywordInsightsService { } return crawls.filter { !$0.isTrackedRecovery }.map { CrawlRecord( + persistentID: $0.persistentModelID, queryKey: $0.queryKey, observationKey: $0.observationKey, observedAt: $0.observedAt, @@ -351,19 +353,23 @@ final class KeywordInsightsService { var records: [CrawlRecord] = [] records.reserveCapacity(queryKeys.count) + let recoveryPrefix = RankingCrawlRecord.trackedRecoveryObservationKeyPrefix for queryKey in queryKeys { let targetQueryKey = queryKey - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { crawl in crawl.queryKey == targetQueryKey + && !crawl.observationKey.starts(with: recoveryPrefix) }, sortBy: [SortDescriptor(\RankingCrawlRecord.observedAt, order: .reverse)] ) - guard let crawl = try modelContext.fetch(descriptor).first(where: { - !$0.isTrackedRecovery - }) else { continue } + // The fallback only needs the latest real observation. Materializing + // every historical crawl made stale, keyword-heavy apps slow to open. + descriptor.fetchLimit = 1 + guard let crawl = try modelContext.fetch(descriptor).first else { continue } records.append( CrawlRecord( + persistentID: crawl.persistentModelID, queryKey: crawl.queryKey, observationKey: crawl.observationKey, observedAt: crawl.observedAt, @@ -419,21 +425,23 @@ final class KeywordInsightsService { ) throws -> [RankingRecord] { let rankings: [RankingFact] if let cutoffDate { - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { ranking in queryKeys.contains(ranking.observation.queryKey) && ranking.appStoreID == appStoreID && ranking.observation.observedAt >= cutoffDate } ) + descriptor.relationshipKeyPathsForPrefetching = [\.observation] rankings = try modelContext.fetch(descriptor) } else { - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { ranking in queryKeys.contains(ranking.observation.queryKey) && ranking.appStoreID == appStoreID } ) + descriptor.relationshipKeyPathsForPrefetching = [\.observation] rankings = try modelContext.fetch(descriptor) } return rankings.map { @@ -453,12 +461,13 @@ final class KeywordInsightsService { Array(crawlKeys), size: 500 ) { - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { ranking in crawlKeyChunk.contains(ranking.observation.observationKey) && ranking.appStoreID == appStoreID } ) + descriptor.relationshipKeyPathsForPrefetching = [\.observation] records.append(contentsOf: try modelContext.fetch(descriptor).map { RankingRecord(crawlKey: $0.crawlKey, position: $0.position) }) @@ -467,32 +476,46 @@ final class KeywordInsightsService { } private nonisolated static func fetchTopResultsByCrawlKey( - crawlKeys: Set, + observationIDs: [PersistentIdentifier], in modelContext: ModelContext ) throws -> [String: [KeywordRankingAppSummary]] { - guard !crawlKeys.isEmpty else { return [:] } + guard !observationIDs.isEmpty else { return [:] } - var resultsByCrawlKey: [String: [KeywordRankingAppSummary]] = [:] - for crawlKeyChunk in chunks( - Array(crawlKeys), + var rankings: [RankingFact] = [] + for observationIDChunk in chunks( + observationIDs, size: 500 ) { - let descriptor = FetchDescriptor( + var descriptor = FetchDescriptor( predicate: #Predicate { ranking in - crawlKeyChunk.contains(ranking.observation.observationKey) + observationIDChunk.contains(ranking.observation.persistentModelID) && ranking.position <= 5 }, sortBy: [ - SortDescriptor(\RankingFact.observation.observedAt, order: .forward), SortDescriptor(\RankingFact.position, order: .forward) ] ) - for ranking in try modelContext.fetch(descriptor) { - resultsByCrawlKey[ranking.crawlKey, default: []].append( - KeywordRankingAppSummary(ranking) - ) + descriptor.relationshipKeyPathsForPrefetching = [\.observation, \.revision] + rankings.append(contentsOf: try modelContext.fetch(descriptor)) + } + // Resolve icon URLs with the workspace, not one main-thread catalog + // fetch per visible competitor. Keep only scalar values across actors. + var iconURLsByAppID: [Int64: String] = [:] + for appIDs in chunks(Array(Set(rankings.map(\.appStoreID))), size: 500) { + var descriptor = FetchDescriptor(predicate: #Predicate { + appIDs.contains($0.appStoreID) + }) + descriptor.propertiesToFetch = [\.appStoreID, \.iconURLString] + for app in try modelContext.fetch(descriptor) { + iconURLsByAppID[app.appStoreID] = app.iconURLString } } + var resultsByCrawlKey: [String: [KeywordRankingAppSummary]] = [:] + for ranking in rankings { + resultsByCrawlKey[ranking.crawlKey, default: []].append( + KeywordRankingAppSummary(ranking, iconURLString: iconURLsByAppID[ranking.appStoreID]) + ) + } return resultsByCrawlKey } diff --git a/OpenASOTests/KeywordWorkspaceProjectionTests.swift b/OpenASOTests/KeywordWorkspaceProjectionTests.swift index 32c7e37..4402a91 100644 --- a/OpenASOTests/KeywordWorkspaceProjectionTests.swift +++ b/OpenASOTests/KeywordWorkspaceProjectionTests.swift @@ -60,6 +60,17 @@ struct KeywordWorkspaceProjectionTests { #expect(filtered.map { $0.track.term } == ["Focus Timer"]) } + @Test + func databaseReadsAndWritesStayOffMainThreadForUIRequests() async throws { + let container = try ModelContainerFactory.makeModelContainer(isStoredInMemoryOnly: true) + let store = BackgroundModelStore(modelContainer: container) + await store.prepare() + let readIsMain = try await store.read { _ in Thread.isMainThread } + let writeIsMain = try await store.write { _ in Thread.isMainThread } + #expect(!readIsMain) + #expect(!writeIsMain) + } + @Test func initialMaterializationPublishesLatestFiltersAndLoadingCompletionTogether() async { let model = KeywordWorkspaceModel() @@ -317,6 +328,12 @@ struct KeywordWorkspaceProjectionTests { difficultyScore: 41, source: .appleAdsPopularity ) + let competitor = StoreApp( + appStoreID: 202, bundleID: "com.example.competitor", name: "Competitor", + sellerName: "Example", iconURLString: "https://example.com/competitor.png", + defaultPlatform: .iphone + ) + modelContext.insert(competitor) let rankings = [ makeRankingFact( position: 8, @@ -385,6 +402,8 @@ struct KeywordWorkspaceProjectionTests { #expect(row.latestSnapshot?.rank == 3) #expect(row.trendSnapshots.map(\.rank) == [8, 3]) #expect(row.rankingApps.map(\.position) == [1, 3]) + #expect(row.rankingApps.first?.iconURLString == competitor.iconURLString) + #expect(row.rankingApps.last?.iconURLString == nil) #expect(service.cachedWorkspace(for: materializationID) != nil) } @@ -437,6 +456,27 @@ struct KeywordWorkspaceProjectionTests { modelContext.insert(ranking) try modelContext.save() + let recovery = RankingCrawlRecord( + keyword: query.term, + storefront: query.storefront, + platform: query.platform, + observedAt: oldDate.addingTimeInterval(86_400), + source: .appStoreWeb, + resultCount: 1 + ) + recovery.observationKey = RankingCrawlRecord.makeTrackedRecoveryObservationKey(snapshotKey: "newer-recovery") + modelContext.insert(recovery) + modelContext.insert(makeRankingFact( + position: 1, + appStoreID: appStoreID, + bundleID: trackedApp.bundleID, + name: trackedApp.name, + sellerName: trackedApp.sellerName, + observation: recovery, + in: modelContext + )) + try modelContext.save() + let materializationID = KeywordWorkspaceProjection.MaterializationID( refreshToken: 0, backgroundStoreRevision: 0, @@ -462,6 +502,47 @@ struct KeywordWorkspaceProjectionTests { #expect(row.rankingApps.map(\.position) == [3]) } + @Test + func returningToUnchangedWorkspaceKeepsPublicationButChangedRowsStillPublish() async { + let model = KeywordWorkspaceModel() + let id = materializationID(refreshToken: 0) + let original = makeRow(term: "Focus Timer", appStoreID: 1, currentRank: 7) + let initialError = await model.materialize(id: id, initialFilters: filters()) { [original] } + #expect(initialError == nil) + let revision = model.contentRevision + let generation = model.materializationGeneration + + model.prime(id: id, rows: [original], filters: filters()) + let cachedError = await model.materialize(id: id, initialFilters: filters()) { [original] } + #expect(cachedError == nil) + #expect(model.contentRevision == revision) + #expect(model.materializationGeneration == generation) + + let updated = makeRow(term: "Focus Timer", appStoreID: 1, currentRank: 2) + let updatedError = await model.materialize(id: id, initialFilters: filters()) { [updated] } + #expect(updatedError == nil) + #expect(model.rows.first?.currentRank == 2) + #expect(model.contentRevision == revision + 1) + } + + @Test + func retainedWorkspaceReusesOnlyLoadedCurrentRevisionAndAppliesCurrentFilters() async { + let model = KeywordWorkspaceModel() + let id = materializationID(refreshToken: 0) + let rows = [makeRow(term: "Focus Timer", appStoreID: 1), makeRow(term: "Habit Tracker", appStoreID: 2)] + model.prime(id: id, rows: rows, filters: filters()) + #expect(!model.reuseMaterialization(for: id, filters: filters())) + let error = await model.materialize(id: id, initialFilters: filters()) { rows } + #expect(error == nil) + let revision = model.contentRevision + #expect(model.reuseMaterialization(for: id, filters: filters())) + #expect(model.contentRevision == revision) + #expect(model.reuseMaterialization(for: id, filters: filters(searchText: "habit"))) + #expect(model.rows.map { $0.track.term } == ["Habit Tracker"]) + #expect(!model.reuseMaterialization(for: materializationID(refreshToken: 1), filters: filters())) + #expect(!model.reuseMaterialization(for: materializationID(refreshToken: 0, backgroundStoreRevision: 1), filters: filters())) + } + @Test func canceledSupersededErrorCannotClearFreshPublicationOrReportAnError() async { let model = KeywordWorkspaceModel() @@ -739,7 +820,6 @@ struct KeywordWorkspaceProjectionTests { let initialIDs = model.rowIDs #expect(model.rows.count == 338) #expect(model.sortCount == 1) - #expect(model.tableIdentity == 1) let firstSourceRow = try #require(sourceRows.first) let updatedFirstRow = firstSourceRow.updating( @@ -768,7 +848,6 @@ struct KeywordWorkspaceProjectionTests { #expect(model.rowIDs != initialIDs) #expect(model.sortCount == 26) - #expect(model.tableIdentity == 1) #expect(model.rows.first?.id == updatedFirstRow.id) #expect(model.rows.first?.popularitySortValue == 1_000) @@ -780,7 +859,6 @@ struct KeywordWorkspaceProjectionTests { ) #expect(model.sortCount == 27) - #expect(model.tableIdentity == 2) #expect(model.rows.first?.id == updatedFirstRow.id) }