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