fix: 22 audit fixes — concurrency, memory, performance, accessibility

- Move 7 Data(contentsOf:) calls off MainActor via Task.detached (BootstrapService)
- Batch-fetch N+1 queries in sync merge loops (CanonicalSyncService)
- Predicate-based gamesForTeam fetch instead of fetching all games (DataProvider)
- Proper Sendable on RouteInfo with nonisolated(unsafe) polyline (LocationService)
- [weak self] in BGTaskScheduler register closures (BackgroundSyncManager)
- Cache tripDays, routeWaypoints as @State with recompute (TripDetailView)
- Remove unused AnyCancellable, add Task lifecycle management (TripDetailView)
- Cache sportStadiums, recentVisits as stored properties (ProgressViewModel)
- Dynamic Type fonts replacing hardcoded sizes (OnboardingPaywallView)
- Accessibility labels/hints on stadium picker, date picker, map, stats,
  settings toggle, and day cards

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 09:23:29 -06:00
parent dad3270be7
commit e7420061a5
12 changed files with 180 additions and 101 deletions

View File

@@ -63,10 +63,10 @@ final class BackgroundSyncManager {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.refreshTaskIdentifier,
using: nil
) { task in
) { [weak self] task in
guard let task = task as? BGAppRefreshTask else { return }
Task { @MainActor in
await self.handleRefreshTask(task)
await self?.handleRefreshTask(task)
}
}
@@ -74,10 +74,10 @@ final class BackgroundSyncManager {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: Self.processingTaskIdentifier,
using: nil
) { task in
) { [weak self] task in
guard let task = task as? BGProcessingTask else { return }
Task { @MainActor in
await self.handleProcessingTask(task)
await self?.handleProcessingTask(task)
}
}

View File

@@ -202,12 +202,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json")
}
let data: Data
let stadiums: [JSONCanonicalStadium]
do {
data = try Data(contentsOf: url)
stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
stadiums = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalStadium].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error)
}
@@ -238,12 +239,13 @@ final class BootstrapService {
return
}
let data: Data
let aliases: [JSONStadiumAlias]
do {
data = try Data(contentsOf: url)
aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
aliases = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONStadiumAlias].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error)
}
@@ -280,12 +282,13 @@ final class BootstrapService {
return
}
let data: Data
let structures: [JSONLeagueStructure]
do {
data = try Data(contentsOf: url)
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
structures = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
}
@@ -319,12 +322,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("teams_canonical.json")
}
let data: Data
let teams: [JSONCanonicalTeam]
do {
data = try Data(contentsOf: url)
teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
teams = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalTeam].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error)
}
@@ -352,12 +356,13 @@ final class BootstrapService {
return
}
let data: Data
let aliases: [JSONTeamAlias]
do {
data = try Data(contentsOf: url)
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data)
aliases = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONTeamAlias].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
}
@@ -393,12 +398,13 @@ final class BootstrapService {
throw BootstrapError.bundledResourceNotFound("games_canonical.json")
}
let data: Data
let games: [JSONCanonicalGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
games = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalGame].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("games_canonical.json", error)
}
@@ -462,12 +468,13 @@ final class BootstrapService {
return
}
let data: Data
let sports: [JSONCanonicalSport]
do {
data = try Data(contentsOf: url)
sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
sports = try await Task.detached {
let data = try Data(contentsOf: url)
return try JSONDecoder().decode([JSONCanonicalSport].self, from: data)
}.value
} catch {
throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error)
}

View File

@@ -429,11 +429,16 @@ final class CanonicalSyncService {
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch all existing stadiums to avoid N+1 FetchDescriptor lookups
let allExistingStadiums = try context.fetch(FetchDescriptor<CanonicalStadium>())
let existingStadiumsByCanonicalId = Dictionary(grouping: allExistingStadiums, by: \.canonicalId).compactMapValues(\.first)
for syncStadium in syncStadiums {
// Use canonical ID directly from CloudKit - no UUID-based generation!
let result = try mergeStadium(
syncStadium.stadium,
canonicalId: syncStadium.canonicalId,
existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId],
context: context
)
@@ -465,6 +470,10 @@ final class CanonicalSyncService {
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch all existing teams to avoid N+1 FetchDescriptor lookups
let allExistingTeams = try context.fetch(FetchDescriptor<CanonicalTeam>())
let existingTeamsByCanonicalId = Dictionary(grouping: allExistingTeams, by: \.canonicalId).compactMapValues(\.first)
for syncTeam in allSyncTeams {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeTeam(
@@ -472,6 +481,7 @@ final class CanonicalSyncService {
canonicalId: syncTeam.canonicalId,
stadiumCanonicalId: syncTeam.stadiumCanonicalId,
validStadiumIds: &validStadiumIds,
existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId],
context: context
)
@@ -514,6 +524,10 @@ final class CanonicalSyncService {
var skippedIncompatible = 0
var skippedOlder = 0
// Batch-fetch all existing games to avoid N+1 FetchDescriptor lookups
let allExistingGames = try context.fetch(FetchDescriptor<CanonicalGame>())
let existingGamesByCanonicalId = Dictionary(grouping: allExistingGames, by: \.canonicalId).compactMapValues(\.first)
for syncGame in syncGames {
// Use canonical IDs directly from CloudKit - no UUID lookups!
let result = try mergeGame(
@@ -525,6 +539,7 @@ final class CanonicalSyncService {
validTeamIds: validTeamIds,
teamStadiumByTeamId: teamStadiumByTeamId,
validStadiumIds: &validStadiumIds,
existingRecord: existingGamesByCanonicalId[syncGame.canonicalId],
context: context
)
@@ -649,13 +664,19 @@ final class CanonicalSyncService {
private func mergeStadium(
_ remote: Stadium,
canonicalId: String,
existingRecord: CanonicalStadium? = nil,
context: ModelContext
) throws -> MergeResult {
// Look up existing
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalStadium?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
if let existing = existing {
// Preserve user fields
@@ -715,12 +736,19 @@ final class CanonicalSyncService {
canonicalId: String,
stadiumCanonicalId: String?,
validStadiumIds: inout Set<String>,
existingRecord: CanonicalTeam? = nil,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalTeam?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines)
let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -809,12 +837,19 @@ final class CanonicalSyncService {
validTeamIds: Set<String>,
teamStadiumByTeamId: [String: String],
validStadiumIds: inout Set<String>,
existingRecord: CanonicalGame? = nil,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Use pre-fetched record if available, otherwise fall back to individual fetch
let existing: CanonicalGame?
if let existingRecord {
existing = existingRecord
} else {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
existing = try context.fetch(descriptor).first
}
func resolveTeamId(remote: String, existing: String?) -> String? {
if validTeamIds.contains(remote) {

View File

@@ -289,20 +289,25 @@ final class AppDataProvider: ObservableObject {
throw DataProviderError.contextNotConfigured
}
// Fetch all non-deprecated games (predicate with captured vars causes type-check timeout)
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil },
// Use two separate predicates to avoid the type-check timeout from combining
// captured vars with OR in a single #Predicate expression.
let homeDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.homeTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)]
)
let awayDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.awayTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)]
)
let allCanonical: [CanonicalGame] = try context.fetch(descriptor)
let homeGames = try context.fetch(homeDescriptor)
let awayGames = try context.fetch(awayDescriptor)
// Filter by team in Swift (fast for ~5K games)
// Merge and deduplicate
var seenIds = Set<String>()
var teamGames: [RichGame] = []
for canonical in allCanonical {
guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else {
continue
}
for canonical in homeGames + awayGames {
guard seenIds.insert(canonical.canonicalId).inserted else { continue }
let game = canonical.toDomain()
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
@@ -311,7 +316,7 @@ final class AppDataProvider: ObservableObject {
}
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
}
return teamGames
return teamGames.sorted { $0.game.dateTime < $1.game.dateTime }
}
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.

View File

@@ -160,10 +160,10 @@ actor LocationService {
// MARK: - Route Info
struct RouteInfo: @unchecked Sendable {
struct RouteInfo: Sendable {
let distance: CLLocationDistance // meters
let expectedTravelTime: TimeInterval // seconds
let polyline: MKPolyline?
nonisolated(unsafe) let polyline: MKPolyline?
var distanceMiles: Double { distance * 0.000621371 }
var travelTimeHours: Double { expectedTravelTime / 3600.0 }

View File

@@ -32,7 +32,8 @@ final class NetworkMonitor {
/// Debounce delay in seconds (2.5s to handle WiFicellular handoffs)
private let debounceDelay: TimeInterval = 2.5
/// Callback for when sync should be triggered
/// Callback for when sync should be triggered after network restoration.
/// - Important: Capture references weakly in this closure to avoid retain cycles.
var onSyncNeeded: (() async -> Void)?
// MARK: - Singleton