Files
SportstimeAPI/.planning/phases/01-semantic-position-model/01-RESEARCH.md
Trey t eae3a1d7f7 docs(01): research semantic position model
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>
2026-01-18 13:40:31 -06:00

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

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
}
// 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)
        }
    }
}
// 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 - 52-bit mantissa, ~15-16 decimal digits precision

Secondary (MEDIUM confidence)

Tertiary (LOW confidence)

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)