1349 lines
42 KiB
Markdown
1349 lines
42 KiB
Markdown
# Itinerary Reorder Refactor Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Refactor TripDetailView itinerary with constrained drag-and-drop where games are fixed, travel has hard constraints, and custom items are freely movable.
|
|
|
|
**Architecture:** Unified `ItineraryItem` model replaces separate CustomItineraryItem and TravelDayOverride. A testable `ItineraryConstraints` type validates all drag positions. Full itinerary structure stored in CloudKit with debounced sync.
|
|
|
|
**Tech Stack:** Swift, SwiftUI, UIKit (UITableView for drag), CloudKit, SwiftData
|
|
|
|
---
|
|
|
|
## Phase 1: Data Model
|
|
|
|
### Task 1: Create ItineraryItem Model
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Models/Domain/ItineraryItem.swift`
|
|
|
|
**Step 1: Create the ItineraryItem struct and ItemKind enum**
|
|
|
|
```swift
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
/// Unified model for all itinerary items (games, travel, custom)
|
|
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
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
tripId: UUID,
|
|
day: Int,
|
|
sortOrder: Double,
|
|
kind: ItemKind,
|
|
modifiedAt: Date = Date()
|
|
) {
|
|
self.id = id
|
|
self.tripId = tripId
|
|
self.day = day
|
|
self.sortOrder = sortOrder
|
|
self.kind = kind
|
|
self.modifiedAt = modifiedAt
|
|
}
|
|
}
|
|
|
|
/// The type of itinerary item
|
|
enum ItemKind: Codable, Hashable {
|
|
case game(gameId: String)
|
|
case travel(TravelInfo)
|
|
case custom(CustomInfo)
|
|
}
|
|
|
|
/// Travel-specific information
|
|
struct TravelInfo: Codable, Hashable {
|
|
let fromCity: String
|
|
let toCity: String
|
|
var distanceMeters: Double?
|
|
var durationSeconds: Double?
|
|
|
|
var formattedDistance: String {
|
|
guard let meters = distanceMeters else { return "" }
|
|
let miles = meters / 1609.34
|
|
return String(format: "%.0f mi", miles)
|
|
}
|
|
|
|
var formattedDuration: String {
|
|
guard let seconds = durationSeconds else { return "" }
|
|
let hours = Int(seconds) / 3600
|
|
let minutes = (Int(seconds) % 3600) / 60
|
|
if hours > 0 {
|
|
return "\(hours)h \(minutes)m"
|
|
}
|
|
return "\(minutes)m"
|
|
}
|
|
}
|
|
|
|
/// Custom item information
|
|
struct CustomInfo: Codable, Hashable {
|
|
var title: String
|
|
var icon: String
|
|
var time: Date?
|
|
var latitude: Double?
|
|
var longitude: Double?
|
|
var address: String?
|
|
|
|
var coordinate: CLLocationCoordinate2D? {
|
|
guard let lat = latitude, let lon = longitude else { return nil }
|
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
|
}
|
|
|
|
var isMappable: Bool {
|
|
latitude != nil && longitude != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience Properties
|
|
|
|
extension ItineraryItem {
|
|
var isGame: Bool {
|
|
if case .game = kind { return true }
|
|
return false
|
|
}
|
|
|
|
var isTravel: Bool {
|
|
if case .travel = kind { return true }
|
|
return false
|
|
}
|
|
|
|
var isCustom: Bool {
|
|
if case .custom = kind { return true }
|
|
return false
|
|
}
|
|
|
|
var travelInfo: TravelInfo? {
|
|
if case .travel(let info) = kind { return info }
|
|
return nil
|
|
}
|
|
|
|
var customInfo: CustomInfo? {
|
|
if case .custom(let info) = kind { return info }
|
|
return nil
|
|
}
|
|
|
|
var gameId: String? {
|
|
if case .game(let id) = kind { return id }
|
|
return nil
|
|
}
|
|
|
|
/// Display title for the item
|
|
var displayTitle: String {
|
|
switch kind {
|
|
case .game(let gameId):
|
|
return "Game: \(gameId)"
|
|
case .travel(let info):
|
|
return "\(info.fromCity) → \(info.toCity)"
|
|
case .custom(let info):
|
|
return info.title
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify file compiles**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)"`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/Domain/ItineraryItem.swift
|
|
git commit -m "feat: add unified ItineraryItem model
|
|
|
|
Replaces CustomItineraryItem and TravelDayOverride with single model.
|
|
Supports game, travel, and custom item kinds.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: Create ItineraryConstraints Type
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Models/Domain/ItineraryConstraints.swift`
|
|
- Create: `SportsTimeTests/ItineraryConstraintsTests.swift`
|
|
|
|
**Step 1: Write failing tests for basic constraint validation**
|
|
|
|
```swift
|
|
import XCTest
|
|
@testable import SportsTime
|
|
|
|
final class ItineraryConstraintsTests: XCTestCase {
|
|
|
|
// MARK: - Custom Item Tests (No Constraints)
|
|
|
|
func test_customItem_canGoOnAnyDay() {
|
|
// Given: A 5-day trip with games on days 1 and 5
|
|
let constraints = makeConstraints(tripDays: 5, gameDays: [1, 5])
|
|
let customItem = makeCustomItem(day: 1, sortOrder: 50)
|
|
|
|
// When/Then: Custom item can go on any day
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 1, sortOrder: 50))
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50))
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 3, sortOrder: 50))
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 4, sortOrder: 50))
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 5, sortOrder: 50))
|
|
}
|
|
|
|
func test_customItem_canGoBeforeOrAfterGames() {
|
|
// Given: A day with a game at sortOrder 100
|
|
let constraints = makeConstraints(tripDays: 3, gameDays: [2])
|
|
let customItem = makeCustomItem(day: 2, sortOrder: 50)
|
|
|
|
// When/Then: Custom item can go before or after game
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 50)) // Before
|
|
XCTAssertTrue(constraints.isValidPosition(for: customItem, day: 2, sortOrder: 150)) // After
|
|
}
|
|
|
|
// MARK: - Travel Constraint Tests
|
|
|
|
func test_travel_validDayRange_simpleCase() {
|
|
// Given: Chicago game Day 1, Detroit game Day 3
|
|
let constraints = makeConstraints(
|
|
tripDays: 5,
|
|
games: [
|
|
makeGameItem(city: "Chicago", day: 1),
|
|
makeGameItem(city: "Detroit", day: 3)
|
|
]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 200)
|
|
|
|
// When
|
|
let range = constraints.validDayRange(for: travel)
|
|
|
|
// Then: Travel can be on days 1 (after game), 2, or 3 (before game)
|
|
XCTAssertEqual(range, 1...3)
|
|
}
|
|
|
|
func test_travel_mustBeAfterDepartureGames() {
|
|
// Given: Chicago game on Day 1 at sortOrder 100
|
|
let constraints = makeConstraints(
|
|
tripDays: 3,
|
|
games: [makeGameItem(city: "Chicago", day: 1, sortOrder: 100)]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
|
|
|
// When/Then: Travel before game is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))
|
|
|
|
// Travel after game is valid
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
|
|
}
|
|
|
|
func test_travel_mustBeBeforeArrivalGames() {
|
|
// Given: Detroit game on Day 3 at sortOrder 100
|
|
let constraints = makeConstraints(
|
|
tripDays: 3,
|
|
games: [makeGameItem(city: "Detroit", day: 3, sortOrder: 100)]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 150)
|
|
|
|
// When/Then: Travel after arrival game is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 150))
|
|
|
|
// Travel before game is valid
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
|
}
|
|
|
|
func test_travel_canBeAnywhereOnRestDays() {
|
|
// Given: Chicago game Day 1, Detroit game Day 4, rest days 2-3
|
|
let constraints = makeConstraints(
|
|
tripDays: 4,
|
|
games: [
|
|
makeGameItem(city: "Chicago", day: 1),
|
|
makeGameItem(city: "Detroit", day: 4)
|
|
]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 2, sortOrder: 50)
|
|
|
|
// When/Then: Any position on rest days is valid
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 1))
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 100))
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 2, sortOrder: 500))
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
|
}
|
|
|
|
func test_travel_mustBeAfterAllDepartureGamesOnSameDay() {
|
|
// Given: Two games in Chicago on Day 1 (1pm and 7pm)
|
|
let constraints = makeConstraints(
|
|
tripDays: 3,
|
|
games: [
|
|
makeGameItem(city: "Chicago", day: 1, sortOrder: 100), // 1pm
|
|
makeGameItem(city: "Chicago", day: 1, sortOrder: 101) // 7pm
|
|
]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 100.5)
|
|
|
|
// When/Then: Between games is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 100.5))
|
|
|
|
// After all games is valid
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 1, sortOrder: 150))
|
|
}
|
|
|
|
func test_travel_mustBeBeforeAllArrivalGamesOnSameDay() {
|
|
// Given: Two games in Detroit on Day 3 (1pm and 7pm)
|
|
let constraints = makeConstraints(
|
|
tripDays: 3,
|
|
games: [
|
|
makeGameItem(city: "Detroit", day: 3, sortOrder: 100), // 1pm
|
|
makeGameItem(city: "Detroit", day: 3, sortOrder: 101) // 7pm
|
|
]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 100.5)
|
|
|
|
// When/Then: Between games is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 3, sortOrder: 100.5))
|
|
|
|
// Before all games is valid
|
|
XCTAssertTrue(constraints.isValidPosition(for: travel, day: 3, sortOrder: 50))
|
|
}
|
|
|
|
func test_travel_cannotGoOutsideValidDayRange() {
|
|
// Given: Chicago game Day 2, Detroit game Day 4
|
|
let constraints = makeConstraints(
|
|
tripDays: 5,
|
|
games: [
|
|
makeGameItem(city: "Chicago", day: 2),
|
|
makeGameItem(city: "Detroit", day: 4)
|
|
]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 1, sortOrder: 50)
|
|
|
|
// When/Then: Day 1 (before departure game) is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 1, sortOrder: 50))
|
|
|
|
// Day 5 (after arrival game) is invalid
|
|
XCTAssertFalse(constraints.isValidPosition(for: travel, day: 5, sortOrder: 50))
|
|
}
|
|
|
|
// MARK: - Barrier Games Tests
|
|
|
|
func test_barrierGames_returnsDepartureAndArrivalGames() {
|
|
// Given: Chicago games Days 1-2, Detroit games Days 4-5
|
|
let chicagoGame1 = makeGameItem(city: "Chicago", day: 1, sortOrder: 100)
|
|
let chicagoGame2 = makeGameItem(city: "Chicago", day: 2, sortOrder: 100)
|
|
let detroitGame1 = makeGameItem(city: "Detroit", day: 4, sortOrder: 100)
|
|
let detroitGame2 = makeGameItem(city: "Detroit", day: 5, sortOrder: 100)
|
|
|
|
let constraints = makeConstraints(
|
|
tripDays: 5,
|
|
games: [chicagoGame1, chicagoGame2, detroitGame1, detroitGame2]
|
|
)
|
|
let travel = makeTravelItem(from: "Chicago", to: "Detroit", day: 3, sortOrder: 50)
|
|
|
|
// When
|
|
let barriers = constraints.barrierGames(for: travel)
|
|
|
|
// Then: Returns the last Chicago game and first Detroit game
|
|
XCTAssertEqual(barriers.count, 2)
|
|
XCTAssertTrue(barriers.contains { $0.id == chicagoGame2.id })
|
|
XCTAssertTrue(barriers.contains { $0.id == detroitGame1.id })
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private let testTripId = UUID()
|
|
|
|
private func makeConstraints(tripDays: Int, gameDays: [Int] = []) -> ItineraryConstraints {
|
|
let games = gameDays.map { makeGameItem(city: "TestCity", day: $0) }
|
|
return makeConstraints(tripDays: tripDays, games: games)
|
|
}
|
|
|
|
private func makeConstraints(tripDays: Int, games: [ItineraryItem]) -> ItineraryConstraints {
|
|
return ItineraryConstraints(tripDayCount: tripDays, items: games)
|
|
}
|
|
|
|
private func makeGameItem(city: String, day: Int, sortOrder: Double = 100) -> ItineraryItem {
|
|
// For tests, we use gameId to encode the city
|
|
return ItineraryItem(
|
|
tripId: testTripId,
|
|
day: day,
|
|
sortOrder: sortOrder,
|
|
kind: .game(gameId: "game-\(city)-\(UUID().uuidString.prefix(4))")
|
|
)
|
|
}
|
|
|
|
private func makeTravelItem(from: String, to: String, day: Int, sortOrder: Double) -> ItineraryItem {
|
|
return ItineraryItem(
|
|
tripId: testTripId,
|
|
day: day,
|
|
sortOrder: sortOrder,
|
|
kind: .travel(TravelInfo(fromCity: from, toCity: to))
|
|
)
|
|
}
|
|
|
|
private func makeCustomItem(day: Int, sortOrder: Double) -> ItineraryItem {
|
|
return ItineraryItem(
|
|
tripId: testTripId,
|
|
day: day,
|
|
sortOrder: sortOrder,
|
|
kind: .custom(CustomInfo(title: "Test Item", icon: "star"))
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run tests to verify they fail**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(error:|Test Case|passed|failed)"`
|
|
Expected: Compilation errors (ItineraryConstraints type doesn't exist)
|
|
|
|
**Step 3: Implement ItineraryConstraints**
|
|
|
|
```swift
|
|
import Foundation
|
|
|
|
/// Validates itinerary item positions and calculates valid drop zones
|
|
struct ItineraryConstraints {
|
|
let tripDayCount: Int
|
|
private let items: [ItineraryItem]
|
|
|
|
/// City extracted from game ID (format: "game-CityName-xxxx")
|
|
private func city(forGameId gameId: String) -> String? {
|
|
let components = gameId.components(separatedBy: "-")
|
|
guard components.count >= 2 else { return nil }
|
|
return components[1]
|
|
}
|
|
|
|
init(tripDayCount: Int, items: [ItineraryItem]) {
|
|
self.tripDayCount = tripDayCount
|
|
self.items = items
|
|
}
|
|
|
|
// MARK: - Public API
|
|
|
|
/// Check if a position is valid for an item
|
|
func isValidPosition(for item: ItineraryItem, day: Int, sortOrder: Double) -> Bool {
|
|
// Day must be within trip range
|
|
guard day >= 1 && day <= tripDayCount else { return false }
|
|
|
|
switch item.kind {
|
|
case .game:
|
|
// Games are fixed, should never be moved
|
|
return false
|
|
|
|
case .travel(let info):
|
|
return isValidTravelPosition(
|
|
fromCity: info.fromCity,
|
|
toCity: info.toCity,
|
|
day: day,
|
|
sortOrder: sortOrder
|
|
)
|
|
|
|
case .custom:
|
|
// Custom items can go anywhere
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Get the valid day range for a travel item
|
|
func validDayRange(for item: ItineraryItem) -> ClosedRange<Int>? {
|
|
guard case .travel(let info) = item.kind else { return nil }
|
|
|
|
let departureGameDays = gameDays(in: info.fromCity)
|
|
let arrivalGameDays = gameDays(in: info.toCity)
|
|
|
|
// Can leave on or after the day of last departure game
|
|
let minDay = departureGameDays.max() ?? 1
|
|
// Must arrive on or before the day of first arrival game
|
|
let maxDay = arrivalGameDays.min() ?? tripDayCount
|
|
|
|
guard minDay <= maxDay else { return nil }
|
|
return minDay...maxDay
|
|
}
|
|
|
|
/// Get the games that act as barriers for a travel item (for visual highlighting)
|
|
func barrierGames(for item: ItineraryItem) -> [ItineraryItem] {
|
|
guard case .travel(let info) = item.kind else { return [] }
|
|
|
|
var barriers: [ItineraryItem] = []
|
|
|
|
// Last game in departure city
|
|
let departureGames = games(in: info.fromCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
|
|
if let lastDeparture = departureGames.last {
|
|
barriers.append(lastDeparture)
|
|
}
|
|
|
|
// First game in arrival city
|
|
let arrivalGames = games(in: info.toCity).sorted { $0.day < $1.day || ($0.day == $1.day && $0.sortOrder < $1.sortOrder) }
|
|
if let firstArrival = arrivalGames.first {
|
|
barriers.append(firstArrival)
|
|
}
|
|
|
|
return barriers
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
private func isValidTravelPosition(fromCity: String, toCity: String, day: Int, sortOrder: Double) -> Bool {
|
|
let departureGameDays = gameDays(in: fromCity)
|
|
let arrivalGameDays = gameDays(in: toCity)
|
|
|
|
let minDay = departureGameDays.max() ?? 1
|
|
let maxDay = arrivalGameDays.min() ?? tripDayCount
|
|
|
|
// Check day is in valid range
|
|
guard day >= minDay && day <= maxDay else { return false }
|
|
|
|
// Check sortOrder constraints on edge days
|
|
if departureGameDays.contains(day) {
|
|
// On a departure game day: must be after ALL games in that city on that day
|
|
let maxGameSortOrder = games(in: fromCity)
|
|
.filter { $0.day == day }
|
|
.map { $0.sortOrder }
|
|
.max() ?? 0
|
|
|
|
if sortOrder <= maxGameSortOrder {
|
|
return false
|
|
}
|
|
}
|
|
|
|
if arrivalGameDays.contains(day) {
|
|
// On an arrival game day: must be before ALL games in that city on that day
|
|
let minGameSortOrder = games(in: toCity)
|
|
.filter { $0.day == day }
|
|
.map { $0.sortOrder }
|
|
.min() ?? Double.greatestFiniteMagnitude
|
|
|
|
if sortOrder >= minGameSortOrder {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func gameDays(in city: String) -> [Int] {
|
|
return games(in: city).map { $0.day }
|
|
}
|
|
|
|
private func games(in city: String) -> [ItineraryItem] {
|
|
return items.filter { item in
|
|
guard case .game(let gameId) = item.kind else { return false }
|
|
return self.city(forGameId: gameId) == city
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Run tests to verify they pass**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ItineraryConstraintsTests test 2>&1 | grep -E "(Test Case|passed|failed)"`
|
|
Expected: All tests pass
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/Domain/ItineraryConstraints.swift SportsTimeTests/ItineraryConstraintsTests.swift
|
|
git commit -m "feat: add ItineraryConstraints with full test coverage
|
|
|
|
Validates travel positions based on game locations:
|
|
- Travel must be after ALL departure city games
|
|
- Travel must be before ALL arrival city games
|
|
- Custom items have no constraints
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Service Layer
|
|
|
|
### Task 3: Create ItineraryItemService
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Core/Services/ItineraryItemService.swift`
|
|
|
|
**Step 1: Create the service with CloudKit CRUD operations**
|
|
|
|
```swift
|
|
import Foundation
|
|
import CloudKit
|
|
import Combine
|
|
|
|
/// Service for persisting and syncing ItineraryItems to CloudKit
|
|
actor ItineraryItemService {
|
|
static let shared = ItineraryItemService()
|
|
|
|
private let container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
|
private var database: CKDatabase { container.privateCloudDatabase }
|
|
|
|
private let recordType = "ItineraryItem"
|
|
|
|
// Debounce tracking
|
|
private var pendingUpdates: [UUID: ItineraryItem] = [:]
|
|
private var debounceTask: Task<Void, Never>?
|
|
|
|
private init() {}
|
|
|
|
// MARK: - CRUD Operations
|
|
|
|
/// Fetch all items for a trip
|
|
func fetchItems(forTripId tripId: UUID) async throws -> [ItineraryItem] {
|
|
let predicate = NSPredicate(format: "tripId == %@", tripId.uuidString)
|
|
let query = CKQuery(recordType: recordType, predicate: predicate)
|
|
|
|
let (results, _) = try await database.records(matching: query)
|
|
|
|
return results.compactMap { _, result in
|
|
guard case .success(let record) = result else { return nil }
|
|
return ItineraryItem(from: record)
|
|
}
|
|
}
|
|
|
|
/// Create a new item
|
|
func createItem(_ item: ItineraryItem) async throws -> ItineraryItem {
|
|
let record = item.toCKRecord()
|
|
let savedRecord = try await database.save(record)
|
|
return ItineraryItem(from: savedRecord) ?? item
|
|
}
|
|
|
|
/// Update an existing item (debounced)
|
|
func updateItem(_ item: ItineraryItem) async {
|
|
pendingUpdates[item.id] = item
|
|
|
|
// Cancel existing debounce
|
|
debounceTask?.cancel()
|
|
|
|
// Start new debounce
|
|
debounceTask = Task {
|
|
try? await Task.sleep(for: .seconds(1.5))
|
|
|
|
guard !Task.isCancelled else { return }
|
|
|
|
await flushPendingUpdates()
|
|
}
|
|
}
|
|
|
|
/// Force immediate sync of pending updates
|
|
func flushPendingUpdates() async {
|
|
let updates = pendingUpdates
|
|
pendingUpdates.removeAll()
|
|
|
|
for (_, item) in updates {
|
|
do {
|
|
let record = item.toCKRecord()
|
|
_ = try await database.save(record)
|
|
} catch {
|
|
// Silent retry - add back to pending
|
|
pendingUpdates[item.id] = item
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Delete an item
|
|
func deleteItem(_ itemId: UUID) async throws {
|
|
let recordId = CKRecord.ID(recordName: itemId.uuidString)
|
|
try await database.deleteRecord(withID: recordId)
|
|
}
|
|
|
|
/// Delete all items for a trip
|
|
func deleteItems(forTripId tripId: UUID) async throws {
|
|
let items = try await fetchItems(forTripId: tripId)
|
|
|
|
for item in items {
|
|
let recordId = CKRecord.ID(recordName: item.id.uuidString)
|
|
try? await database.deleteRecord(withID: recordId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - CloudKit Conversion
|
|
|
|
extension ItineraryItem {
|
|
init?(from record: CKRecord) {
|
|
guard let idString = record["itemId"] as? String,
|
|
let id = UUID(uuidString: idString),
|
|
let tripIdString = record["tripId"] as? String,
|
|
let tripId = UUID(uuidString: tripIdString),
|
|
let day = record["day"] as? Int,
|
|
let sortOrder = record["sortOrder"] as? Double,
|
|
let kindString = record["kind"] as? String,
|
|
let modifiedAt = record["modifiedAt"] as? Date else {
|
|
return nil
|
|
}
|
|
|
|
self.id = id
|
|
self.tripId = tripId
|
|
self.day = day
|
|
self.sortOrder = sortOrder
|
|
self.modifiedAt = modifiedAt
|
|
|
|
// Parse kind
|
|
switch kindString {
|
|
case "game":
|
|
guard let gameId = record["gameId"] as? String else { return nil }
|
|
self.kind = .game(gameId: gameId)
|
|
|
|
case "travel":
|
|
guard let fromCity = record["travelFromCity"] as? String,
|
|
let toCity = record["travelToCity"] as? String else { return nil }
|
|
let info = TravelInfo(
|
|
fromCity: fromCity,
|
|
toCity: toCity,
|
|
distanceMeters: record["travelDistanceMeters"] as? Double,
|
|
durationSeconds: record["travelDurationSeconds"] as? Double
|
|
)
|
|
self.kind = .travel(info)
|
|
|
|
case "custom":
|
|
guard let title = record["customTitle"] as? String,
|
|
let icon = record["customIcon"] as? String else { return nil }
|
|
let info = CustomInfo(
|
|
title: title,
|
|
icon: icon,
|
|
time: record["customTime"] as? Date,
|
|
latitude: record["latitude"] as? Double,
|
|
longitude: record["longitude"] as? Double,
|
|
address: record["address"] as? String
|
|
)
|
|
self.kind = .custom(info)
|
|
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func toCKRecord() -> CKRecord {
|
|
let recordId = CKRecord.ID(recordName: id.uuidString)
|
|
let record = CKRecord(recordType: "ItineraryItem", recordID: recordId)
|
|
|
|
record["itemId"] = id.uuidString
|
|
record["tripId"] = tripId.uuidString
|
|
record["day"] = day
|
|
record["sortOrder"] = sortOrder
|
|
record["modifiedAt"] = modifiedAt
|
|
|
|
switch kind {
|
|
case .game(let gameId):
|
|
record["kind"] = "game"
|
|
record["gameId"] = gameId
|
|
|
|
case .travel(let info):
|
|
record["kind"] = "travel"
|
|
record["travelFromCity"] = info.fromCity
|
|
record["travelToCity"] = info.toCity
|
|
record["travelDistanceMeters"] = info.distanceMeters
|
|
record["travelDurationSeconds"] = info.durationSeconds
|
|
|
|
case .custom(let info):
|
|
record["kind"] = "custom"
|
|
record["customTitle"] = info.title
|
|
record["customIcon"] = info.icon
|
|
record["customTime"] = info.time
|
|
record["latitude"] = info.latitude
|
|
record["longitude"] = info.longitude
|
|
record["address"] = info.address
|
|
}
|
|
|
|
return record
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Verify it compiles**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)"`
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Services/ItineraryItemService.swift
|
|
git commit -m "feat: add ItineraryItemService with CloudKit sync
|
|
|
|
Debounced updates (1.5s), local-first with silent retry.
|
|
Supports game, travel, and custom item kinds.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Delete Old Files
|
|
|
|
### Task 4: Remove Legacy Models and Services
|
|
|
|
**Files:**
|
|
- Delete: `SportsTime/Core/Models/Domain/CustomItineraryItem.swift`
|
|
- Delete: `SportsTime/Core/Models/Domain/TravelDayOverride.swift`
|
|
- Delete: `SportsTime/Core/Services/CustomItemService.swift`
|
|
- Delete: `SportsTime/Core/Services/CustomItemSubscriptionService.swift`
|
|
- Delete: `SportsTime/Core/Services/TravelOverrideService.swift`
|
|
- Delete: `SportsTime/Features/Trip/Views/CustomItemRow.swift`
|
|
|
|
**Step 1: Delete files and remove from Xcode project**
|
|
|
|
```bash
|
|
cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder
|
|
|
|
# Delete the files
|
|
rm -f SportsTime/Core/Models/Domain/CustomItineraryItem.swift
|
|
rm -f SportsTime/Core/Models/Domain/TravelDayOverride.swift
|
|
rm -f SportsTime/Core/Services/CustomItemService.swift
|
|
rm -f SportsTime/Core/Services/CustomItemSubscriptionService.swift
|
|
rm -f SportsTime/Core/Services/TravelOverrideService.swift
|
|
rm -f SportsTime/Features/Trip/Views/CustomItemRow.swift
|
|
```
|
|
|
|
**Step 2: Update TripDetailView to use new models (stub - will be completed in Phase 4)**
|
|
|
|
This step will cause compilation errors. We'll fix them in the next phase.
|
|
|
|
**Step 3: Commit deletions**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "chore: remove legacy itinerary models and services
|
|
|
|
Removed:
|
|
- CustomItineraryItem
|
|
- TravelDayOverride
|
|
- CustomItemService
|
|
- CustomItemSubscriptionService
|
|
- TravelOverrideService
|
|
- CustomItemRow
|
|
|
|
These are replaced by unified ItineraryItem model and service.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: UI Implementation
|
|
|
|
### Task 5: Create Itinerary Row Views
|
|
|
|
**Files:**
|
|
- Create: `SportsTime/Features/Trip/Views/ItineraryRows/DayHeaderRow.swift`
|
|
- Create: `SportsTime/Features/Trip/Views/ItineraryRows/GameItemRow.swift`
|
|
- Create: `SportsTime/Features/Trip/Views/ItineraryRows/TravelItemRow.swift`
|
|
- Create: `SportsTime/Features/Trip/Views/ItineraryRows/CustomItemRow.swift`
|
|
|
|
**Step 1: Create DayHeaderRow**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct DayHeaderRow: View {
|
|
let dayNumber: Int
|
|
let date: Date
|
|
let onAddTapped: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var formattedDate: String {
|
|
date.formatted(.dateTime.weekday(.wide).month().day())
|
|
}
|
|
|
|
var body: some View {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Day \(dayNumber)")
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
Text(formattedDate)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(action: onAddTapped) {
|
|
Image(systemName: "plus.circle.fill")
|
|
.font(.title2)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Create GameItemRow (prominent card)**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct GameItemRow: View {
|
|
let game: RichGame
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Sport color bar
|
|
SportColorBar(sport: game.game.sport)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
// Sport badge + Matchup
|
|
HStack(spacing: 6) {
|
|
HStack(spacing: 3) {
|
|
Image(systemName: game.game.sport.iconName)
|
|
.font(.caption2)
|
|
Text(game.game.sport.rawValue)
|
|
.font(.caption2)
|
|
}
|
|
.foregroundStyle(game.game.sport.themeColor)
|
|
|
|
HStack(spacing: 4) {
|
|
Text(game.awayTeam.abbreviation)
|
|
.font(.body)
|
|
Text("@")
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
Text(game.homeTeam.abbreviation)
|
|
.font(.body)
|
|
}
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
// Stadium
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "building.2")
|
|
.font(.caption2)
|
|
Text(game.stadium.name)
|
|
.font(.subheadline)
|
|
}
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Time
|
|
Text(game.localGameTimeShort)
|
|
.font(.subheadline)
|
|
.foregroundStyle(Theme.warmOrange)
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.strokeBorder(game.game.sport.themeColor.opacity(0.3), lineWidth: 1)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Create TravelItemRow (gold styling with drag handle)**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct TravelItemRow: View {
|
|
let item: ItineraryItem
|
|
let isHighlighted: Bool
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var travelInfo: TravelInfo? {
|
|
item.travelInfo
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Drag handle
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.title3)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
// Car icon
|
|
ZStack {
|
|
Circle()
|
|
.fill(Theme.routeGold.opacity(0.2))
|
|
.frame(width: 36, height: 36)
|
|
|
|
Image(systemName: "car.fill")
|
|
.font(.body)
|
|
.foregroundStyle(Theme.routeGold)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
if let info = travelInfo {
|
|
Text("\(info.fromCity) → \(info.toCity)")
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
|
|
HStack(spacing: Theme.Spacing.xs) {
|
|
if !info.formattedDistance.isEmpty {
|
|
Text(info.formattedDistance)
|
|
.font(.caption)
|
|
}
|
|
if !info.formattedDistance.isEmpty && !info.formattedDuration.isEmpty {
|
|
Text("•")
|
|
}
|
|
if !info.formattedDuration.isEmpty {
|
|
Text(info.formattedDuration)
|
|
.font(.caption)
|
|
}
|
|
}
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.padding(Theme.Spacing.md)
|
|
.background(Theme.cardBackground(colorScheme))
|
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
|
.strokeBorder(isHighlighted ? Theme.routeGold : Theme.routeGold.opacity(0.3), lineWidth: isHighlighted ? 2 : 1)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Create CustomItemRow (minimal with drag handle)**
|
|
|
|
```swift
|
|
import SwiftUI
|
|
|
|
struct CustomItemRow: View {
|
|
let item: ItineraryItem
|
|
let onTap: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
private var customInfo: CustomInfo? {
|
|
item.customInfo
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: onTap) {
|
|
HStack(spacing: Theme.Spacing.md) {
|
|
// Drag handle
|
|
Image(systemName: "line.3.horizontal")
|
|
.font(.title3)
|
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
|
|
|
// Icon
|
|
if let info = customInfo {
|
|
Text(info.icon)
|
|
.font(.title3)
|
|
}
|
|
|
|
// Title
|
|
if let info = customInfo {
|
|
Text(info.title)
|
|
.font(.body)
|
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Time (if set)
|
|
if let time = customInfo?.time {
|
|
Text(time.formatted(.dateTime.hour().minute()))
|
|
.font(.caption)
|
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
|
}
|
|
}
|
|
.padding(.vertical, Theme.Spacing.sm)
|
|
.padding(.horizontal, Theme.Spacing.md)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Verify compilation**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)"`
|
|
Expected: BUILD SUCCEEDED (or errors that we'll fix in next task)
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/ItineraryRows/
|
|
git commit -m "feat: add itinerary row components
|
|
|
|
- DayHeaderRow with add button
|
|
- GameItemRow (prominent card, no drag handle)
|
|
- TravelItemRow (gold styling, drag handle)
|
|
- CustomItemRow (minimal, drag handle)
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 6: Refactor TripDetailView Itinerary Section
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
This is a large refactor. The key changes:
|
|
|
|
1. Replace `customItems: [CustomItineraryItem]` with `itineraryItems: [ItineraryItem]`
|
|
2. Replace `travelDayOverrides` with items stored in `itineraryItems`
|
|
3. Remove legacy service imports
|
|
4. Update `itinerarySections` to use new model
|
|
5. Update all row rendering to use new components
|
|
|
|
**Step 1: Update state and imports**
|
|
|
|
In TripDetailView, replace:
|
|
- `@State private var customItems: [CustomItineraryItem] = []`
|
|
- `@State private var travelDayOverrides: [String: Int] = [:]`
|
|
|
|
With:
|
|
- `@State private var itineraryItems: [ItineraryItem] = []`
|
|
|
|
**Step 2: Update loading logic**
|
|
|
|
Replace `loadCustomItems()` and travel override loading with:
|
|
|
|
```swift
|
|
private func loadItineraryItems() async {
|
|
do {
|
|
let items = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id)
|
|
itineraryItems = items
|
|
} catch {
|
|
print("Failed to load itinerary items: \(error)")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Update sections building**
|
|
|
|
The `itinerarySections` computed property needs to be updated to:
|
|
1. Build games from trip data (as ItineraryItem with .game kind)
|
|
2. Include travel and custom items from `itineraryItems`
|
|
3. Sort by day then sortOrder
|
|
|
|
**Step 4: Update row rendering**
|
|
|
|
Update `itineraryRow(for:at:)` to use new row components.
|
|
|
|
**Step 5: Update drag/drop handlers**
|
|
|
|
Update all drag handlers to work with the new `ItineraryItem` model.
|
|
|
|
*Note: This is a large refactor. See the design document for full details.*
|
|
|
|
**Step 6: Verify compilation and test**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | grep -E "(error:|BUILD SUCCEEDED|BUILD FAILED)"`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "refactor: update TripDetailView to use unified ItineraryItem
|
|
|
|
- Replace CustomItineraryItem and TravelDayOverride with ItineraryItem
|
|
- Use ItineraryItemService for persistence
|
|
- Update itinerary sections building
|
|
- Update row rendering with new components
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Drag & Drop with Constraints
|
|
|
|
### Task 7: Implement Constraint-Aware Drag & Drop
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift`
|
|
|
|
**Step 1: Add constraint validation during drag**
|
|
|
|
Add properties:
|
|
```swift
|
|
private var constraints: ItineraryConstraints?
|
|
private var draggingItem: ItineraryItem?
|
|
private var invalidRowIndices: Set<Int> = []
|
|
private var barrierGameIds: Set<UUID> = []
|
|
```
|
|
|
|
**Step 2: Calculate invalid zones on drag start**
|
|
|
|
In `tableView(_:dragSessionWillBegin:)`:
|
|
```swift
|
|
// Pre-calculate invalid rows and barrier games
|
|
if let item = draggingItem {
|
|
if item.isTravel {
|
|
barrierGameIds = Set(constraints?.barrierGames(for: item).map(\.id) ?? [])
|
|
}
|
|
invalidRowIndices = calculateInvalidRows(for: item)
|
|
}
|
|
```
|
|
|
|
**Step 3: Apply visual feedback**
|
|
|
|
In `tableView(_:cellForRowAt:)`:
|
|
```swift
|
|
// Dim invalid zones
|
|
if draggingItem != nil && invalidRowIndices.contains(indexPath.row) {
|
|
cell.contentView.alpha = 0.3
|
|
}
|
|
|
|
// Highlight barrier games
|
|
if let gameItem = rowItem.gameItem, barrierGameIds.contains(gameItem.id) {
|
|
// Apply gold border highlight
|
|
}
|
|
```
|
|
|
|
**Step 4: Block invalid drops**
|
|
|
|
In `tableView(_:targetIndexPathForMoveFromRowAt:toProposedIndexPath:)`:
|
|
```swift
|
|
// Validate proposed position
|
|
let targetDay = dayNumber(forRow: proposed.row)
|
|
let targetSortOrder = calculateSortOrder(at: proposed.row)
|
|
|
|
if let item = draggingItem,
|
|
let constraints = constraints,
|
|
!constraints.isValidPosition(for: item, day: targetDay, sortOrder: targetSortOrder) {
|
|
// Find nearest valid position or return source
|
|
return findNearestValidPosition(for: item, from: proposed.row) ?? source
|
|
}
|
|
```
|
|
|
|
**Step 5: Add haptic feedback**
|
|
|
|
```swift
|
|
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
|
|
|
// On drag start
|
|
feedbackGenerator.prepare()
|
|
feedbackGenerator.impactOccurred()
|
|
|
|
// On valid zone hover
|
|
feedbackGenerator.impactOccurred(intensity: 0.5)
|
|
|
|
// On drop
|
|
feedbackGenerator.impactOccurred()
|
|
```
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
|
git commit -m "feat: add constraint-aware drag & drop
|
|
|
|
- Invalid zones dimmed during drag
|
|
- Barrier games highlighted for travel
|
|
- Drops blocked at invalid positions
|
|
- Haptic feedback on pickup, hover, drop
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6: Final Integration
|
|
|
|
### Task 8: Update AddItemSheet
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/AddItemSheet.swift`
|
|
|
|
Update to create `ItineraryItem` with `.custom` kind instead of `CustomItineraryItem`.
|
|
|
|
**Step 1: Update the item creation**
|
|
|
|
```swift
|
|
let newItem = ItineraryItem(
|
|
tripId: tripId,
|
|
day: day,
|
|
sortOrder: calculateNextSortOrder(),
|
|
kind: .custom(CustomInfo(
|
|
title: title,
|
|
icon: selectedCategory.icon,
|
|
time: selectedTime,
|
|
latitude: selectedLocation?.latitude,
|
|
longitude: selectedLocation?.longitude,
|
|
address: selectedAddress
|
|
))
|
|
)
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/AddItemSheet.swift
|
|
git commit -m "refactor: update AddItemSheet for ItineraryItem
|
|
|
|
Create ItineraryItem with .custom kind instead of CustomItineraryItem.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 9: Update PDF Export
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Export/PDFGenerator.swift`
|
|
|
|
Update to respect itinerary order from stored items.
|
|
|
|
**Step 1: Update the itinerary rendering**
|
|
|
|
Read items in sortOrder within each day instead of deriving order.
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Export/PDFGenerator.swift
|
|
git commit -m "refactor: update PDFGenerator for itinerary order
|
|
|
|
Respects user's custom itinerary ordering in PDF export.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
### Task 10: Run Full Test Suite
|
|
|
|
**Step 1: Run all tests**
|
|
|
|
Run: `cd /Users/treyt/Desktop/code/SportsTime/.worktrees/itinerary-reorder && xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | grep -E "(Test Case|passed|failed|error:)"`
|
|
|
|
**Step 2: Fix any failures**
|
|
|
|
Address any test failures before proceeding.
|
|
|
|
**Step 3: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "test: verify all tests pass after itinerary refactor
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Phase | Tasks | Key Deliverables |
|
|
|-------|-------|------------------|
|
|
| 1. Data Model | 1-2 | ItineraryItem, ItineraryConstraints with tests |
|
|
| 2. Service | 3 | ItineraryItemService with CloudKit sync |
|
|
| 3. Cleanup | 4 | Remove legacy files |
|
|
| 4. UI | 5-6 | Row components, TripDetailView refactor |
|
|
| 5. Drag/Drop | 7 | Constraint-aware drag with visual feedback |
|
|
| 6. Integration | 8-10 | AddItemSheet, PDFGenerator, test suite |
|
|
|
|
**Total estimated tasks:** 10 tasks across 6 phases
|