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
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"
Task 10: Deep Link Handling
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:
- Domain Models:
TripPoll,PollVote,PollResults - CloudKit Models:
CKTripPoll,CKPollVotefor public database - SwiftData Models:
LocalTripPoll,LocalPollVotefor offline caching - PollService: CloudKit CRUD, subscriptions, vote aggregation
- ViewModels:
PollCreationViewModel,PollDetailViewModel - Views:
PollCreationView,PollDetailView,VoteRankingSheet - Integration: Polls section in My Trips tab
- Deep Links:
sportstime://poll/CODEURL handling
Total: 12 tasks, ~40 commits