diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 302ff17..7fcf942 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -45,8 +45,8 @@ Plans:
**Plans:** 2 plans
Plans:
-- [ ] 02-01-PLAN.md - Migrate XCTest constraint tests to Swift Testing
-- [ ] 02-02-PLAN.md - Add edge case tests and document constraint API
+- [x] 02-01-PLAN.md - Migrate XCTest constraint tests to Swift Testing
+- [x] 02-02-PLAN.md - Add edge case tests and document constraint API
**Requirements:**
- CONS-01: Games cannot be moved (fixed by schedule)
@@ -68,6 +68,12 @@ Plans:
**Dependencies:** Phase 2 (constraints inform what items exist per day)
+**Plans:** 2 plans
+
+Plans:
+- [ ] 03-01-PLAN.md - Create ItineraryFlattener utility and refactor reloadData()
+- [ ] 03-02-PLAN.md - Add determinism tests for flattening behavior
+
**Requirements:**
- FLAT-01: Visual flattening sorts by sortOrder within each day
- FLAT-02: Flattening is deterministic and stateless
@@ -109,12 +115,12 @@ Plans:
| Phase | Status | Requirements | Completed |
|-------|--------|--------------|-----------|
-| 1 - Semantic Position Model | ✓ Complete | 8 | 8 |
-| 2 - Constraint Validation | Planned | 4 | 0 |
-| 3 - Visual Flattening | Not Started | 3 | 0 |
+| 1 - Semantic Position Model | Complete | 8 | 8 |
+| 2 - Constraint Validation | Complete | 4 | 4 |
+| 3 - Visual Flattening | In Progress | 3 | 0 |
| 4 - Drag Interaction | Not Started | 8 | 0 |
-**Total:** 8/23 requirements completed
+**Total:** 12/23 requirements completed
---
*Roadmap created: 2026-01-18*
diff --git a/.planning/phases/03-visual-flattening/03-01-PLAN.md b/.planning/phases/03-visual-flattening/03-01-PLAN.md
new file mode 100644
index 0000000..aef12ea
--- /dev/null
+++ b/.planning/phases/03-visual-flattening/03-01-PLAN.md
@@ -0,0 +1,180 @@
+---
+phase: 03-visual-flattening
+plan: 01
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - SportsTime/Core/Models/Domain/ItineraryFlattener.swift
+ - SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+autonomous: true
+
+must_haves:
+ truths:
+ - "Items with lower sortOrder appear before items with higher sortOrder within each day"
+ - "Items with sortOrder < 0 appear before games"
+ - "Items with sortOrder >= 0 appear after or between games"
+ - "Day headers always appear first in their day's section"
+ artifacts:
+ - path: "SportsTime/Core/Models/Domain/ItineraryFlattener.swift"
+ provides: "Pure flatten function"
+ exports: ["flatten(days:itineraryItems:gamesByDay:)"]
+ - path: "SportsTime/Features/Trip/Views/ItineraryTableViewController.swift"
+ provides: "Refactored reloadData() using ItineraryFlattener"
+ contains: "ItineraryFlattener.flatten"
+ key_links:
+ - from: "ItineraryTableViewController.reloadData()"
+ to: "ItineraryFlattener.flatten()"
+ via: "function call"
+ pattern: "ItineraryFlattener\\.flatten"
+---
+
+
+Create a pure ItineraryFlattener utility that transforms hierarchical day data into display rows sorted by sortOrder.
+
+Purpose: Replace the bucket-based flattening (beforeGames/afterGames arrays) with pure sortOrder sorting, ensuring deterministic display order.
+
+Output: New `ItineraryFlattener.swift` file and refactored `reloadData()` method using it.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/03-visual-flattening/03-RESEARCH.md
+@.planning/phases/03-visual-flattening/03-CONTEXT.md
+@SportsTime/Core/Models/Domain/SortOrderProvider.swift
+@SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+@SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
+
+
+
+
+
+ Task 1: Create ItineraryFlattener utility
+ SportsTime/Core/Models/Domain/ItineraryFlattener.swift
+
+Create a new `ItineraryFlattener.swift` file with a pure flatten function.
+
+The function signature:
+```swift
+enum ItineraryFlattener {
+ static func flatten(
+ days: [ItineraryDayData],
+ itineraryItems: [ItineraryItem],
+ gamesByDay: [Int: [RichGame]]
+ ) -> [ItineraryRowItem]
+}
+```
+
+Implementation logic:
+1. For each day in order:
+ a. Add `.dayHeader` row (always first for each day)
+ b. Collect all positionable items with their sortOrder:
+ - Games: use `SortOrderProvider.initialSortOrder(forGameTime:)` for first game's time
+ - Custom items: use `item.sortOrder` directly
+ - Travel items: look up sortOrder from `itineraryItems` array by matching city names
+ c. Sort collected items by sortOrder (ascending)
+ d. Append sorted items to result
+
+Key decisions (from CONTEXT.md):
+- sortOrder < 0 appears BEFORE games (games have sortOrder 100-1540)
+- sortOrder >= 0 appears AFTER/BETWEEN games
+- Day header is NOT a positioned item - always first
+
+Do NOT:
+- Split into beforeGames/afterGames buckets
+- Use hardcoded order assumptions
+- Special-case travel with `travelBefore` property
+
+
+Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -5`
+
+
+`ItineraryFlattener.swift` exists with `flatten()` function that:
+- Sorts all items within a day by sortOrder
+- Day headers appear first in each day
+- Function is pure (no side effects, no instance state)
+
+
+
+
+ Task 2: Refactor reloadData() to use ItineraryFlattener
+ SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
+
+Refactor the `reloadData()` method (currently lines 484-545) to use the new `ItineraryFlattener.flatten()` function.
+
+Current flow to replace:
+```swift
+// OLD: Bucket-based approach
+var beforeGames: [ItineraryRowItem] = []
+var afterGames: [ItineraryRowItem] = []
+for row in day.items {
+ if sortOrder < 0 { beforeGames.append(row) }
+ else { afterGames.append(row) }
+}
+flatItems.append(contentsOf: beforeGames)
+flatItems.append(.games(...))
+flatItems.append(contentsOf: afterGames)
+```
+
+New flow:
+```swift
+// NEW: Pure sortOrder-based approach
+let gamesByDay = Dictionary(grouping: days.flatMap { ($0.games, $0.dayNumber) }) { ... }
+flatItems = ItineraryFlattener.flatten(
+ days: days,
+ itineraryItems: allItineraryItems,
+ gamesByDay: gamesByDay
+)
+```
+
+The refactored method should:
+1. Build `gamesByDay` dictionary: `[Int: [RichGame]]`
+2. Call `ItineraryFlattener.flatten()` to get `flatItems`
+3. Reload table view
+
+Preserve:
+- `self.travelValidRanges = travelValidRanges`
+- `self.allItineraryItems = itineraryItems`
+- `self.tripDayCount = days.count`
+- `self.constraints = ItineraryConstraints(...)` rebuild
+- `tableView.reloadData()` call at end
+
+Remove:
+- The manual flattening loop with beforeGames/afterGames buckets
+- Any remaining `travelBefore` references in the flatten logic
+
+
+Build succeeds: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -5`
+
+
+`reloadData()` method:
+- Uses `ItineraryFlattener.flatten()` instead of manual loop
+- No longer contains beforeGames/afterGames bucket logic
+- Still updates travelValidRanges, allItineraryItems, tripDayCount, constraints
+
+
+
+
+
+
+1. Build succeeds with no errors
+2. `ItineraryFlattener.swift` exists in `SportsTime/Core/Models/Domain/`
+3. `reloadData()` in `ItineraryTableViewController.swift` calls `ItineraryFlattener.flatten()`
+4. No `beforeGames` or `afterGames` variables remain in `reloadData()`
+
+
+
+- FLAT-01: Items sorted by sortOrder within each day (implemented in ItineraryFlattener)
+- FLAT-03: sortOrder < 0 before games, >= 0 after (handled by natural sort + game sortOrder range 100-1540)
+
+
+
diff --git a/.planning/phases/03-visual-flattening/03-02-PLAN.md b/.planning/phases/03-visual-flattening/03-02-PLAN.md
new file mode 100644
index 0000000..01f9440
--- /dev/null
+++ b/.planning/phases/03-visual-flattening/03-02-PLAN.md
@@ -0,0 +1,183 @@
+---
+phase: 03-visual-flattening
+plan: 02
+type: execute
+wave: 2
+depends_on: ["03-01"]
+files_modified:
+ - SportsTimeTests/ItineraryFlattenerTests.swift
+autonomous: true
+
+must_haves:
+ truths:
+ - "Same semantic state produces identical row order on repeated flattening"
+ - "Item with sortOrder -1.0 appears before all games in that day"
+ - "Reordering an item and re-flattening preserves the new order"
+ artifacts:
+ - path: "SportsTimeTests/ItineraryFlattenerTests.swift"
+ provides: "Determinism and ordering tests"
+ min_lines: 80
+ key_links:
+ - from: "ItineraryFlattenerTests"
+ to: "ItineraryFlattener.flatten()"
+ via: "test assertions"
+ pattern: "ItineraryFlattener\\.flatten"
+---
+
+
+Add tests verifying ItineraryFlattener produces deterministic, sortOrder-based output.
+
+Purpose: Ensure the three success criteria from ROADMAP.md are verifiable through automated tests.
+
+Output: New test file `ItineraryFlattenerTests.swift` with tests covering all success criteria.
+
+
+
+@~/.claude/get-shit-done/workflows/execute-plan.md
+@~/.claude/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/03-visual-flattening/03-RESEARCH.md
+@.planning/phases/03-visual-flattening/03-01-SUMMARY.md
+@SportsTime/Core/Models/Domain/ItineraryFlattener.swift
+@SportsTime/Core/Models/Domain/SortOrderProvider.swift
+@SportsTimeTests/ItineraryConstraintsTests.swift
+
+
+
+
+
+ Task 1: Create ItineraryFlattenerTests with determinism tests
+ SportsTimeTests/ItineraryFlattenerTests.swift
+
+Create a new test file `ItineraryFlattenerTests.swift` using Swift Testing (`@Suite`, `@Test`).
+
+Test suite structure:
+```swift
+import Testing
+@testable import SportsTime
+
+@Suite("ItineraryFlattener")
+struct ItineraryFlattenerTests {
+ // Test helper to create mock ItineraryDayData
+ // Test helper to create mock RichGame
+ // Test helper to create mock ItineraryItem
+}
+```
+
+Required tests (map directly to ROADMAP success criteria):
+
+1. **success_negativeSortOrderAppearsBeforeGames** (Success Criterion 1)
+ - Create item with sortOrder = -1.0
+ - Create games with sortOrder ~820 (noon game)
+ - Flatten and verify custom item index < games index
+ - Assert: "Item with sortOrder -1.0 appears before all games in that day's section"
+
+2. **success_sameStateProducesIdenticalOrder** (Success Criterion 2)
+ - Create fixed test data (2 days, games, travel, custom items)
+ - Flatten twice
+ - Compare resulting row IDs
+ - Assert: arrays are identical (not just equal length)
+
+3. **success_reorderPreservesNewOrder** (Success Criterion 3)
+ - Create items with sortOrder 1.0, 2.0, 3.0
+ - Simulate reorder: change item from 1.0 to 2.5
+ - Flatten
+ - Verify new order is [2.0, 2.5, 3.0] not [1.0, 2.0, 3.0]
+
+Additional tests for comprehensive coverage:
+
+4. **flatten_dayHeaderAlwaysFirst**
+ - Verify first item for each day is `.dayHeader`
+
+5. **flatten_sortsByDayThenSortOrder**
+ - Items on day 1 appear before items on day 2
+ - Within each day, items sorted by sortOrder
+
+6. **flatten_gamesGetSortOrderFromTime**
+ - Game at 8:00 PM (sortOrder ~1180) appears after item with sortOrder 500
+ - Game at 1:00 PM (sortOrder ~880) appears before item with sortOrder 1000
+
+7. **flatten_emptyDayHasOnlyHeader**
+ - Day with no games, no items produces only `.dayHeader` row
+
+8. **flatten_multipleGamesOnSameDay**
+ - Day with 2 games: both included in single `.games` row
+ - Custom items around games sort correctly
+
+Test helpers needed:
+- `makeGame(id:, stadium:, dateTime:)` -> RichGame
+- `makeItem(day:, sortOrder:, kind:)` -> ItineraryItem
+- `makeDayData(dayNumber:, date:, games:, items:)` -> ItineraryDayData
+
+
+Run tests: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryFlattenerTests test 2>&1 | grep -E "(Test Suite|passed|failed|error:)"`
+
+
+`ItineraryFlattenerTests.swift` exists with:
+- 3 success_* tests mapping to ROADMAP success criteria
+- 5+ additional tests for comprehensive coverage
+- All tests passing
+
+
+
+
+ Task 2: Verify existing behavior unchanged
+ SportsTimeTests/ItineraryFlattenerTests.swift
+
+Add integration-style tests that verify the refactored flattening produces expected behavior for realistic trip data.
+
+Additional tests:
+
+1. **flatten_travelWithNegativeSortOrderAppearsBeforeGames**
+ - Travel item with sortOrder = -5.0
+ - Should appear before games in same day
+
+2. **flatten_travelWithPositiveSortOrderAppearsAfterGames**
+ - Travel item with sortOrder = 1500.0 (after noon game)
+ - Should appear after games in same day
+
+3. **flatten_mixedItemTypes**
+ - Day with: travel (-5), custom (-1), games (820), custom (900), travel (1500)
+ - Verify exact order: header, travel, custom, games, custom, travel
+
+4. **flatten_multiDayTrip**
+ - 3-day trip with different item configurations per day
+ - Verify each day's items are independent and sorted correctly
+
+Run full test suite to ensure no regressions:
+`xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
+
+
+Full test suite passes: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Suite|passed|failed)" | tail -10`
+
+
+- All ItineraryFlattenerTests passing (12+ tests)
+- Full test suite passes (no regressions)
+- Tests cover all three ROADMAP success criteria
+
+
+
+
+
+
+1. All tests in `ItineraryFlattenerTests.swift` pass
+2. Tests explicitly named `success_*` map to ROADMAP success criteria:
+ - success_negativeSortOrderAppearsBeforeGames -> Criterion 1
+ - success_sameStateProducesIdenticalOrder -> Criterion 2
+ - success_reorderPreservesNewOrder -> Criterion 3
+3. Full test suite passes (no regressions from refactoring)
+
+
+
+- FLAT-02: Flattening is deterministic and stateless (verified by success_sameStateProducesIdenticalOrder test)
+- All three ROADMAP success criteria have corresponding tests
+
+
+