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