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