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>
This commit is contained in:
335
.planning/phases/01-semantic-position-model/01-RESEARCH.md
Normal file
335
.planning/phases/01-semantic-position-model/01-RESEARCH.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# 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)
|
||||||
Reference in New Issue
Block a user