Phase 1: Semantic Position Model - Standard stack identified (SwiftData, Double sortOrder, CloudKit) - Architecture patterns documented (semantic position, midpoint insertion) - Pitfalls catalogued (row index confusion, precision exhaustion) - Existing codebase analysis confirms good foundation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
15 KiB
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:
# 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:
// 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:
// 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:
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:
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)
// 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)
// 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)
// 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)
// 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:
-
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
-
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
-
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 - 52-bit mantissa, ~15-16 decimal digits precision
Secondary (MEDIUM confidence)
- SwiftData: How to Preserve Array Order - sortIndex pattern for SwiftData
- Reordering: Tables and Fractional Indexing (Steve Ruiz) - Midpoint insertion algorithm, precision limits (~52 iterations)
- OrderedRelationship macro - Alternative approach with random integers
Tertiary (LOW confidence)
- Fractional Indexing concepts (vlcn.io) - General fractional indexing theory
- Hacking with Swift: SwiftData sorting - @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)