From cd68ba834b623fd9952f9ab21fee79672170dfcd Mon Sep 17 00:00:00 2001 From: Trey t Date: Tue, 13 Jan 2026 20:47:30 -0600 Subject: [PATCH] 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 --- .../2026-01-13-group-trip-polling-design.md | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 docs/plans/2026-01-13-group-trip-polling-design.md diff --git a/docs/plans/2026-01-13-group-trip-polling-design.md b/docs/plans/2026-01-13-group-trip-polling-design.md new file mode 100644 index 0000000..ca58c52 --- /dev/null +++ b/docs/plans/2026-01-13-group-trip-polling-design.md @@ -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