From 8e78828bde037aa1164932ae8d386ee033b6410e Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 21:01:58 -0600 Subject: [PATCH] docs: add group trip polling implementation plan 12 tasks with TDD workflow: - Domain, CloudKit, and SwiftData models - PollService for CloudKit operations - Creation and detail ViewModels - SwiftUI views with vote ranking - Deep link handling --- docs/plans/2026-01-13-group-trip-polling.md | 2253 +++++++++++++++++++ 1 file changed, 2253 insertions(+) create mode 100644 docs/plans/2026-01-13-group-trip-polling.md diff --git a/docs/plans/2026-01-13-group-trip-polling.md b/docs/plans/2026-01-13-group-trip-polling.md new file mode 100644 index 0000000..bae4d71 --- /dev/null +++ b/docs/plans/2026-01-13-group-trip-polling.md @@ -0,0 +1,2253 @@ +# 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