# Phase 1: Semantic Position Model - Research **Researched:** 2026-01-18 **Domain:** Swift data modeling with sortOrder-based positioning, SwiftData persistence **Confidence:** HIGH ## Summary This phase establishes the semantic position model `(day: Int, sortOrder: Double)` for itinerary items. The existing codebase already has a well-designed `ItineraryItem` struct with the correct fields and a `LocalItineraryItem` SwiftData model for persistence. The research confirms that the current Double-based sortOrder approach is sufficient for typical use (supports ~52 midpoint insertions before precision loss), and documents the patterns needed for reliable sortOrder assignment, midpoint insertion, and normalization. The codebase is well-positioned for this phase: `ItineraryItem.swift` already defines the semantic model, `LocalItineraryItem` in SwiftData persists it, and `ItineraryItemService.swift` handles CloudKit sync. The main work is implementing the sortOrder initialization logic for games based on game time, ensuring consistent midpoint insertion, and adding normalization as a safety net. **Primary recommendation:** Leverage existing `ItineraryItem` model. Implement `SortOrderProvider` utility for initial assignment and midpoint calculation. Add normalization threshold check (gap < 1e-10) with rebalancing. ## Standard Stack The established libraries/tools for this domain: ### Core | Library | Version | Purpose | Why Standard | |---------|---------|---------|--------------| | SwiftData | iOS 26+ | Local persistence | Already used for `LocalItineraryItem` | | Foundation `Double` | Swift stdlib | sortOrder storage | 53-bit mantissa = ~52 midpoint insertions | | CloudKit | iOS 26+ | Remote sync | Already integrated in `ItineraryItemService` | ### Supporting | Library | Version | Purpose | When to Use | |---------|---------|---------|-------------| | Swift `Calendar` | Swift stdlib | Day boundary calculation | Deriving day number from trip.startDate | ### Alternatives Considered | Instead of | Could Use | Tradeoff | |------------|-----------|----------| | Double sortOrder | String-based fractional indexing | Unlimited insertions vs added complexity; Double is sufficient for typical itinerary use | | Manual sortOrder | OrderedRelationship macro | Macro adds dependency; manual approach gives more control for constraint validation | | SwiftData | UserDefaults/JSON | SwiftData already in use; consistency with existing architecture | **Installation:** ```bash # No additional dependencies - uses existing Swift/iOS frameworks ``` ## Architecture Patterns ### Recommended Project Structure ``` SportsTime/Core/Models/Domain/ ItineraryItem.swift # (exists) Semantic model SortOrderProvider.swift # (new) sortOrder calculation utilities SportsTime/Core/Models/Local/ SavedTrip.swift # (exists) Contains LocalItineraryItem SportsTime/Core/Services/ ItineraryItemService.swift # (exists) CloudKit persistence ``` ### Pattern 1: Semantic Position as Source of Truth **What:** All item positions stored as `(day: Int, sortOrder: Double)`, never row indices **When to use:** Any position-related logic, persistence, validation **Example:** ```swift // Source: Existing codebase ItineraryItem.swift struct ItineraryItem: Identifiable, Codable, Hashable { let id: UUID let tripId: UUID var day: Int // 1-indexed day number var sortOrder: Double // Position within day (fractional) var kind: ItemKind var modifiedAt: Date } ``` ### Pattern 2: Integer Spacing for Initial Assignment **What:** Assign initial sortOrder values using integer spacing (1, 2, 3...) derived from game times **When to use:** Creating itinerary items from a trip's games **Example:** ```swift // Derive sortOrder from game time (minutes since midnight) func initialSortOrder(for gameTime: Date) -> Double { let calendar = Calendar.current let components = calendar.dateComponents([.hour, .minute], from: gameTime) let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0) // Scale to reasonable range: 0-1440 minutes -> 100-1540 sortOrder return 100.0 + Double(minutesSinceMidnight) } ``` ### Pattern 3: Midpoint Insertion for Placement **What:** Calculate sortOrder as midpoint between adjacent items when inserting **When to use:** Placing travel segments or custom items between existing items **Example:** ```swift func sortOrderBetween(_ above: Double, _ below: Double) -> Double { return (above + below) / 2.0 } func sortOrderBefore(_ first: Double) -> Double { return first - 1.0 // Or first / 2.0 for "before start" items } func sortOrderAfter(_ last: Double) -> Double { return last + 1.0 } ``` ### Pattern 4: Day Derivation from Trip Start Date **What:** Day number = calendar days since trip.startDate + 1 **When to use:** Assigning items to days, validating day boundaries **Example:** ```swift func dayNumber(for date: Date, tripStartDate: Date) -> Int { let calendar = Calendar.current let startDay = calendar.startOfDay(for: tripStartDate) let targetDay = calendar.startOfDay(for: date) let days = calendar.dateComponents([.day], from: startDay, to: targetDay).day ?? 0 return days + 1 // 1-indexed } ``` ### Anti-Patterns to Avoid - **Storing row indices:** Never persist UITableView row indices; always use semantic (day, sortOrder) - **Hard-coded flatten order:** Don't build display order as "header, travel, games, custom" - sort by sortOrder - **Calculating sortOrder from row index:** Only calculate sortOrder at insertion/drop time using midpoint algorithm - **Travel as day property:** Travel is an ItineraryItem with its own (day, sortOrder), not a "travelBefore" day property ## Don't Hand-Roll Problems that look simple but have existing solutions: | Problem | Don't Build | Use Instead | Why | |---------|-------------|-------------|-----| | SwiftData array ordering | Custom sync logic | sortIndex pattern with sorted computed property | Arrays in SwiftData relationships are unordered by default | | CloudKit field mapping | Manual CKRecord conversion | Existing `ItineraryItem.toCKRecord()` extension | Already implemented correctly | | Day boundary calculation | Manual date arithmetic | `Calendar.dateComponents([.day], from:to:)` | Handles DST, leap seconds, etc. | | Precision checking | Manual epsilon comparison | `abs(a - b) < 1e-10` pattern | Standard floating-point comparison | **Key insight:** The existing codebase already has correct implementations for most of these patterns. The task is to ensure they're used consistently and to add the sortOrder assignment/midpoint logic. ## Common Pitfalls ### Pitfall 1: Row Index vs Semantic Position Confusion **What goes wrong:** Code treats UITableView row indices as source of truth instead of semantic (day, sortOrder) **Why it happens:** UITableView's `moveRowAt:to:` gives row indices; tempting to use directly **How to avoid:** Immediately convert row index to semantic position at drop time; never persist row indices **Warning signs:** `indexPath.row` stored anywhere except during active drag ### Pitfall 2: sortOrder Precision Exhaustion **What goes wrong:** After many midpoint insertions, adjacent items get sortOrder values too close to distinguish **Why it happens:** Double has 52-bit mantissa = ~52 midpoint insertions before precision loss **How to avoid:** Monitor gap size; normalize when `abs(a.sortOrder - b.sortOrder) < 1e-10` **Warning signs:** Items render in wrong order despite "correct" sortOrder; sortOrder comparison returns equal ### Pitfall 3: Treating Travel as Structural **What goes wrong:** Travel stored as `travelBefore` day property instead of positioned item **Why it happens:** Intuitive to think "travel happens before Day 3" vs "travel has day=3, sortOrder=-1" **How to avoid:** Travel is an `ItineraryItem` with `kind: .travel(TravelInfo)` and its own (day, sortOrder) **Warning signs:** `travelBefore` or `travelDay` as day property; different code paths for travel vs custom items ### Pitfall 4: SwiftData Array Order Loss **What goes wrong:** Array order in SwiftData relationships appears random after reload **Why it happens:** SwiftData relationships are backed by unordered database tables **How to avoid:** Use `sortOrder` field and sort when accessing; use computed property pattern **Warning signs:** Items in correct order during session but shuffled after app restart ### Pitfall 5: Game sortOrder Drift **What goes wrong:** Game sortOrder values diverge from game time order over time **Why it happens:** Games get sortOrder from row index on reload instead of game time **How to avoid:** Derive game sortOrder from game time, not from current position; games are immovable anchors **Warning signs:** Games appear in wrong time order after editing trip ## Code Examples Verified patterns from existing codebase and research: ### ItineraryItem Model (Existing) ```swift // Source: SportsTime/Core/Models/Domain/ItineraryItem.swift struct ItineraryItem: Identifiable, Codable, Hashable { let id: UUID let tripId: UUID var day: Int // 1-indexed day number var sortOrder: Double // Position within day (fractional) var kind: ItemKind var modifiedAt: Date } enum ItemKind: Codable, Hashable { case game(gameId: String) case travel(TravelInfo) case custom(CustomInfo) } ``` ### LocalItineraryItem SwiftData Model (Existing) ```swift // Source: SportsTime/Core/Models/Local/SavedTrip.swift @Model final class LocalItineraryItem { @Attribute(.unique) var id: UUID var tripId: UUID var day: Int var sortOrder: Double var kindData: Data // Encoded ItineraryItem.Kind var modifiedAt: Date var pendingSync: Bool } ``` ### SortOrder Provider (New - Recommended) ```swift // Source: Research synthesis - new utility to implement enum SortOrderProvider { /// Initial sortOrder for a game based on its start time /// Games get sortOrder = 100 + minutes since midnight (range: 100-1540) static func initialSortOrder(forGameTime gameTime: Date) -> Double { let calendar = Calendar.current let components = calendar.dateComponents([.hour, .minute], from: gameTime) let minutesSinceMidnight = (components.hour ?? 0) * 60 + (components.minute ?? 0) return 100.0 + Double(minutesSinceMidnight) } /// sortOrder for insertion between two existing items static func sortOrderBetween(_ above: Double, _ below: Double) -> Double { return (above + below) / 2.0 } /// sortOrder for insertion before the first item static func sortOrderBefore(_ first: Double) -> Double { // Use negative values for "before games" items return first - 1.0 } /// sortOrder for insertion after the last item static func sortOrderAfter(_ last: Double) -> Double { return last + 1.0 } /// Check if normalization is needed (gap too small) static func needsNormalization(_ items: [ItineraryItem]) -> Bool { let sorted = items.sorted { $0.sortOrder < $1.sortOrder } for i in 0..<(sorted.count - 1) { if abs(sorted[i].sortOrder - sorted[i + 1].sortOrder) < 1e-10 { return true } } return false } /// Normalize sortOrder values to integer spacing static func normalize(_ items: inout [ItineraryItem]) { items.sort { $0.sortOrder < $1.sortOrder } for (index, _) in items.enumerated() { items[index].sortOrder = Double(index + 1) } } } ``` ### Day Derivation (Recommended Pattern) ```swift // Source: Research synthesis - consistent day calculation extension Trip { /// Calculate day number for a given date (1-indexed) func dayNumber(for date: Date) -> Int { let calendar = Calendar.current let startDay = calendar.startOfDay(for: startDate) let targetDay = calendar.startOfDay(for: date) let days = calendar.dateComponents([.day], from: startDay, to: targetDay).day ?? 0 return days + 1 } /// Get the calendar date for a given day number func date(forDay dayNumber: Int) -> Date? { let calendar = Calendar.current let startDay = calendar.startOfDay(for: startDate) return calendar.date(byAdding: .day, value: dayNumber - 1, to: startDay) } } ``` ## State of the Art | Old Approach | Current Approach | When Changed | Impact | |--------------|------------------|--------------|--------| | Row index as position | Semantic (day, sortOrder) | SportsTime v1 | Core architecture decision | | Integer sortOrder | Double sortOrder with midpoint | Industry standard | Enables unlimited insertions | | Travel as day property | Travel as positioned item | SportsTime refactor | Unified item handling | **Deprecated/outdated:** - Storing row indices for persistence (causes position loss on reload) - Using consecutive integers without gaps (requires renumbering on every insert) ## Open Questions Things that couldn't be fully resolved: 1. **Negative sortOrder vs offset for "before games" items** - What we know: Both approaches work; negative values are simpler conceptually - What's unclear: User preference for which is cleaner - Recommendation: Use negative sortOrder for items before games (sortOrder < 100); simpler math, clearer semantics 2. **Exact normalization threshold** - What we know: 1e-10 is a safe threshold for Double precision issues - What's unclear: Whether to normalize proactively or only on precision exhaustion - Recommendation: Check on save; normalize if any gap < 1e-10; this is defensive and rare in practice 3. **Position preservation on trip edit** - What we know: When user edits trip (adds/removes games), existing items may need repositioning - What's unclear: Whether to preserve exact sortOrder or recompute relative positions - Recommendation: Preserve sortOrder where possible; only recompute if items become orphaned (their day no longer exists) ## Sources ### Primary (HIGH confidence) - Existing codebase: `ItineraryItem.swift`, `SavedTrip.swift` (LocalItineraryItem), `ItineraryItemService.swift`, `ItineraryConstraints.swift` - Existing codebase: `ItineraryTableViewWrapper.swift` (flattening implementation) - [IEEE 754 Double precision](https://en.wikipedia.org/wiki/Double-precision_floating-point_format) - 52-bit mantissa, ~15-16 decimal digits precision ### Secondary (MEDIUM confidence) - [SwiftData: How to Preserve Array Order](https://medium.com/@jc_builds/swiftdata-how-to-preserve-array-order-in-a-swiftdata-model-6ea1b895ed50) - sortIndex pattern for SwiftData - [Reordering: Tables and Fractional Indexing (Steve Ruiz)](https://www.steveruiz.me/posts/reordering-fractional-indices) - Midpoint insertion algorithm, precision limits (~52 iterations) - [OrderedRelationship macro](https://github.com/FiveSheepCo/OrderedRelationship) - Alternative approach with random integers ### Tertiary (LOW confidence) - [Fractional Indexing concepts (vlcn.io)](https://vlcn.io/blog/fractional-indexing) - General fractional indexing theory - [Hacking with Swift: SwiftData sorting](https://www.hackingwithswift.com/quick-start/swiftdata/sorting-query-results) - @Query sort patterns ## Metadata **Confidence breakdown:** - Standard stack: HIGH - Using existing frameworks already in codebase - Architecture: HIGH - Patterns verified against existing implementation - Pitfalls: HIGH - Documented in existing PITFALLS.md research and verified against codebase **Research date:** 2026-01-18 **Valid until:** Indefinite (foundational patterns, not framework-version-dependent)