Files
Sportstime/docs/plans/2026-01-12-delta-sync-design.md
Trey t ffe5c0b6f7 docs: add delta sync and game browsing design
Addresses issue where Houston Astros only shows 7 games in "By Games" mode.
Documents plan to remove arbitrary date restrictions and implement proper
delta sync using CloudKit modificationDate.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:41:17 -06:00

5.0 KiB

Delta Sync & Game Browsing Design

Date: 2026-01-12 Status: Approved Problem: Houston Astros only shows 7 games in "By Games" mode

Problem Analysis

Root Causes

  1. 90-day browsing window in TripCreationViewModel.loadGamesForBrowsing() arbitrarily limits games shown in "By Games" mode

  2. 6-month CloudKit sync window in CanonicalSyncService.syncGames() only syncs games within 6 months of today

  3. Wrong query field - CloudKit queries filter by game dateTime (when game is scheduled) instead of modificationDate (when record was last modified)

Impact

  • Users cannot plan trips around games outside the 90-day window
  • New schedules added to CloudKit may not sync if games fall outside the window
  • Delta sync doesn't work correctly - modified historical games never sync

Solution

1. Unified Delta Sync Pattern

All CloudKit data types follow the same pattern:

func fetchForSync(since lastSync: Date?) async throws -> [T] {
    let predicate: NSPredicate

    if let lastSync = lastSync {
        // Delta: only modified records
        predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
    } else {
        // First sync: ALL records
        predicate = NSPredicate(value: true)
    }

    // ... execute query
}

First sync (lastSync == nil): Fetch all records from CloudKit. This handles the edge case where CloudKit is updated after app build but before user installs.

Delta sync (lastSync has value): Fetch only records with modificationDate >= lastSync.

2. CloudKitService Changes

Method Before After
fetchStadiumsForSync () (since: Date?)
fetchTeamsForSync (for: Sport) (since: Date?)
fetchGamesForSync (sports:, startDate:, endDate:) (since: Date?)
fetchLeagueStructureChanges (since: Date?) No change
fetchTeamAliasChanges (since: Date?) No change
fetchStadiumAliasChanges (since: Date?) No change

Teams optimization: Fetch all teams in one call instead of 7 separate per-sport calls.

3. CanonicalSyncService Changes

Remove date range calculations from syncGames():

// BEFORE
let startDate = lastSync ?? Date()
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
let syncGames = try await cloudKitService.fetchGamesForSync(
    sports: Set(Sport.allCases),
    startDate: startDate,
    endDate: endDate
)

// AFTER
let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync)

4. DataProvider Naming Convention

Rename methods to clarify local vs network operations:

Before After Purpose
fetchGames(sports:, startDate:, endDate:) filterGames(sports:, startDate:, endDate:) Local query with date filter
fetchRichGames(...) filterRichGames(...) Local query with date filter
New allGames(for sports:) Local query, no date filter
New allRichGames(for sports:) Local query, no date filter

Convention: filter* = local query with constraints, all* = local query without date filter.

5. TripCreationViewModel Changes

Remove 90-day browsing limit:

// BEFORE
let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate
games = try await dataProvider.fetchGames(
    sports: selectedSports,
    startDate: Date(),
    endDate: browseEndDate
)

// AFTER
games = try await dataProvider.allGames(for: selectedSports)

Files to Modify

File Changes
SportsTime/Core/Services/CloudKitService.swift Update fetch signatures, query by modificationDate
SportsTime/Core/Services/CanonicalSyncService.swift Remove date range logic, pass lastSync directly
SportsTime/Core/Services/DataProvider.swift Rename methods, add allGames/allRichGames
SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift Use allGames instead of date-filtered fetch
SportsTimeTests/Mocks/MockCloudKitService.swift Update mock signatures
SportsTimeTests/Mocks/MockAppDataProvider.swift Update mock signatures, add new methods

Edge Cases

App built before CloudKit update

Date Event
1/12 App published with bundled JSON
1/13 Developer adds NWSL 2026 to CloudKit
1/14 User installs app

Handled by: First sync (lastSync == nil) fetches ALL records from CloudKit, including the 1/13 additions.

New league added

When a new league (e.g., NWSL) is added to CloudKit:

  • New records have modificationDate = creation timestamp
  • Delta sync picks them up if modificationDate >= lastSync
  • Works for games, teams, stadiums, and all other data types

Testing

Existing tests should pass with method renames. Key scenarios to verify:

  1. First launch: Full sync fetches all data
  2. Subsequent launch: Delta sync only fetches modified records
  3. "By Games" mode shows all available games
  4. New CloudKit data syncs correctly after app update