docs: add group trip polling design
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>
This commit is contained in:
238
docs/plans/2026-01-13-group-trip-polling-design.md
Normal file
238
docs/plans/2026-01-13-group-trip-polling-design.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# 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
|
||||
|
||||
```swift
|
||||
@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
|
||||
|
||||
1. User navigates to "My Trips" tab
|
||||
2. Taps "Select" and picks 2-10 trips
|
||||
3. Taps "Create Poll"
|
||||
4. Enters poll title
|
||||
5. App snapshots trips, generates 6-char share code
|
||||
6. Creates TripPoll in CloudKit public database
|
||||
7. Shows share sheet with link: `sportstime://poll/X7K9M2`
|
||||
|
||||
### Joining a Poll
|
||||
|
||||
1. Recipient taps shared link
|
||||
2. App opens via URL scheme/universal link
|
||||
3. Fetches TripPoll where shareCode matches
|
||||
4. Shows poll detail view
|
||||
5. User's iCloud identity captured automatically
|
||||
|
||||
### Voting
|
||||
|
||||
1. Participant views poll with trip summary cards
|
||||
2. Can tap any trip for full detail view
|
||||
3. Taps "Vote" to enter ranking mode
|
||||
4. Drags to reorder trips (favorite at top)
|
||||
5. Taps "Submit Vote"
|
||||
6. Creates/updates PollVote record (keyed by pollId + odg)
|
||||
7. 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:
|
||||
|
||||
1. **CloudKit subscription** on PollVote records for this poll
|
||||
2. **Push notification** triggers refetch when anyone votes
|
||||
3. **Refresh on view appear** as fallback
|
||||
4. **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:
|
||||
|
||||
1. App computes hash of trip's key properties
|
||||
2. Compares to stored tripVersions[index]
|
||||
3. If different, shows confirmation: "Updating will reset all votes. Continue?"
|
||||
4. On confirm:
|
||||
- Updates tripSnapshots with new data
|
||||
- Updates tripVersions with new hash
|
||||
- Deletes all PollVote records for this poll
|
||||
- Increments modifiedAt (triggers push)
|
||||
5. 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
|
||||
Reference in New Issue
Block a user