From eae3a1d7f7195ff5ea75dc3159b4b6815bc5db4b Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 18 Jan 2026 13:40:31 -0600 Subject: [PATCH] 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 --- .../01-semantic-position-model/01-RESEARCH.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 .planning/phases/01-semantic-position-model/01-RESEARCH.md diff --git a/.planning/phases/01-semantic-position-model/01-RESEARCH.md b/.planning/phases/01-semantic-position-model/01-RESEARCH.md new file mode 100644 index 0000000..fc9aab0 --- /dev/null +++ b/.planning/phases/01-semantic-position-model/01-RESEARCH.md @@ -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)