# Group Trip Polling Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Enable users to group trips into polls, share via link, and collect ranked-choice votes from friends. **Architecture:** CloudKit public database stores polls and votes. SwiftData caches locally for offline access. Hybrid real-time updates via CloudKit subscriptions + refresh-on-appear. **Tech Stack:** SwiftUI, SwiftData, CloudKit (public database), @Observable ViewModels --- ## Task 1: Domain Models **Files:** - Create: `SportsTime/Core/Models/Domain/TripPoll.swift` - Test: `SportsTimeTests/Models/TripPollTests.swift` **Step 1: Write the domain model tests** ```swift // SportsTimeTests/Models/TripPollTests.swift import XCTest @testable import SportsTime final class TripPollTests: XCTestCase { func test_TripPoll_GeneratesShareCode_SixCharacters() { let shareCode = TripPoll.generateShareCode() XCTAssertEqual(shareCode.count, 6) } func test_TripPoll_ShareCodeCharacterSet_ExcludesAmbiguous() { // Generate many codes and ensure none contain 0, O, 1, I, L let ambiguous: Set = ["0", "O", "1", "I", "L"] for _ in 0..<100 { let code = TripPoll.generateShareCode() let hasAmbiguous = code.contains { ambiguous.contains($0) } XCTAssertFalse(hasAmbiguous, "Code '\(code)' contains ambiguous character") } } func test_TripPoll_ComputeTripHash_ChangesWhenStopsChange() { let trip1 = Trip.mock(stops: [.mock(city: "Boston")]) let trip2 = Trip.mock(stops: [.mock(city: "Boston"), .mock(city: "New York")]) let hash1 = TripPoll.computeTripHash(trip1) let hash2 = TripPoll.computeTripHash(trip2) XCTAssertNotEqual(hash1, hash2) } func test_TripPoll_ComputeTripHash_StableForSameTrip() { let trip = Trip.mock(stops: [.mock(city: "Boston")]) let hash1 = TripPoll.computeTripHash(trip) let hash2 = TripPoll.computeTripHash(trip) XCTAssertEqual(hash1, hash2) } func test_PollVote_CalculateScore_BordaCount() { // 3 trips, rankings [0, 2, 1] means: trip0=1st, trip2=2nd, trip1=3rd // Borda: 1st gets 3 points, 2nd gets 2, 3rd gets 1 let scores = PollVote.calculateScores(rankings: [0, 2, 1], tripCount: 3) XCTAssertEqual(scores[0], 3) // Trip 0 is 1st XCTAssertEqual(scores[1], 1) // Trip 1 is 3rd XCTAssertEqual(scores[2], 2) // Trip 2 is 2nd } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/TripPollTests test 2>&1 | tail -20 ``` Expected: FAIL - TripPoll not defined **Step 3: Write minimal implementation** ```swift // SportsTime/Core/Models/Domain/TripPoll.swift import Foundation // MARK: - Trip Poll struct TripPoll: Identifiable, Codable, Hashable { let id: UUID var title: String let ownerId: String let shareCode: String var tripSnapshots: [Trip] var tripVersions: [String] let createdAt: Date var modifiedAt: Date init( id: UUID = UUID(), title: String, ownerId: String, shareCode: String = TripPoll.generateShareCode(), tripSnapshots: [Trip], createdAt: Date = Date(), modifiedAt: Date = Date() ) { self.id = id self.title = title self.ownerId = ownerId self.shareCode = shareCode self.tripSnapshots = tripSnapshots self.tripVersions = tripSnapshots.map { TripPoll.computeTripHash($0) } self.createdAt = createdAt self.modifiedAt = modifiedAt } // MARK: - Share Code Generation private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789") static func generateShareCode() -> String { String((0..<6).map { _ in shareCodeCharacters.randomElement()! }) } // MARK: - Trip Hash static func computeTripHash(_ trip: Trip) -> String { var hasher = Hasher() hasher.combine(trip.stops.map { $0.city }) hasher.combine(trip.stops.flatMap { $0.games }) hasher.combine(trip.preferences.startDate) hasher.combine(trip.preferences.endDate) return String(hasher.finalize()) } // MARK: - Deep Link URL var shareURL: URL { URL(string: "sportstime://poll/\(shareCode)")! } } // MARK: - Poll Vote struct PollVote: Identifiable, Codable, Hashable { let id: UUID let pollId: UUID let odg: String // voter's userRecordID var rankings: [Int] // trip indices in preference order let votedAt: Date var modifiedAt: Date init( id: UUID = UUID(), pollId: UUID, odg: String, rankings: [Int], votedAt: Date = Date(), modifiedAt: Date = Date() ) { self.id = id self.pollId = pollId self.odg = odg self.rankings = rankings self.votedAt = votedAt self.modifiedAt = modifiedAt } /// Calculate Borda count scores from rankings /// Returns array where index = trip index, value = score static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] { var scores = Array(repeating: 0, count: tripCount) for (rank, tripIndex) in rankings.enumerated() { // Borda: 1st place gets N points, 2nd gets N-1, etc. let points = tripCount - rank scores[tripIndex] = points } return scores } } // MARK: - Poll Results struct PollResults { let poll: TripPoll let votes: [PollVote] var voterCount: Int { votes.count } var tripScores: [(tripIndex: Int, score: Int)] { guard !votes.isEmpty else { return poll.tripSnapshots.indices.map { ($0, 0) } } var totalScores = Array(repeating: 0, count: poll.tripSnapshots.count) for vote in votes { let scores = PollVote.calculateScores(rankings: vote.rankings, tripCount: poll.tripSnapshots.count) for (index, score) in scores.enumerated() { totalScores[index] += score } } return totalScores.enumerated() .map { ($0.offset, $0.element) } .sorted { $0.score > $1.score } } var maxScore: Int { tripScores.first?.score ?? 0 } func scorePercentage(for tripIndex: Int) -> Double { guard maxScore > 0 else { return 0 } let score = tripScores.first { $0.tripIndex == tripIndex }?.score ?? 0 return Double(score) / Double(maxScore) } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/TripPollTests test 2>&1 | tail -20 ``` Expected: PASS **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Core/Models/Domain/TripPoll.swift SportsTimeTests/Models/TripPollTests.swift && \ git commit -m "feat(poll): add TripPoll and PollVote domain models - Share code generation with unambiguous characters - Trip hash computation for change detection - Borda count scoring for ranked choice voting" ``` --- ## Task 2: CloudKit Record Types **Files:** - Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` (add new record types) - Test: `SportsTimeTests/Models/CKPollModelsTests.swift` **Step 1: Write tests for CloudKit model conversion** ```swift // SportsTimeTests/Models/CKPollModelsTests.swift import XCTest import CloudKit @testable import SportsTime final class CKPollModelsTests: XCTestCase { func test_CKTripPoll_RoundTrip_PreservesData() throws { let poll = TripPoll( title: "Summer 2026 Options", ownerId: "user123", shareCode: "X7K9M2", tripSnapshots: [.mock()] ) let ckPoll = CKTripPoll(poll: poll) let roundTripped = try XCTUnwrap(ckPoll.toPoll()) XCTAssertEqual(roundTripped.id, poll.id) XCTAssertEqual(roundTripped.title, poll.title) XCTAssertEqual(roundTripped.ownerId, poll.ownerId) XCTAssertEqual(roundTripped.shareCode, poll.shareCode) XCTAssertEqual(roundTripped.tripSnapshots.count, poll.tripSnapshots.count) } func test_CKPollVote_RoundTrip_PreservesData() throws { let vote = PollVote( pollId: UUID(), odg: "voter456", rankings: [2, 0, 1] ) let ckVote = CKPollVote(vote: vote) let roundTripped = try XCTUnwrap(ckVote.toVote()) XCTAssertEqual(roundTripped.id, vote.id) XCTAssertEqual(roundTripped.pollId, vote.pollId) XCTAssertEqual(roundTripped.odg, vote.odg) XCTAssertEqual(roundTripped.rankings, vote.rankings) } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/CKPollModelsTests test 2>&1 | tail -20 ``` Expected: FAIL - CKTripPoll not defined **Step 3: Add CloudKit record types** Add to `SportsTime/Core/Models/CloudKit/CKModels.swift`: ```swift // In CKRecordType enum, add: static let tripPoll = "TripPoll" static let pollVote = "PollVote" // MARK: - CKTripPoll struct CKTripPoll { static let pollIdKey = "pollId" static let titleKey = "title" static let ownerIdKey = "ownerId" static let shareCodeKey = "shareCode" static let tripSnapshotsKey = "tripSnapshots" static let tripVersionsKey = "tripVersions" static let createdAtKey = "createdAt" static let modifiedAtKey = "modifiedAt" let record: CKRecord init(record: CKRecord) { self.record = record } init(poll: TripPoll) { let record = CKRecord(recordType: CKRecordType.tripPoll, recordID: CKRecord.ID(recordName: poll.id.uuidString)) record[CKTripPoll.pollIdKey] = poll.id.uuidString record[CKTripPoll.titleKey] = poll.title record[CKTripPoll.ownerIdKey] = poll.ownerId record[CKTripPoll.shareCodeKey] = poll.shareCode // Encode trips as JSON data if let tripsData = try? JSONEncoder().encode(poll.tripSnapshots) { record[CKTripPoll.tripSnapshotsKey] = tripsData } record[CKTripPoll.tripVersionsKey] = poll.tripVersions record[CKTripPoll.createdAtKey] = poll.createdAt record[CKTripPoll.modifiedAtKey] = poll.modifiedAt self.record = record } func toPoll() -> TripPoll? { guard let pollIdString = record[CKTripPoll.pollIdKey] as? String, let pollId = UUID(uuidString: pollIdString), let title = record[CKTripPoll.titleKey] as? String, let ownerId = record[CKTripPoll.ownerIdKey] as? String, let shareCode = record[CKTripPoll.shareCodeKey] as? String, let tripsData = record[CKTripPoll.tripSnapshotsKey] as? Data, let tripSnapshots = try? JSONDecoder().decode([Trip].self, from: tripsData), let tripVersions = record[CKTripPoll.tripVersionsKey] as? [String], let createdAt = record[CKTripPoll.createdAtKey] as? Date, let modifiedAt = record[CKTripPoll.modifiedAtKey] as? Date else { return nil } var poll = TripPoll( id: pollId, title: title, ownerId: ownerId, shareCode: shareCode, tripSnapshots: tripSnapshots, createdAt: createdAt, modifiedAt: modifiedAt ) // Override computed tripVersions with stored values poll.tripVersions = tripVersions return poll } } // MARK: - CKPollVote struct CKPollVote { static let voteIdKey = "voteId" static let pollIdKey = "pollId" static let voterIdKey = "odg" static let rankingsKey = "rankings" static let votedAtKey = "votedAt" static let modifiedAtKey = "modifiedAt" let record: CKRecord init(record: CKRecord) { self.record = record } init(vote: PollVote) { let record = CKRecord(recordType: CKRecordType.pollVote, recordID: CKRecord.ID(recordName: vote.id.uuidString)) record[CKPollVote.voteIdKey] = vote.id.uuidString record[CKPollVote.pollIdKey] = vote.pollId.uuidString record[CKPollVote.voterIdKey] = vote.odg // Encode rankings as JSON if let rankingsData = try? JSONEncoder().encode(vote.rankings) { record[CKPollVote.rankingsKey] = rankingsData } record[CKPollVote.votedAtKey] = vote.votedAt record[CKPollVote.modifiedAtKey] = vote.modifiedAt self.record = record } func toVote() -> PollVote? { guard let voteIdString = record[CKPollVote.voteIdKey] as? String, let voteId = UUID(uuidString: voteIdString), let pollIdString = record[CKPollVote.pollIdKey] as? String, let pollId = UUID(uuidString: pollIdString), let odg = record[CKPollVote.voterIdKey] as? String, let rankingsData = record[CKPollVote.rankingsKey] as? Data, let rankings = try? JSONDecoder().decode([Int].self, from: rankingsData), let votedAt = record[CKPollVote.votedAtKey] as? Date, let modifiedAt = record[CKPollVote.modifiedAtKey] as? Date else { return nil } return PollVote( id: voteId, pollId: pollId, odg: odg, rankings: rankings, votedAt: votedAt, modifiedAt: modifiedAt ) } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/CKPollModelsTests test 2>&1 | tail -20 ``` Expected: PASS **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Core/Models/CloudKit/CKModels.swift SportsTimeTests/Models/CKPollModelsTests.swift && \ git commit -m "feat(poll): add CKTripPoll and CKPollVote CloudKit models - JSON encoding for trip snapshots and rankings - Round-trip conversion tests" ``` --- ## Task 3: SwiftData Local Models **Files:** - Modify: `SportsTime/Core/Models/Local/SavedTrip.swift` (add LocalTripPoll, LocalPollVote) - Test: `SportsTimeTests/Models/LocalPollModelsTests.swift` **Step 1: Write tests** ```swift // SportsTimeTests/Models/LocalPollModelsTests.swift import XCTest import SwiftData @testable import SportsTime final class LocalPollModelsTests: XCTestCase { func test_LocalTripPoll_FromDomain_PreservesData() throws { let poll = TripPoll( title: "Test Poll", ownerId: "user123", tripSnapshots: [.mock()] ) let localPoll = LocalTripPoll.from(poll) let decoded = try XCTUnwrap(localPoll?.poll) XCTAssertEqual(decoded.id, poll.id) XCTAssertEqual(decoded.title, poll.title) } func test_LocalPollVote_FromDomain_PreservesData() throws { let vote = PollVote( pollId: UUID(), odg: "voter456", rankings: [1, 2, 0] ) let localVote = LocalPollVote.from(vote) let decoded = try XCTUnwrap(localVote?.vote) XCTAssertEqual(decoded.id, vote.id) XCTAssertEqual(decoded.rankings, vote.rankings) } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/LocalPollModelsTests test 2>&1 | tail -20 ``` Expected: FAIL - LocalTripPoll not defined **Step 3: Add SwiftData models** Add to `SportsTime/Core/Models/Local/SavedTrip.swift`: ```swift // MARK: - Local Trip Poll @Model final class LocalTripPoll { @Attribute(.unique) var id: UUID var cloudRecordId: String? var title: String var ownerId: String var shareCode: String var pollData: Data // Encoded TripPoll var isOwner: Bool var createdAt: Date var modifiedAt: Date @Relationship(deleteRule: .cascade) var votes: [LocalPollVote]? init( id: UUID, cloudRecordId: String? = nil, title: String, ownerId: String, shareCode: String, pollData: Data, isOwner: Bool, createdAt: Date, modifiedAt: Date ) { self.id = id self.cloudRecordId = cloudRecordId self.title = title self.ownerId = ownerId self.shareCode = shareCode self.pollData = pollData self.isOwner = isOwner self.createdAt = createdAt self.modifiedAt = modifiedAt } var poll: TripPoll? { try? JSONDecoder().decode(TripPoll.self, from: pollData) } static func from(_ poll: TripPoll, isOwner: Bool = false, cloudRecordId: String? = nil) -> LocalTripPoll? { guard let data = try? JSONEncoder().encode(poll) else { return nil } return LocalTripPoll( id: poll.id, cloudRecordId: cloudRecordId, title: poll.title, ownerId: poll.ownerId, shareCode: poll.shareCode, pollData: data, isOwner: isOwner, createdAt: poll.createdAt, modifiedAt: poll.modifiedAt ) } } // MARK: - Local Poll Vote @Model final class LocalPollVote { @Attribute(.unique) var id: UUID var pollId: UUID var odg: String var rankingsData: Data var votedAt: Date var modifiedAt: Date init( id: UUID, pollId: UUID, odg: String, rankingsData: Data, votedAt: Date, modifiedAt: Date ) { self.id = id self.pollId = pollId self.odg = odg self.rankingsData = rankingsData self.votedAt = votedAt self.modifiedAt = modifiedAt } var vote: PollVote? { guard let rankings = try? JSONDecoder().decode([Int].self, from: rankingsData) else { return nil } return PollVote( id: id, pollId: pollId, odg: odg, rankings: rankings, votedAt: votedAt, modifiedAt: modifiedAt ) } var rankings: [Int] { (try? JSONDecoder().decode([Int].self, from: rankingsData)) ?? [] } static func from(_ vote: PollVote) -> LocalPollVote? { guard let data = try? JSONEncoder().encode(vote.rankings) else { return nil } return LocalPollVote( id: vote.id, pollId: vote.pollId, odg: vote.odg, rankingsData: data, votedAt: vote.votedAt, modifiedAt: vote.modifiedAt ) } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/LocalPollModelsTests test 2>&1 | tail -20 ``` Expected: PASS **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Core/Models/Local/SavedTrip.swift SportsTimeTests/Models/LocalPollModelsTests.swift && \ git commit -m "feat(poll): add LocalTripPoll and LocalPollVote SwiftData models - Local caching for offline access - Relationship from poll to votes" ``` --- ## Task 4: PollService - Core CloudKit Operations **Files:** - Create: `SportsTime/Core/Services/PollService.swift` - Test: `SportsTimeTests/Services/PollServiceTests.swift` **Step 1: Write tests using mock** ```swift // SportsTimeTests/Services/PollServiceTests.swift import XCTest @testable import SportsTime final class PollServiceTests: XCTestCase { func test_createPoll_GeneratesShareCode() async throws { let service = PollService.shared let trips: [Trip] = [.mock(), .mock()] // Note: This test requires CloudKit availability // In CI, skip or use mock guard await service.isCloudKitAvailable() else { throw XCTSkip("CloudKit not available") } let poll = try await service.createPoll(title: "Test", trips: trips) XCTAssertEqual(poll.shareCode.count, 6) XCTAssertEqual(poll.tripSnapshots.count, 2) } func test_aggregateVotes_CalculatesCorrectScores() { let pollId = UUID() let votes = [ PollVote(pollId: pollId, odg: "user1", rankings: [0, 1, 2]), PollVote(pollId: pollId, odg: "user2", rankings: [1, 0, 2]), PollVote(pollId: pollId, odg: "user3", rankings: [0, 2, 1]) ] let results = PollService.aggregateVotes(votes, tripCount: 3) // Trip 0: 3+2+3 = 8 points // Trip 1: 2+3+1 = 6 points // Trip 2: 1+1+2 = 4 points XCTAssertEqual(results[0], 8) XCTAssertEqual(results[1], 6) XCTAssertEqual(results[2], 4) } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollServiceTests test 2>&1 | tail -20 ``` Expected: FAIL - PollService not defined **Step 3: Implement PollService** ```swift // SportsTime/Core/Services/PollService.swift import Foundation import CloudKit actor PollService { static let shared = PollService() private let container: CKContainer private let publicDatabase: CKDatabase private init() { self.container = CKContainer(identifier: "iCloud.com.sportstime.app") self.publicDatabase = container.publicCloudDatabase } // MARK: - Availability func isCloudKitAvailable() async -> Bool { do { let status = try await container.accountStatus() return status == .available } catch { return false } } func getCurrentUserRecordID() async throws -> String { let recordID = try await container.userRecordID() return recordID.recordName } // MARK: - Poll CRUD func createPoll(title: String, trips: [Trip]) async throws -> TripPoll { let ownerId = try await getCurrentUserRecordID() var poll = TripPoll(title: title, ownerId: ownerId, tripSnapshots: trips) // Ensure unique share code while try await fetchPoll(byShareCode: poll.shareCode) != nil { poll = TripPoll( id: poll.id, title: title, ownerId: ownerId, shareCode: TripPoll.generateShareCode(), tripSnapshots: trips, createdAt: poll.createdAt, modifiedAt: poll.modifiedAt ) } let ckPoll = CKTripPoll(poll: poll) try await publicDatabase.save(ckPoll.record) return poll } func fetchPoll(byShareCode shareCode: String) async throws -> TripPoll? { let predicate = NSPredicate(format: "%K == %@", CKTripPoll.shareCodeKey, shareCode) let query = CKQuery(recordType: CKRecordType.tripPoll, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) guard let result = results.first, case .success(let record) = result.1 else { return nil } return CKTripPoll(record: record).toPoll() } func fetchPoll(byId id: UUID) async throws -> TripPoll? { let recordID = CKRecord.ID(recordName: id.uuidString) do { let record = try await publicDatabase.record(for: recordID) return CKTripPoll(record: record).toPoll() } catch let error as CKError where error.code == .unknownItem { return nil } } func updatePoll(_ poll: TripPoll) async throws { let recordID = CKRecord.ID(recordName: poll.id.uuidString) let record = try await publicDatabase.record(for: recordID) // Update fields var updatedPoll = poll updatedPoll.modifiedAt = Date() record[CKTripPoll.titleKey] = updatedPoll.title if let tripsData = try? JSONEncoder().encode(updatedPoll.tripSnapshots) { record[CKTripPoll.tripSnapshotsKey] = tripsData } record[CKTripPoll.tripVersionsKey] = updatedPoll.tripVersions record[CKTripPoll.modifiedAtKey] = updatedPoll.modifiedAt try await publicDatabase.save(record) } func deletePoll(_ poll: TripPoll) async throws { let recordID = CKRecord.ID(recordName: poll.id.uuidString) try await publicDatabase.deleteRecord(withID: recordID) // Also delete all votes for this poll try await deleteVotes(forPollId: poll.id) } // MARK: - Vote CRUD func submitVote(_ vote: PollVote) async throws { let ckVote = CKPollVote(vote: vote) try await publicDatabase.save(ckVote.record) } func fetchVotes(forPollId pollId: UUID) async throws -> [PollVote] { let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString) let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) return results.compactMap { result in guard case .success(let record) = result.1 else { return nil } return CKPollVote(record: record).toVote() } } func fetchUserVote(forPollId pollId: UUID, odg: String) async throws -> PollVote? { let predicate = NSPredicate( format: "%K == %@ AND %K == %@", CKPollVote.pollIdKey, pollId.uuidString, CKPollVote.voterIdKey, odg ) let query = CKQuery(recordType: CKRecordType.pollVote, predicate: predicate) let (results, _) = try await publicDatabase.records(matching: query) guard let result = results.first, case .success(let record) = result.1 else { return nil } return CKPollVote(record: record).toVote() } func updateVote(_ vote: PollVote) async throws { let recordID = CKRecord.ID(recordName: vote.id.uuidString) let record = try await publicDatabase.record(for: recordID) var updatedVote = vote updatedVote.modifiedAt = Date() if let rankingsData = try? JSONEncoder().encode(updatedVote.rankings) { record[CKPollVote.rankingsKey] = rankingsData } record[CKPollVote.modifiedAtKey] = updatedVote.modifiedAt try await publicDatabase.save(record) } private func deleteVotes(forPollId pollId: UUID) async throws { let votes = try await fetchVotes(forPollId: pollId) for vote in votes { let recordID = CKRecord.ID(recordName: vote.id.uuidString) try await publicDatabase.deleteRecord(withID: recordID) } } // MARK: - Subscriptions func subscribeToVoteUpdates(forPollId pollId: UUID) async throws { let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString) let subscription = CKQuerySubscription( recordType: CKRecordType.pollVote, predicate: predicate, subscriptionID: "poll-votes-\(pollId.uuidString)", options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion] ) let notification = CKSubscription.NotificationInfo() notification.shouldSendContentAvailable = true subscription.notificationInfo = notification try await publicDatabase.save(subscription) } // MARK: - Aggregation static func aggregateVotes(_ votes: [PollVote], tripCount: Int) -> [Int] { var totalScores = Array(repeating: 0, count: tripCount) for vote in votes { let scores = PollVote.calculateScores(rankings: vote.rankings, tripCount: tripCount) for (index, score) in scores.enumerated() { totalScores[index] += score } } return totalScores } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollServiceTests test 2>&1 | tail -20 ``` Expected: PASS (aggregateVotes test passes; CloudKit tests may skip if unavailable) **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Core/Services/PollService.swift SportsTimeTests/Services/PollServiceTests.swift && \ git commit -m "feat(poll): add PollService for CloudKit operations - Create, fetch, update, delete polls - Submit and fetch votes - Vote aggregation with Borda count - CloudKit subscription for real-time updates" ``` --- ## Task 5: Poll Creation ViewModel **Files:** - Create: `SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift` - Test: `SportsTimeTests/ViewModels/PollCreationViewModelTests.swift` **Step 1: Write tests** ```swift // SportsTimeTests/ViewModels/PollCreationViewModelTests.swift import XCTest @testable import SportsTime @MainActor final class PollCreationViewModelTests: XCTestCase { func test_canCreate_RequiresTwoTrips() { let vm = PollCreationViewModel() vm.selectedTrips = [] XCTAssertFalse(vm.canCreate) vm.selectedTrips = [.mock()] XCTAssertFalse(vm.canCreate) vm.selectedTrips = [.mock(), .mock()] XCTAssertTrue(vm.canCreate) } func test_canCreate_RequiresTitle() { let vm = PollCreationViewModel() vm.selectedTrips = [.mock(), .mock()] vm.title = "" XCTAssertFalse(vm.canCreate) vm.title = " " XCTAssertFalse(vm.canCreate) vm.title = "Summer Trip" XCTAssertTrue(vm.canCreate) } func test_maxTrips_IsTen() { let vm = PollCreationViewModel() XCTAssertEqual(vm.maxTrips, 10) } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollCreationViewModelTests test 2>&1 | tail -20 ``` Expected: FAIL - PollCreationViewModel not defined **Step 3: Implement ViewModel** ```swift // SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift import Foundation import SwiftUI @MainActor @Observable final class PollCreationViewModel { var title: String = "" var selectedTrips: [Trip] = [] var isLoading = false var error: Error? var createdPoll: TripPoll? let maxTrips = 10 var canCreate: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty && selectedTrips.count >= 2 && selectedTrips.count <= maxTrips } var tripCountLabel: String { "\(selectedTrips.count)/\(maxTrips) trips selected" } func toggleTrip(_ trip: Trip) { if let index = selectedTrips.firstIndex(where: { $0.id == trip.id }) { selectedTrips.remove(at: index) } else if selectedTrips.count < maxTrips { selectedTrips.append(trip) } } func isSelected(_ trip: Trip) -> Bool { selectedTrips.contains { $0.id == trip.id } } func createPoll() async { guard canCreate else { return } isLoading = true error = nil do { let poll = try await PollService.shared.createPoll( title: title.trimmingCharacters(in: .whitespaces), trips: selectedTrips ) createdPoll = poll } catch { self.error = error } isLoading = false } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollCreationViewModelTests test 2>&1 | tail -20 ``` Expected: PASS **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Features/Polls/ViewModels/PollCreationViewModel.swift \ SportsTimeTests/ViewModels/PollCreationViewModelTests.swift && \ git commit -m "feat(poll): add PollCreationViewModel - Trip selection with 2-10 limit - Title validation - Create poll via PollService" ``` --- ## Task 6: Poll Detail ViewModel **Files:** - Create: `SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift` - Test: `SportsTimeTests/ViewModels/PollDetailViewModelTests.swift` **Step 1: Write tests** ```swift // SportsTimeTests/ViewModels/PollDetailViewModelTests.swift import XCTest @testable import SportsTime @MainActor final class PollDetailViewModelTests: XCTestCase { func test_hasVoted_FalseInitially() { let poll = TripPoll(title: "Test", ownerId: "owner", tripSnapshots: [.mock()]) let vm = PollDetailViewModel(poll: poll) XCTAssertFalse(vm.hasVoted) } func test_sortedResults_OrdersByScore() { let poll = TripPoll(title: "Test", ownerId: "owner", tripSnapshots: [.mock(), .mock(), .mock()]) let vm = PollDetailViewModel(poll: poll) // Simulate votes where trip 1 wins vm.votes = [ PollVote(pollId: poll.id, odg: "v1", rankings: [1, 0, 2]), PollVote(pollId: poll.id, odg: "v2", rankings: [1, 0, 2]) ] let sorted = vm.sortedResults XCTAssertEqual(sorted.first?.tripIndex, 1) } func test_canVote_RequiresRankingAllTrips() { let poll = TripPoll(title: "Test", ownerId: "owner", tripSnapshots: [.mock(), .mock()]) let vm = PollDetailViewModel(poll: poll) vm.draftRankings = [] XCTAssertFalse(vm.canSubmitVote) vm.draftRankings = [0] XCTAssertFalse(vm.canSubmitVote) vm.draftRankings = [0, 1] XCTAssertTrue(vm.canSubmitVote) } } ``` **Step 2: Run test to verify it fails** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollDetailViewModelTests test 2>&1 | tail -20 ``` Expected: FAIL - PollDetailViewModel not defined **Step 3: Implement ViewModel** ```swift // SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift import Foundation import SwiftUI @MainActor @Observable final class PollDetailViewModel { let poll: TripPoll var votes: [PollVote] = [] var currentUserVote: PollVote? var draftRankings: [Int] = [] var isLoading = false var isVoting = false var error: Error? var currentUserId: String? init(poll: TripPoll) { self.poll = poll self.draftRankings = Array(poll.tripSnapshots.indices) } // MARK: - Computed Properties var hasVoted: Bool { currentUserVote != nil } var voterCount: Int { votes.count } var canSubmitVote: Bool { draftRankings.count == poll.tripSnapshots.count && Set(draftRankings).count == poll.tripSnapshots.count } var isOwner: Bool { currentUserId == poll.ownerId } var sortedResults: [(tripIndex: Int, score: Int, percentage: Double)] { let scores = PollService.aggregateVotes(votes, tripCount: poll.tripSnapshots.count) let maxScore = scores.max() ?? 1 return scores.enumerated() .map { (index: $0.offset, score: $0.element, percentage: maxScore > 0 ? Double($0.element) / Double(maxScore) : 0) } .sorted { $0.score > $1.score } } // MARK: - Actions func loadData() async { isLoading = true error = nil do { // Get current user ID currentUserId = try await PollService.shared.getCurrentUserRecordID() // Fetch all votes votes = try await PollService.shared.fetchVotes(forPollId: poll.id) // Find current user's vote if let userId = currentUserId { currentUserVote = votes.first { $0.odg == userId } if let existingVote = currentUserVote { draftRankings = existingVote.rankings } } // Subscribe to updates try? await PollService.shared.subscribeToVoteUpdates(forPollId: poll.id) } catch { self.error = error } isLoading = false } func refreshVotes() async { do { votes = try await PollService.shared.fetchVotes(forPollId: poll.id) } catch { self.error = error } } func submitVote() async { guard canSubmitVote, let userId = currentUserId else { return } isVoting = true error = nil do { if var existingVote = currentUserVote { // Update existing vote existingVote.rankings = draftRankings try await PollService.shared.updateVote(existingVote) currentUserVote = existingVote } else { // Create new vote let vote = PollVote(pollId: poll.id, odg: userId, rankings: draftRankings) try await PollService.shared.submitVote(vote) currentUserVote = vote } // Refresh all votes await refreshVotes() } catch { self.error = error } isVoting = false } func moveTrip(from source: IndexSet, to destination: Int) { draftRankings.move(fromOffsets: source, toOffset: destination) } } ``` **Step 4: Run test to verify it passes** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/PollDetailViewModelTests test 2>&1 | tail -20 ``` Expected: PASS **Step 5: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Features/Polls/ViewModels/PollDetailViewModel.swift \ SportsTimeTests/ViewModels/PollDetailViewModelTests.swift && \ git commit -m "feat(poll): add PollDetailViewModel - Vote loading and submission - Ranked results calculation - Draft rankings for voting UI" ``` --- ## Task 7: Poll Creation View **Files:** - Create: `SportsTime/Features/Polls/Views/PollCreationView.swift` **Step 1: Create the view** ```swift // SportsTime/Features/Polls/Views/PollCreationView.swift import SwiftUI struct PollCreationView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @State private var viewModel = PollCreationViewModel() let trips: [Trip] var body: some View { NavigationStack { Form { Section { TextField("Poll Title", text: $viewModel.title) .textContentType(.name) } header: { Text("Title") } footer: { Text("Give your poll a name your friends will recognize") } Section { ForEach(trips, id: \.id) { trip in TripSelectionRow( trip: trip, isSelected: viewModel.isSelected(trip), onTap: { viewModel.toggleTrip(trip) } ) } } header: { Text("Select Trips") } footer: { Text(viewModel.tripCountLabel + " (minimum 2)") } } .navigationTitle("Create Poll") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Create") { Task { await viewModel.createPoll() } } .disabled(!viewModel.canCreate || viewModel.isLoading) } } .overlay { if viewModel.isLoading { ProgressView("Creating poll...") .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) } } .sheet(item: Binding( get: { viewModel.createdPoll }, set: { _ in } )) { poll in PollShareSheet(poll: poll) { dismiss() } } .alert("Error", isPresented: .constant(viewModel.error != nil)) { Button("OK") { viewModel.error = nil } } message: { Text(viewModel.error?.localizedDescription ?? "Unknown error") } } } } // MARK: - Trip Selection Row private struct TripSelectionRow: View { let trip: Trip let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack { VStack(alignment: .leading, spacing: 4) { Text(trip.name) .font(.headline) .foregroundStyle(.primary) Text(trip.formattedDateRange) .font(.caption) .foregroundStyle(.secondary) Text("\(trip.cities.prefix(3).joined(separator: " → "))\(trip.cities.count > 3 ? "..." : "")") .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .font(.title2) .foregroundStyle(isSelected ? Theme.warmOrange : .secondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) } } // MARK: - Share Sheet struct PollShareSheet: View { let poll: TripPoll let onDone: () -> Void @State private var hasCopied = false var body: some View { NavigationStack { VStack(spacing: 24) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 60)) .foregroundStyle(.green) Text("Poll Created!") .font(.title2.bold()) VStack(spacing: 8) { Text("Share this code with friends:") .foregroundStyle(.secondary) Text(poll.shareCode) .font(.system(size: 36, weight: .bold, design: .monospaced)) .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) } VStack(spacing: 12) { ShareLink(item: poll.shareURL) { Label("Share Link", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) .tint(Theme.warmOrange) Button { UIPasteboard.general.string = poll.shareCode hasCopied = true } label: { Label(hasCopied ? "Copied!" : "Copy Code", systemImage: hasCopied ? "checkmark" : "doc.on.doc") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } .padding(.horizontal) Spacer() } .padding(.top, 40) .navigationTitle("Share Poll") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", action: onDone) } } } } } extension TripPoll: Identifiable {} ``` **Step 2: Build to verify compilation** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Features/Polls/Views/PollCreationView.swift && \ git commit -m "feat(poll): add PollCreationView - Trip selection with checkmarks - Title input - Share sheet with code and link" ``` --- ## Task 8: Poll Detail View **Files:** - Create: `SportsTime/Features/Polls/Views/PollDetailView.swift` **Step 1: Create the view** ```swift // SportsTime/Features/Polls/Views/PollDetailView.swift import SwiftUI struct PollDetailView: View { @Environment(\.colorScheme) private var colorScheme @State private var viewModel: PollDetailViewModel @State private var showingVoteSheet = false @State private var selectedTrip: Trip? init(poll: TripPoll) { _viewModel = State(initialValue: PollDetailViewModel(poll: poll)) } var body: some View { ScrollView { VStack(spacing: 20) { // Header headerSection // Results if !viewModel.votes.isEmpty { resultsSection } // Trip Cards tripsSection // Vote Button voteButton } .padding() } .navigationTitle(viewModel.poll.title) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .primaryAction) { ShareLink(item: viewModel.poll.shareURL) { Image(systemName: "square.and.arrow.up") } } } .refreshable { await viewModel.refreshVotes() } .task { await viewModel.loadData() } .sheet(isPresented: $showingVoteSheet) { VoteRankingSheet(viewModel: viewModel) } .sheet(item: $selectedTrip) { trip in NavigationStack { TripDetailView(trip: trip, games: [:]) } } } // MARK: - Header private var headerSection: some View { VStack(spacing: 8) { HStack { Label("\(viewModel.voterCount) voted", systemImage: "person.2.fill") .font(.subheadline) .foregroundStyle(.secondary) Spacer() Text("Code: \(viewModel.poll.shareCode)") .font(.caption.monospaced()) .foregroundStyle(.secondary) } if viewModel.hasVoted { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("You voted") .font(.subheadline) Spacer() } } } .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) } // MARK: - Results private var resultsSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Current Results") .font(.headline) ForEach(viewModel.sortedResults, id: \.tripIndex) { result in let trip = viewModel.poll.tripSnapshots[result.tripIndex] HStack { Text(trip.name) .font(.subheadline) .lineLimit(1) Spacer() Text("\(result.score) pts") .font(.caption) .foregroundStyle(.secondary) } GeometryReader { geo in RoundedRectangle(cornerRadius: 4) .fill(Theme.warmOrange.opacity(0.3)) .frame(width: geo.size.width * result.percentage) .overlay(alignment: .leading) { RoundedRectangle(cornerRadius: 4) .fill(Theme.warmOrange) .frame(width: geo.size.width * result.percentage) } } .frame(height: 8) } } .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) } // MARK: - Trips private var tripsSection: some View { VStack(alignment: .leading, spacing: 12) { Text("Trip Options") .font(.headline) ForEach(Array(viewModel.poll.tripSnapshots.enumerated()), id: \.offset) { index, trip in PollTripCard(trip: trip, rank: rankForTrip(index)) { selectedTrip = trip } } } } private func rankForTrip(_ index: Int) -> Int? { guard let position = viewModel.sortedResults.firstIndex(where: { $0.tripIndex == index }) else { return nil } return position + 1 } // MARK: - Vote Button private var voteButton: some View { Button { showingVoteSheet = true } label: { Label(viewModel.hasVoted ? "Change Vote" : "Vote", systemImage: "hand.raised.fill") .frame(maxWidth: .infinity) .padding() .background(Theme.warmOrange) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 12)) } } } // MARK: - Poll Trip Card private struct PollTripCard: View { let trip: Trip let rank: Int? let onTap: () -> Void var body: some View { Button(action: onTap) { HStack { if let rank = rank { Text("#\(rank)") .font(.caption.bold()) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(rank == 1 ? Color.green : Color.secondary) .clipShape(Capsule()) } VStack(alignment: .leading, spacing: 4) { Text(trip.name) .font(.headline) .foregroundStyle(.primary) Text(trip.formattedDateRange) .font(.caption) .foregroundStyle(.secondary) HStack { Label("\(trip.totalGames) games", systemImage: "sportscourt") Label(trip.formattedTotalDistance, systemImage: "car") } .font(.caption2) .foregroundStyle(.secondary) } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.secondary) } .padding() .background(Color(.systemGray6)) .clipShape(RoundedRectangle(cornerRadius: 12)) } .buttonStyle(.plain) } } // MARK: - Vote Ranking Sheet struct VoteRankingSheet: View { @Environment(\.dismiss) private var dismiss @Bindable var viewModel: PollDetailViewModel var body: some View { NavigationStack { VStack(spacing: 16) { Text("Drag to rank trips from favorite (top) to least favorite (bottom)") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) List { ForEach(Array(viewModel.draftRankings.enumerated()), id: \.offset) { position, tripIndex in let trip = viewModel.poll.tripSnapshots[tripIndex] HStack { Text("\(position + 1).") .font(.headline) .foregroundStyle(Theme.warmOrange) .frame(width: 30) VStack(alignment: .leading) { Text(trip.name) .font(.headline) Text(trip.formattedDateRange) .font(.caption) .foregroundStyle(.secondary) } Spacer() Image(systemName: "line.3.horizontal") .foregroundStyle(.secondary) } } .onMove { source, destination in viewModel.moveTrip(from: source, to: destination) } } .listStyle(.plain) .environment(\.editMode, .constant(.active)) } .navigationTitle("Rank Trips") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } ToolbarItem(placement: .confirmationAction) { Button("Submit") { Task { await viewModel.submitVote() dismiss() } } .disabled(!viewModel.canSubmitVote || viewModel.isVoting) } } .overlay { if viewModel.isVoting { ProgressView("Submitting...") .padding() .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12)) } } } } } extension Trip: Identifiable {} ``` **Step 2: Build to verify compilation** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Features/Polls/Views/PollDetailView.swift && \ git commit -m "feat(poll): add PollDetailView - Results bar chart - Trip cards with rank badges - Vote ranking sheet with drag reorder" ``` --- ## Task 9: Integrate Polls into My Trips Tab **Files:** - Modify: `SportsTime/Features/Home/Views/HomeView.swift` **Step 1: Add polls section to SavedTripsListView** Find `SavedTripsListView` in HomeView.swift (around line 431) and add polls section. In the `SavedTripsListView` struct, add: ```swift // Add these properties @Query(sort: \LocalTripPoll.modifiedAt, order: .reverse) private var localPolls: [LocalTripPoll] @State private var showCreatePoll = false @State private var isSelectingTrips = false @State private var selectedTripIds: Set = [] // In the body, add a section before the trips list: Section { if localPolls.isEmpty { ContentUnavailableView { Label("No Polls", systemImage: "chart.bar.doc.horizontal") } description: { Text("Create a poll to let friends vote on trip options") } } else { ForEach(localPolls) { localPoll in if let poll = localPoll.poll { NavigationLink { PollDetailView(poll: poll) } label: { PollRowView(poll: poll, isOwner: localPoll.isOwner) } } } } } header: { HStack { Text("Polls") Spacer() if !trips.isEmpty { Button(isSelectingTrips ? "Cancel" : "Create") { isSelectingTrips.toggle() selectedTripIds.removeAll() } .font(.subheadline) } } } // Add toolbar button for creating poll from selection .toolbar { if isSelectingTrips && selectedTripIds.count >= 2 { ToolbarItem(placement: .primaryAction) { Button("Create Poll") { showCreatePoll = true } } } } .sheet(isPresented: $showCreatePoll) { let selectedTrips = trips.compactMap { $0.trip }.filter { selectedTripIds.contains($0.id) } PollCreationView(trips: selectedTrips) } ``` **Step 2: Create PollRowView component** Add to HomeView.swift: ```swift // MARK: - Poll Row View private struct PollRowView: View { let poll: TripPoll let isOwner: Bool var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(poll.title) .font(.headline) if isOwner { Text("Owner") .font(.caption2) .foregroundStyle(.white) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.warmOrange) .clipShape(Capsule()) } } HStack { Label("\(poll.tripSnapshots.count) trips", systemImage: "suitcase") Text("•") Text("Code: \(poll.shareCode)") .font(.caption.monospaced()) } .font(.caption) .foregroundStyle(.secondary) } } } ``` **Step 3: Build to verify compilation** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 4: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Features/Home/Views/HomeView.swift && \ git commit -m "feat(poll): integrate polls section into My Trips tab - Polls section with create button - Trip selection mode for poll creation - Poll row with owner badge" ``` --- ## Task 10: Deep Link Handling **Files:** - Modify: `SportsTime/SportsTimeApp.swift` - Create: `SportsTime/Core/Services/DeepLinkHandler.swift` **Step 1: Create DeepLinkHandler** ```swift // SportsTime/Core/Services/DeepLinkHandler.swift import Foundation import SwiftUI @MainActor @Observable final class DeepLinkHandler { static let shared = DeepLinkHandler() var pendingPollShareCode: String? var pendingPoll: TripPoll? var showPollDetail = false var isLoading = false var error: Error? private init() {} func handleURL(_ url: URL) { // sportstime://poll/X7K9M2 guard url.scheme == "sportstime", url.host == "poll", let shareCode = url.pathComponents.last, shareCode.count == 6 else { return } pendingPollShareCode = shareCode Task { await loadPoll(shareCode: shareCode) } } private func loadPoll(shareCode: String) async { isLoading = true error = nil do { if let poll = try await PollService.shared.fetchPoll(byShareCode: shareCode) { pendingPoll = poll showPollDetail = true } else { error = DeepLinkError.pollNotFound } } catch { self.error = error } isLoading = false } func clearPending() { pendingPollShareCode = nil pendingPoll = nil showPollDetail = false } } enum DeepLinkError: LocalizedError { case pollNotFound var errorDescription: String? { switch self { case .pollNotFound: return "This poll no longer exists or the code is invalid." } } } ``` **Step 2: Integrate into App** In `SportsTimeApp.swift`, add: ```swift // Add property @State private var deepLinkHandler = DeepLinkHandler.shared // Add onOpenURL modifier to main view .onOpenURL { url in deepLinkHandler.handleURL(url) } .sheet(isPresented: $deepLinkHandler.showPollDetail) { if let poll = deepLinkHandler.pendingPoll { NavigationStack { PollDetailView(poll: poll) } } } .alert("Error", isPresented: .constant(deepLinkHandler.error != nil)) { Button("OK") { deepLinkHandler.error = nil } } message: { Text(deepLinkHandler.error?.localizedDescription ?? "") } ``` **Step 3: Build to verify compilation** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 4: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTime/Core/Services/DeepLinkHandler.swift SportsTime/SportsTimeApp.swift && \ git commit -m "feat(poll): add deep link handling for poll URLs - Parse sportstime://poll/CODE URLs - Load poll from CloudKit - Show poll detail sheet" ``` --- ## Task 11: Add Test Mocks **Files:** - Create: `SportsTimeTests/Mocks/MockData+Polls.swift` **Step 1: Create mock extensions** ```swift // SportsTimeTests/Mocks/MockData+Polls.swift import Foundation @testable import SportsTime extension Trip { static func mock( id: UUID = UUID(), name: String = "Test Trip", stops: [TripStop] = [.mock()] ) -> Trip { Trip( id: id, name: name, preferences: TripPreferences( startDate: Date(), endDate: Date().addingTimeInterval(86400 * 7), sports: [.mlb] ), stops: stops, travelSegments: [], totalGames: stops.flatMap { $0.games }.count, totalDistanceMeters: 1000, totalDrivingSeconds: 3600 ) } } extension TripStop { static func mock( city: String = "Boston", games: [String] = ["game1"] ) -> TripStop { TripStop( city: city, state: "MA", latitude: 42.3601, longitude: -71.0589, arrivalDate: Date(), departureDate: Date().addingTimeInterval(86400), games: games ) } } extension TripPoll { static func mock( title: String = "Test Poll", tripCount: Int = 2 ) -> TripPoll { TripPoll( title: title, ownerId: "mockOwner", tripSnapshots: (0.. PollVote { PollVote( pollId: pollId, odg: "mockVoter", rankings: rankings ) } } ``` **Step 2: Build and run all poll tests** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' \ -only-testing:SportsTimeTests/TripPollTests \ -only-testing:SportsTimeTests/PollCreationViewModelTests \ -only-testing:SportsTimeTests/PollDetailViewModelTests test 2>&1 | tail -30 ``` Expected: All tests PASS **Step 3: Commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add SportsTimeTests/Mocks/MockData+Polls.swift && \ git commit -m "test(poll): add mock data extensions for poll tests" ``` --- ## Task 12: Final Integration Test **Step 1: Run full test suite** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50 ``` **Step 2: Run build** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ xcodebuild -project SportsTime.xcodeproj -scheme SportsTime \ -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20 ``` Expected: BUILD SUCCEEDED **Step 3: Final commit** ```bash cd /Users/treyt/Desktop/code/SportsTime/.worktrees/group-trip-polling && \ git add -A && git status # If any uncommitted changes, commit them git log --oneline -10 ``` --- ## Summary This plan implements group trip polling with: 1. **Domain Models**: `TripPoll`, `PollVote`, `PollResults` 2. **CloudKit Models**: `CKTripPoll`, `CKPollVote` for public database 3. **SwiftData Models**: `LocalTripPoll`, `LocalPollVote` for offline caching 4. **PollService**: CloudKit CRUD, subscriptions, vote aggregation 5. **ViewModels**: `PollCreationViewModel`, `PollDetailViewModel` 6. **Views**: `PollCreationView`, `PollDetailView`, `VoteRankingSheet` 7. **Integration**: Polls section in My Trips tab 8. **Deep Links**: `sportstime://poll/CODE` URL handling Total: 12 tasks, ~40 commits