Files
Sportstime/docs/plans/2026-01-13-group-trip-polling-design.md
Trey t cd68ba834b 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>
2026-01-13 20:47:30 -06:00

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

  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