Files
Sportstime/docs/plans/2026-01-12-delta-sync-implementation.md
Trey t b514d2119c docs: add delta sync implementation plan
16-task TDD implementation plan for:
- CloudKit delta sync using modificationDate
- Remove 90-day game browsing limit
- Rename fetch* to filter* for clarity
- Add allGames/allRichGames methods

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

18 KiB

Delta Sync Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Remove arbitrary date restrictions from game browsing and implement proper delta sync using CloudKit modificationDate.

Architecture: Update CloudKitService to query by modificationDate instead of game dateTime. First sync fetches all records, subsequent syncs fetch only modified records. Rename DataProvider methods to clarify local vs network semantics.

Tech Stack: Swift, SwiftData, CloudKit, XCTest


Task 1: Update CloudKitService.fetchStadiumsForSync

Files:

  • Modify: SportsTime/Core/Services/CloudKitService.swift:217-231

Step 1: Update method signature and implementation

Change from:

func fetchStadiumsForSync() async throws -> [SyncStadium]

To:

/// Fetch stadiums for sync operations
/// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
func fetchStadiumsForSync(since lastSync: Date?) async throws -> [SyncStadium] {
    let predicate: NSPredicate
    if let lastSync = lastSync {
        predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
    } else {
        predicate = NSPredicate(value: true)
    }
    let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)

    let (results, _) = try await publicDatabase.records(matching: query)

    return results.compactMap { result -> SyncStadium? in
        guard case .success(let record) = result.1 else { return nil }
        let ckStadium = CKStadium(record: record)
        guard let stadium = ckStadium.stadium,
              let canonicalId = ckStadium.canonicalId
        else { return nil }
        return SyncStadium(stadium: stadium, canonicalId: canonicalId)
    }
}

Step 2: Commit

git add SportsTime/Core/Services/CloudKitService.swift
git commit -m "feat(sync): add lastSync parameter to fetchStadiumsForSync"

Task 2: Update CloudKitService.fetchTeamsForSync

Files:

  • Modify: SportsTime/Core/Services/CloudKitService.swift:233-249

Step 1: Update method signature and implementation

Change from per-sport to all teams with delta sync:

/// Fetch teams for sync operations
/// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
func fetchTeamsForSync(since lastSync: Date?) async throws -> [SyncTeam] {
    let predicate: NSPredicate
    if let lastSync = lastSync {
        predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
    } else {
        predicate = NSPredicate(value: true)
    }
    let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)

    let (results, _) = try await publicDatabase.records(matching: query)

    return results.compactMap { result -> SyncTeam? in
        guard case .success(let record) = result.1 else { return nil }
        let ckTeam = CKTeam(record: record)
        guard let team = ckTeam.team,
              let canonicalId = ckTeam.canonicalId,
              let stadiumCanonicalId = ckTeam.stadiumCanonicalId
        else { return nil }
        return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
    }
}

Step 2: Commit

git add SportsTime/Core/Services/CloudKitService.swift
git commit -m "feat(sync): change fetchTeamsForSync to delta sync (all teams, not per-sport)"

Task 3: Update CloudKitService.fetchGamesForSync

Files:

  • Modify: SportsTime/Core/Services/CloudKitService.swift:251-301

Step 1: Update method signature and implementation

Change from date range to delta sync:

/// Fetch games for sync operations
/// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date.
func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] {
    let predicate: NSPredicate
    if let lastSync = lastSync {
        predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
    } else {
        predicate = NSPredicate(value: true)
    }
    let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)

    let (results, _) = try await publicDatabase.records(matching: query)

    return results.compactMap { result -> SyncGame? in
        guard case .success(let record) = result.1 else { return nil }
        let ckGame = CKGame(record: record)

        guard let canonicalId = ckGame.canonicalId,
              let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
              let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
              let stadiumCanonicalId = ckGame.stadiumCanonicalId
        else { return nil }

        guard let game = ckGame.game(
            homeTeamId: homeTeamCanonicalId,
            awayTeamId: awayTeamCanonicalId,
            stadiumId: stadiumCanonicalId
        ) else { return nil }

        return SyncGame(
            game: game,
            canonicalId: canonicalId,
            homeTeamCanonicalId: homeTeamCanonicalId,
            awayTeamCanonicalId: awayTeamCanonicalId,
            stadiumCanonicalId: stadiumCanonicalId
        )
    }.sorted { $0.game.dateTime < $1.game.dateTime }
}

Step 2: Commit

git add SportsTime/Core/Services/CloudKitService.swift
git commit -m "feat(sync): change fetchGamesForSync to delta sync by modificationDate"

Task 4: Update CanonicalSyncService.syncStadiums

Files:

  • Modify: SportsTime/Core/Services/CanonicalSyncService.swift:208-236

Step 1: Pass lastSync to CloudKit fetch

Change line 214 from:

let syncStadiums = try await cloudKitService.fetchStadiumsForSync()

To:

let syncStadiums = try await cloudKitService.fetchStadiumsForSync(since: lastSync)

Step 2: Commit

git add SportsTime/Core/Services/CanonicalSyncService.swift
git commit -m "feat(sync): pass lastSync to stadium sync for delta updates"

Task 5: Update CanonicalSyncService.syncTeams

Files:

  • Modify: SportsTime/Core/Services/CanonicalSyncService.swift:238-271

Step 1: Simplify to single CloudKit call

Replace the for-loop that calls per-sport:

@MainActor
private func syncTeams(
    context: ModelContext,
    since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
    // Single call for all teams (no per-sport loop)
    let allSyncTeams = try await cloudKitService.fetchTeamsForSync(since: lastSync)

    var updated = 0
    var skippedIncompatible = 0
    var skippedOlder = 0

    for syncTeam in allSyncTeams {
        let result = try mergeTeam(
            syncTeam.team,
            canonicalId: syncTeam.canonicalId,
            stadiumCanonicalId: syncTeam.stadiumCanonicalId,
            context: context
        )

        switch result {
        case .applied: updated += 1
        case .skippedIncompatible: skippedIncompatible += 1
        case .skippedOlder: skippedOlder += 1
        }
    }

    return (updated, skippedIncompatible, skippedOlder)
}

Step 2: Commit

git add SportsTime/Core/Services/CanonicalSyncService.swift
git commit -m "feat(sync): simplify team sync to single CloudKit call with delta"

Task 6: Update CanonicalSyncService.syncGames

Files:

  • Modify: SportsTime/Core/Services/CanonicalSyncService.swift:273-311

Step 1: Remove date range, use delta sync

Replace lines 278-286:

@MainActor
private func syncGames(
    context: ModelContext,
    since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
    // Delta sync: nil = all games, Date = only modified since
    let syncGames = try await cloudKitService.fetchGamesForSync(since: lastSync)

    var updated = 0
    var skippedIncompatible = 0
    var skippedOlder = 0

    for syncGame in syncGames {
        let result = try mergeGame(
            syncGame.game,
            canonicalId: syncGame.canonicalId,
            homeTeamCanonicalId: syncGame.homeTeamCanonicalId,
            awayTeamCanonicalId: syncGame.awayTeamCanonicalId,
            stadiumCanonicalId: syncGame.stadiumCanonicalId,
            context: context
        )

        switch result {
        case .applied: updated += 1
        case .skippedIncompatible: skippedIncompatible += 1
        case .skippedOlder: skippedOlder += 1
        }
    }

    return (updated, skippedIncompatible, skippedOlder)
}

Step 2: Commit

git add SportsTime/Core/Services/CanonicalSyncService.swift
git commit -m "feat(sync): remove date range from game sync, use modificationDate delta"

Task 7: Rename DataProvider.fetchGames to filterGames

Files:

  • Modify: SportsTime/Core/Services/DataProvider.swift:121-144

Step 1: Rename method

Change:

/// Fetch games from SwiftData within date range
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]

To:

/// Filter games from SwiftData within date range
func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]

Step 2: Commit

git add SportsTime/Core/Services/DataProvider.swift
git commit -m "refactor: rename fetchGames to filterGames (clarify local query)"

Task 8: Rename DataProvider.fetchRichGames to filterRichGames

Files:

  • Modify: SportsTime/Core/Services/DataProvider.swift:163-175

Step 1: Rename method and update internal call

Change:

/// Fetch games with full team and stadium data
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
    let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)

To:

/// Filter games with full team and stadium data within date range
func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
    let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)

Step 2: Commit

git add SportsTime/Core/Services/DataProvider.swift
git commit -m "refactor: rename fetchRichGames to filterRichGames"

Task 9: Add DataProvider.allGames method

Files:

  • Modify: SportsTime/Core/Services/DataProvider.swift (add after filterGames, around line 145)

Step 1: Add new method

/// Get all games for specified sports (no date filtering)
func allGames(for sports: Set<Sport>) async throws -> [Game] {
    guard let context = modelContext else {
        throw DataProviderError.contextNotConfigured
    }

    let sportStrings = sports.map { $0.rawValue }

    let descriptor = FetchDescriptor<CanonicalGame>(
        predicate: #Predicate<CanonicalGame> { game in
            game.deprecatedAt == nil
        },
        sortBy: [SortDescriptor(\.dateTime)]
    )

    let canonicalGames = try context.fetch(descriptor)

    return canonicalGames.compactMap { canonical -> Game? in
        guard sportStrings.contains(canonical.sport) else { return nil }
        return canonical.toDomain()
    }
}

Step 2: Commit

git add SportsTime/Core/Services/DataProvider.swift
git commit -m "feat: add allGames method for unfiltered game access"

Task 10: Add DataProvider.allRichGames method

Files:

  • Modify: SportsTime/Core/Services/DataProvider.swift (add after allGames)

Step 1: Add new method

/// Get all games with full team and stadium data (no date filtering)
func allRichGames(for sports: Set<Sport>) async throws -> [RichGame] {
    let games = try await allGames(for: sports)

    return games.compactMap { game in
        guard let homeTeam = teamsById[game.homeTeamId],
              let awayTeam = teamsById[game.awayTeamId],
              let stadium = stadiumsById[game.stadiumId] else {
            return nil
        }
        return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
    }
}

Step 2: Commit

git add SportsTime/Core/Services/DataProvider.swift
git commit -m "feat: add allRichGames method for unfiltered rich game access"

Task 11: Update TripCreationViewModel.loadGamesForBrowsing

Files:

  • Modify: SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift:491-497

Step 1: Remove 90-day limit, use allGames

Change from:

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

To:

// Fetch ALL available games for browsing (no date restrictions)
games = try await dataProvider.allGames(for: selectedSports)

Step 2: Commit

git add SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
git commit -m "feat: remove 90-day limit from game browsing, show all games"

Task 12: Update MockCloudKitService sync methods

Files:

  • Modify: SportsTimeTests/Mocks/MockCloudKitService.swift:165-206

Step 1: Update fetchStadiumsForSync

func fetchStadiumsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncStadium] {
    try await simulateNetwork()
    let filtered = lastSync == nil ? stadiums : stadiums  // Mock doesn't track modificationDate
    return filtered.map { stadium in
        CloudKitService.SyncStadium(
            stadium: stadium,
            canonicalId: stadium.id
        )
    }
}

Step 2: Update fetchTeamsForSync

func fetchTeamsForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncTeam] {
    try await simulateNetwork()
    return teams.map { team in
        CloudKitService.SyncTeam(
            team: team,
            canonicalId: team.id,
            stadiumCanonicalId: team.stadiumId
        )
    }
}

Step 3: Update fetchGamesForSync

func fetchGamesForSync(since lastSync: Date?) async throws -> [CloudKitService.SyncGame] {
    try await simulateNetwork()

    return games.map { game in
        CloudKitService.SyncGame(
            game: game,
            canonicalId: game.id,
            homeTeamCanonicalId: game.homeTeamId,
            awayTeamCanonicalId: game.awayTeamId,
            stadiumCanonicalId: game.stadiumId
        )
    }
}

Step 4: Commit

git add SportsTimeTests/Mocks/MockCloudKitService.swift
git commit -m "test: update MockCloudKitService signatures for delta sync"

Task 13: Update MockAppDataProvider methods

Files:

  • Modify: SportsTimeTests/Mocks/MockAppDataProvider.swift:157-189

Step 1: Rename fetchGames to filterGames

func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
    fetchGamesCallCount += 1
    await simulateLatency()

    if config.shouldFailOnFetch {
        throw DataProviderError.contextNotConfigured
    }

    return games.filter { game in
        sports.contains(game.sport) &&
        game.dateTime >= startDate &&
        game.dateTime <= endDate
    }.sorted { $0.dateTime < $1.dateTime }
}

Step 2: Rename fetchRichGames to filterRichGames

func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
    fetchRichGamesCallCount += 1
    let filteredGames = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)

    return filteredGames.compactMap { game in
        richGame(from: game)
    }
}

Step 3: Add allGames method

func allGames(for sports: Set<Sport>) async throws -> [Game] {
    fetchGamesCallCount += 1
    await simulateLatency()

    if config.shouldFailOnFetch {
        throw DataProviderError.contextNotConfigured
    }

    return games.filter { game in
        sports.contains(game.sport)
    }.sorted { $0.dateTime < $1.dateTime }
}

Step 4: Add allRichGames method

func allRichGames(for sports: Set<Sport>) async throws -> [RichGame] {
    fetchRichGamesCallCount += 1
    let allGames = try await allGames(for: sports)

    return allGames.compactMap { game in
        richGame(from: game)
    }
}

Step 5: Commit

git add SportsTimeTests/Mocks/MockAppDataProvider.swift
git commit -m "test: update MockAppDataProvider with renamed and new methods"

Task 14: Fix all callers of renamed methods

Files:

  • Search and update all files calling the old method names

Step 1: Find all usages

Run:

grep -r "fetchGames\|fetchRichGames" --include="*.swift" SportsTime/ SportsTimeTests/ | grep -v "Mock"

Step 2: Update each caller

For each file found:

  • fetchGames(filterGames( (when using date parameters)
  • fetchRichGames(filterRichGames( (when using date parameters)

Common files likely to need updates:

  • GameMatcher.swift
  • ScheduleMatcher.swift
  • TripPlanningEngine.swift
  • Various test files

Step 3: Commit

git add -A
git commit -m "refactor: update all callers to use renamed filter methods"

Task 15: Run tests and fix any failures

Step 1: Run full test suite

xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test

Step 2: Fix any compilation errors or test failures

Most likely issues:

  • Missing method implementations
  • Signature mismatches between mock and real implementations
  • Tests expecting old method names

Step 3: Commit fixes

git add -A
git commit -m "fix: resolve test failures from delta sync refactor"

Task 16: Final verification

Step 1: Verify CloudKit sync logic

Review the sync flow:

  1. First launch: lastSync == nil → fetches ALL records
  2. Subsequent syncs: lastSync == Date → fetches only modified records

Step 2: Verify game browsing

  1. Build and run app
  2. Go to "By Games" mode
  3. Select MLB
  4. Verify Houston Astros shows full season (160+ games)

Step 3: Final commit

git add -A
git commit -m "feat: complete delta sync and unlimited game browsing implementation"

Summary of Changes

File Changes
CloudKitService.swift 3 methods updated to use since: Date? parameter
CanonicalSyncService.swift 3 methods updated to pass lastSync
DataProvider.swift 2 methods renamed, 2 methods added
TripCreationViewModel.swift 1 method updated to use allGames
MockCloudKitService.swift 3 methods updated to match new signatures
MockAppDataProvider.swift 2 methods renamed, 2 methods added
Various callers Updated to use new method names