Design for CloudKit-based group coordination feature: - Ranked choice voting on trip options - Share via link with 6-char codes - Anonymous results (aggregate only) - Real-time updates via CloudKit subscriptions Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
6.9 KiB
6.9 KiB
Group Trip Polling Design
Design finalized: January 13, 2026
Overview
Allow users to group multiple saved trips into a poll, share with friends via link, and collect ranked-choice votes to decide which trip to take. CloudKit-only implementation using the public database.
Core Concepts
- Poll: A container grouping 2-10 trips for voting
- Ranked choice voting: Each voter orders trips by preference (1st, 2nd, 3rd...)
- Anonymous results: Votes deduplicated by iCloud ID, but UI only shows aggregates (no "John voted X")
- Share via link: 6-character code, anyone with app can join
- Owner control: Only owner modifies poll; trip edits reset all votes
Data Model
CloudKit Record Types (Public Database)
TripPoll
| Field | Type | Description |
|---|---|---|
| pollId | String | UUID, primary key |
| ownerId | String | Creator's userRecordID |
| title | String | Display name ("Summer 2026 Options") |
| shareCode | String | 6-char alphanumeric for sharing |
| tripSnapshots | Data | Encoded [Trip] array |
| tripVersions | [String] | Hash per trip for change detection |
| createdAt | Date | Creation timestamp |
| modifiedAt | Date | Last modification (triggers push) |
PollVote
| Field | Type | Description |
|---|---|---|
| voteId | String | UUID, primary key |
| pollId | String | Reference to TripPoll |
| odg | String | Voter's userRecordID |
| rankings | Data | Encoded [Int] (trip indices in preference order) |
| votedAt | Date | Vote timestamp |
| modifiedAt | Date | Last update |
Local SwiftData Models
@Model
final class TripPoll {
@Attribute(.unique) var id: UUID
var cloudRecordId: String?
var title: String
var shareCode: String
var ownerId: String
var tripSnapshots: Data // Encoded [Trip]
var tripVersions: [String]
var createdAt: Date
var modifiedAt: Date
var isOwner: Bool
@Relationship(deleteRule: .cascade)
var localVotes: [LocalPollVote]?
}
@Model
final class LocalPollVote {
@Attribute(.unique) var id: UUID
var odg: String
var rankings: Data
var votedAt: Date
}
User Flows
Creating a Poll
- User navigates to "My Trips" tab
- Taps "Select" and picks 2-10 trips
- Taps "Create Poll"
- Enters poll title
- App snapshots trips, generates 6-char share code
- Creates TripPoll in CloudKit public database
- Shows share sheet with link:
sportstime://poll/X7K9M2
Joining a Poll
- Recipient taps shared link
- App opens via URL scheme/universal link
- Fetches TripPoll where shareCode matches
- Shows poll detail view
- User's iCloud identity captured automatically
Voting
- Participant views poll with trip summary cards
- Can tap any trip for full detail view
- Taps "Vote" to enter ranking mode
- Drags to reorder trips (favorite at top)
- Taps "Submit Vote"
- Creates/updates PollVote record (keyed by pollId + odg)
- Can change vote anytime
Viewing Results
Results use Borda count scoring: (N - rank + 1) points per vote.
Example (3 trips, 4 voters):
- Trip A: 1st, 1st, 2nd, 3rd → 3+3+2+1 = 9 points
- Trip B: 2nd, 2nd, 1st, 1st → 2+2+3+3 = 10 points (winner)
- Trip C: 3rd, 3rd, 3rd, 2nd → 1+1+1+2 = 5 points
Display shows:
- "4 people have voted"
- Trips sorted by score with relative bars
- "Your vote: #2" indicator
- No individual voter attribution
Real-Time Updates
Hybrid approach for reliability:
- CloudKit subscription on PollVote records for this poll
- Push notification triggers refetch when anyone votes
- Refresh on view appear as fallback
- Pull-to-refresh for manual update
Expected behavior: Updates appear within seconds 90%+ of the time, always eventually consistent.
Trip Modification Handling
When owner edits a trip that's in an active poll:
- App computes hash of trip's key properties
- Compares to stored tripVersions[index]
- If different, shows confirmation: "Updating will reset all votes. Continue?"
- On confirm:
- Updates tripSnapshots with new data
- Updates tripVersions with new hash
- Deletes all PollVote records for this poll
- Increments modifiedAt (triggers push)
- Participants see: "Poll updated. Please vote again."
Properties that trigger reset: stops, games, dates, route. Properties that don't: trip name only.
Share Code Design
- 6 characters: uppercase letters + digits
- Excludes ambiguous characters: 0, O, 1, I, L
- Character set: ABCDEFGHJKMNPQRSTUVWXYZ23456789 (32 chars)
- Combinations: 32^6 = ~1 billion
- Validate uniqueness on creation, retry on collision
UI Structure
Navigation
Polls appear as a section in the existing "My Trips" tab:
My Trips Tab
├── Saved Trips section (existing)
│ └── Trip rows...
├── My Polls section (new)
│ └── Polls you created
└── Shared Polls section (new)
└── Polls you joined
New Screens
Poll List View
- Section headers: "My Polls", "Shared with Me"
- Row: title, trip count, voter count, vote status
Poll Detail View
- Header: title, share button, voter count
- Results visualization (bar chart)
- Trip cards (tappable for detail)
- "Vote" / "Change Vote" button
- Owner actions: edit, delete
Vote Ranking View
- Instruction text
- Draggable trip cards
- "Submit Vote" button
Sync Strategy
| Scenario | Behavior |
|---|---|
| Owner creates poll | Write to CloudKit, cache locally |
| Participant joins | Fetch from CloudKit, cache locally |
| Vote submitted | Write to CloudKit, update local cache |
| App opens poll | Fetch latest, update cache |
| Offline | Show cached data, queue vote for sync |
Error Handling
| Scenario | Response |
|---|---|
| iCloud not signed in | Prompt to sign in; polls require iCloud |
| Network error on create | Show error, stay on creation screen |
| Network error on vote | Queue locally, show "pending sync" |
| Network error on fetch | Show cached version with "Last updated" |
| Invalid share code | "Poll no longer exists or code is invalid" |
| Owner deletes poll | Participants see "Poll was deleted" |
Security Considerations
- Votes deduplicated by iCloud userRecordID (one vote per Apple ID)
- Voters cannot spoof another user's ID (CloudKit authenticates server-side)
- Share codes are unguessable (6-char from 32-char set)
- Risk accepted: anyone with link can vote (appropriate for friend groups)
- Anonymity is UI-level; owner could query raw CloudKit data
Constraints
- Maximum 10 trips per poll (UX limitation)
- Requires iCloud sign-in
- Requires app installation (no web viewer)
- Poll never formally closes; owner picks winner manually
Integration Notes
- No changes to existing SavedTrip model
- Polls snapshot trips (no foreign key relationship)
- Uses existing CloudKitService container
- New record types in public database alongside existing ones
Out of Scope
- Expense splitting
- Chat/comments on polls
- Deadline-based voting
- Web-based poll viewing
- Formal poll closing/archival