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:
Trey t
2026-01-13 20:47:30 -06:00
parent bb332ade3c
commit cd68ba834b

View 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