Files
Sportstime/docs/plans/2026-01-13-group-trip-polling.md
Trey t 8e78828bde 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
2026-01-13 21:01:58 -06:00

66 KiB

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

// 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<Character> = ["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

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

// 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

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

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

// 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

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:

// 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

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

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

// 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

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:

// 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

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

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

// 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

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

// 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

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

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

// 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

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

// 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

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

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

// 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

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

// 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

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

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

// 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

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

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

// 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

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

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:

// 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<UUID> = []

// 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:

// 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

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

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"

Files:

  • Modify: SportsTime/SportsTimeApp.swift
  • Create: SportsTime/Core/Services/DeepLinkHandler.swift

Step 1: Create DeepLinkHandler

// 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:

// 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

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

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

// 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..<tripCount).map { Trip.mock(name: "Trip \($0)") }
        )
    }
}

extension PollVote {
    static func mock(
        pollId: UUID = UUID(),
        rankings: [Int] = [0, 1]
    ) -> PollVote {
        PollVote(
            pollId: pollId,
            odg: "mockVoter",
            rankings: rankings
        )
    }
}

Step 2: Build and run all poll tests

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

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

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

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

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