11 bite-sized TDD tasks to replace anchor-based positioning with simple (day, sortOrder) model. Includes migration path for CloudKit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1243 lines
38 KiB
Markdown
1243 lines
38 KiB
Markdown
# Itinerary Refactor Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Replace anchor-based positioning with simple sort-order positioning for custom itinerary items, enabling intuitive drag-and-drop without inference bugs.
|
|
|
|
**Architecture:** Remove `anchorType` and `anchorId` from `CustomItineraryItem`, replacing them with `day: Int` (already exists as `anchorDay`) and `sortOrder: Double`. Update all CloudKit, UI, and routing code to use the new model. Travel constraints remain enforced via valid ranges.
|
|
|
|
**Tech Stack:** Swift, SwiftUI, UIKit (UITableViewController), CloudKit, Swift Testing
|
|
|
|
---
|
|
|
|
## Task 1: Update CustomItineraryItem Model
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Models/Domain/CustomItineraryItem.swift`
|
|
- Modify: `SportsTimeTests/Domain/CustomItineraryItemTests.swift`
|
|
|
|
**Step 1: Write failing test for new model structure**
|
|
|
|
Add to `CustomItineraryItemTests.swift`:
|
|
|
|
```swift
|
|
@Test("Item initializes with day and sortOrder (no anchors)")
|
|
func item_InitializesWithDayAndSortOrder() {
|
|
let tripId = UUID()
|
|
let item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: .restaurant,
|
|
title: "Joe's BBQ",
|
|
day: 1,
|
|
sortOrder: 1.5
|
|
)
|
|
|
|
#expect(item.tripId == tripId)
|
|
#expect(item.category == .restaurant)
|
|
#expect(item.title == "Joe's BBQ")
|
|
#expect(item.day == 1)
|
|
#expect(item.sortOrder == 1.5)
|
|
}
|
|
|
|
@Test("SortOrder defaults to 0.0")
|
|
func sortOrder_DefaultsToZero() {
|
|
let item = CustomItineraryItem(
|
|
tripId: UUID(),
|
|
category: .activity,
|
|
title: "City Tour",
|
|
day: 2
|
|
)
|
|
|
|
#expect(item.sortOrder == 0.0)
|
|
}
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test`
|
|
|
|
Expected: FAIL - initializer doesn't match
|
|
|
|
**Step 3: Update CustomItineraryItem model**
|
|
|
|
Replace `SportsTime/Core/Models/Domain/CustomItineraryItem.swift`:
|
|
|
|
```swift
|
|
//
|
|
// CustomItineraryItem.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
struct CustomItineraryItem: Identifiable, Codable, Hashable {
|
|
let id: UUID
|
|
let tripId: UUID
|
|
var category: ItemCategory
|
|
var title: String
|
|
var day: Int // Day number (1-indexed)
|
|
var sortOrder: Double // Position within day (allows insertion between items)
|
|
let createdAt: Date
|
|
var modifiedAt: Date
|
|
|
|
// Optional location for mappable items (from MapKit search)
|
|
var latitude: Double?
|
|
var longitude: Double?
|
|
var address: String?
|
|
|
|
/// Whether this item has a location and can be shown on the map
|
|
var isMappable: Bool {
|
|
latitude != nil && longitude != nil
|
|
}
|
|
|
|
/// Get coordinate if mappable
|
|
var coordinate: CLLocationCoordinate2D? {
|
|
guard let lat = latitude, let lon = longitude else { return nil }
|
|
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
|
}
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
tripId: UUID,
|
|
category: ItemCategory,
|
|
title: String,
|
|
day: Int,
|
|
sortOrder: Double = 0.0,
|
|
createdAt: Date = Date(),
|
|
modifiedAt: Date = Date(),
|
|
latitude: Double? = nil,
|
|
longitude: Double? = nil,
|
|
address: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.tripId = tripId
|
|
self.category = category
|
|
self.title = title
|
|
self.day = day
|
|
self.sortOrder = sortOrder
|
|
self.createdAt = createdAt
|
|
self.modifiedAt = modifiedAt
|
|
self.latitude = latitude
|
|
self.longitude = longitude
|
|
self.address = address
|
|
}
|
|
|
|
enum ItemCategory: String, Codable, CaseIterable {
|
|
case restaurant
|
|
case hotel
|
|
case activity
|
|
case note
|
|
|
|
var icon: String {
|
|
switch self {
|
|
case .restaurant: return "🍽️"
|
|
case .hotel: return "🏨"
|
|
case .activity: return "🎯"
|
|
case .note: return "📝"
|
|
}
|
|
}
|
|
|
|
var label: String {
|
|
switch self {
|
|
case .restaurant: return "Restaurant"
|
|
case .hotel: return "Hotel"
|
|
case .activity: return "Activity"
|
|
case .note: return "Note"
|
|
}
|
|
}
|
|
|
|
var systemImage: String {
|
|
switch self {
|
|
case .restaurant: return "fork.knife"
|
|
case .hotel: return "bed.double.fill"
|
|
case .activity: return "figure.run"
|
|
case .note: return "note.text"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Update existing tests to use new model**
|
|
|
|
Replace `SportsTimeTests/Domain/CustomItineraryItemTests.swift`:
|
|
|
|
```swift
|
|
//
|
|
// CustomItineraryItemTests.swift
|
|
// SportsTimeTests
|
|
//
|
|
|
|
import Testing
|
|
@testable import SportsTime
|
|
import Foundation
|
|
|
|
struct CustomItineraryItemTests {
|
|
|
|
@Test("Item initializes with default values")
|
|
func item_InitializesWithDefaults() {
|
|
let tripId = UUID()
|
|
let item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: .restaurant,
|
|
title: "Joe's BBQ",
|
|
day: 1
|
|
)
|
|
|
|
#expect(item.tripId == tripId)
|
|
#expect(item.category == .restaurant)
|
|
#expect(item.title == "Joe's BBQ")
|
|
#expect(item.day == 1)
|
|
#expect(item.sortOrder == 0.0)
|
|
}
|
|
|
|
@Test("Item initializes with day and sortOrder")
|
|
func item_InitializesWithDayAndSortOrder() {
|
|
let tripId = UUID()
|
|
let item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: .restaurant,
|
|
title: "Joe's BBQ",
|
|
day: 1,
|
|
sortOrder: 1.5
|
|
)
|
|
|
|
#expect(item.tripId == tripId)
|
|
#expect(item.category == .restaurant)
|
|
#expect(item.title == "Joe's BBQ")
|
|
#expect(item.day == 1)
|
|
#expect(item.sortOrder == 1.5)
|
|
}
|
|
|
|
@Test("SortOrder defaults to 0.0")
|
|
func sortOrder_DefaultsToZero() {
|
|
let item = CustomItineraryItem(
|
|
tripId: UUID(),
|
|
category: .activity,
|
|
title: "City Tour",
|
|
day: 2
|
|
)
|
|
|
|
#expect(item.sortOrder == 0.0)
|
|
}
|
|
|
|
@Test("Item category has correct icons")
|
|
func category_HasCorrectIcons() {
|
|
#expect(CustomItineraryItem.ItemCategory.restaurant.icon == "🍽️")
|
|
#expect(CustomItineraryItem.ItemCategory.hotel.icon == "🏨")
|
|
#expect(CustomItineraryItem.ItemCategory.activity.icon == "🎯")
|
|
#expect(CustomItineraryItem.ItemCategory.note.icon == "📝")
|
|
}
|
|
|
|
@Test("Item is Codable")
|
|
func item_IsCodable() throws {
|
|
let item = CustomItineraryItem(
|
|
tripId: UUID(),
|
|
category: .hotel,
|
|
title: "Hilton Downtown",
|
|
day: 2,
|
|
sortOrder: 3.5
|
|
)
|
|
|
|
let encoded = try JSONEncoder().encode(item)
|
|
let decoded = try JSONDecoder().decode(CustomItineraryItem.self, from: encoded)
|
|
|
|
#expect(decoded.id == item.id)
|
|
#expect(decoded.title == item.title)
|
|
#expect(decoded.day == 2)
|
|
#expect(decoded.sortOrder == 3.5)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Run tests to verify they pass**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/Domain/CustomItineraryItem.swift SportsTimeTests/Domain/CustomItineraryItemTests.swift
|
|
git commit -m "refactor(model): replace anchors with day+sortOrder in CustomItineraryItem
|
|
|
|
BREAKING: Remove anchorType, anchorId, anchorDay
|
|
Add: day (Int), sortOrder (Double)
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Update CloudKit Model (CKCustomItineraryItem)
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` (lines 628-713)
|
|
|
|
**Step 1: Update CKCustomItineraryItem field keys and conversion**
|
|
|
|
In `CKModels.swift`, replace the `CKCustomItineraryItem` struct (around line 628):
|
|
|
|
```swift
|
|
// MARK: - CKCustomItineraryItem
|
|
|
|
struct CKCustomItineraryItem {
|
|
static let itemIdKey = "itemId"
|
|
static let tripIdKey = "tripId"
|
|
static let categoryKey = "category"
|
|
static let titleKey = "title"
|
|
static let dayKey = "day" // NEW: replaces anchorDay
|
|
static let sortOrderDoubleKey = "sortOrderDouble" // NEW: Double instead of Int
|
|
static let createdAtKey = "createdAt"
|
|
static let modifiedAtKey = "modifiedAt"
|
|
// Location fields for mappable items
|
|
static let latitudeKey = "latitude"
|
|
static let longitudeKey = "longitude"
|
|
static let addressKey = "address"
|
|
// DEPRECATED - kept for migration reads only
|
|
static let anchorTypeKey = "anchorType"
|
|
static let anchorIdKey = "anchorId"
|
|
static let anchorDayKey = "anchorDay"
|
|
static let sortOrderKey = "sortOrder"
|
|
|
|
let record: CKRecord
|
|
|
|
init(record: CKRecord) {
|
|
self.record = record
|
|
}
|
|
|
|
init(item: CustomItineraryItem) {
|
|
let record = CKRecord(
|
|
recordType: CKRecordType.customItineraryItem,
|
|
recordID: CKRecord.ID(recordName: item.id.uuidString)
|
|
)
|
|
record[CKCustomItineraryItem.itemIdKey] = item.id.uuidString
|
|
record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString
|
|
record[CKCustomItineraryItem.categoryKey] = item.category.rawValue
|
|
record[CKCustomItineraryItem.titleKey] = item.title
|
|
record[CKCustomItineraryItem.dayKey] = item.day
|
|
record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
|
|
record[CKCustomItineraryItem.createdAtKey] = item.createdAt
|
|
record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt
|
|
// Location fields (nil values are not stored in CloudKit)
|
|
record[CKCustomItineraryItem.latitudeKey] = item.latitude
|
|
record[CKCustomItineraryItem.longitudeKey] = item.longitude
|
|
record[CKCustomItineraryItem.addressKey] = item.address
|
|
self.record = record
|
|
}
|
|
|
|
func toItem() -> CustomItineraryItem? {
|
|
guard let itemIdString = record[CKCustomItineraryItem.itemIdKey] as? String,
|
|
let itemId = UUID(uuidString: itemIdString),
|
|
let tripIdString = record[CKCustomItineraryItem.tripIdKey] as? String,
|
|
let tripId = UUID(uuidString: tripIdString),
|
|
let categoryString = record[CKCustomItineraryItem.categoryKey] as? String,
|
|
let category = CustomItineraryItem.ItemCategory(rawValue: categoryString),
|
|
let title = record[CKCustomItineraryItem.titleKey] as? String,
|
|
let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date,
|
|
let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date
|
|
else { return nil }
|
|
|
|
// Read new fields, with migration fallback from old fields
|
|
let day: Int
|
|
if let newDay = record[CKCustomItineraryItem.dayKey] as? Int {
|
|
day = newDay
|
|
} else if let oldDay = record[CKCustomItineraryItem.anchorDayKey] as? Int {
|
|
// Migration: use old anchorDay
|
|
day = oldDay
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
let sortOrder: Double
|
|
if let newSortOrder = record[CKCustomItineraryItem.sortOrderDoubleKey] as? Double {
|
|
sortOrder = newSortOrder
|
|
} else if let oldSortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int {
|
|
// Migration: convert old Int sortOrder to Double
|
|
sortOrder = Double(oldSortOrder)
|
|
} else {
|
|
sortOrder = 0.0
|
|
}
|
|
|
|
// Location fields (optional - nil if not stored)
|
|
let latitude = record[CKCustomItineraryItem.latitudeKey] as? Double
|
|
let longitude = record[CKCustomItineraryItem.longitudeKey] as? Double
|
|
let address = record[CKCustomItineraryItem.addressKey] as? String
|
|
|
|
return CustomItineraryItem(
|
|
id: itemId,
|
|
tripId: tripId,
|
|
category: category,
|
|
title: title,
|
|
day: day,
|
|
sortOrder: sortOrder,
|
|
createdAt: createdAt,
|
|
modifiedAt: modifiedAt,
|
|
latitude: latitude,
|
|
longitude: longitude,
|
|
address: address
|
|
)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run build to verify compilation**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: Build errors in files that reference old anchor fields
|
|
|
|
**Step 3: Commit (partial - model layer)**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Models/CloudKit/CKModels.swift
|
|
git commit -m "refactor(cloudkit): update CKCustomItineraryItem for day+sortOrder
|
|
|
|
Add migration fallback for existing records with old anchor fields.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Update CustomItemService
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Core/Services/CustomItemService.swift`
|
|
|
|
**Step 1: Update field references in CustomItemService**
|
|
|
|
Replace the update methods in `CustomItemService.swift`:
|
|
|
|
```swift
|
|
func updateItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem {
|
|
// Fetch existing record to get changeTag
|
|
let recordID = CKRecord.ID(recordName: item.id.uuidString)
|
|
let existingRecord: CKRecord
|
|
|
|
do {
|
|
existingRecord = try await publicDatabase.record(for: recordID)
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
|
|
// Update fields
|
|
let now = Date()
|
|
existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue
|
|
existingRecord[CKCustomItineraryItem.titleKey] = item.title
|
|
existingRecord[CKCustomItineraryItem.dayKey] = item.day
|
|
existingRecord[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
|
|
existingRecord[CKCustomItineraryItem.modifiedAtKey] = now
|
|
// Location fields (nil values clear the field in CloudKit)
|
|
existingRecord[CKCustomItineraryItem.latitudeKey] = item.latitude
|
|
existingRecord[CKCustomItineraryItem.longitudeKey] = item.longitude
|
|
existingRecord[CKCustomItineraryItem.addressKey] = item.address
|
|
|
|
do {
|
|
try await publicDatabase.save(existingRecord)
|
|
var updatedItem = item
|
|
updatedItem.modifiedAt = now
|
|
return updatedItem
|
|
} catch let error as CKError {
|
|
throw mapCloudKitError(error)
|
|
} catch {
|
|
throw CustomItemError.unknown(error)
|
|
}
|
|
}
|
|
|
|
/// Batch update sortOrder for multiple items (for reordering)
|
|
func updateSortOrders(_ items: [CustomItineraryItem]) async throws {
|
|
guard !items.isEmpty else { return }
|
|
print("☁️ [CloudKit] Batch updating day+sortOrder for \(items.count) items")
|
|
|
|
// Fetch all records
|
|
let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) }
|
|
let fetchResults = try await publicDatabase.records(for: recordIDs)
|
|
|
|
// Update each record's day and sortOrder
|
|
var recordsToSave: [CKRecord] = []
|
|
let now = Date()
|
|
|
|
for item in items {
|
|
let recordID = CKRecord.ID(recordName: item.id.uuidString)
|
|
guard case .success(let record) = fetchResults[recordID] else { continue }
|
|
|
|
record[CKCustomItineraryItem.dayKey] = item.day
|
|
record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder
|
|
record[CKCustomItineraryItem.modifiedAtKey] = now
|
|
recordsToSave.append(record)
|
|
}
|
|
|
|
// Save all in one batch operation
|
|
let modifyOp = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: nil)
|
|
modifyOp.savePolicy = .changedKeys
|
|
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
|
modifyOp.modifyRecordsResultBlock = { result in
|
|
switch result {
|
|
case .success:
|
|
print("☁️ [CloudKit] Batch day+sortOrder update complete")
|
|
continuation.resume()
|
|
case .failure(let error):
|
|
print("☁️ [CloudKit] Batch update failed: \(error)")
|
|
continuation.resume(throwing: error)
|
|
}
|
|
}
|
|
publicDatabase.add(modifyOp)
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run build to verify**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: More build errors in UI files (expected at this stage)
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Core/Services/CustomItemService.swift
|
|
git commit -m "refactor(service): update CustomItemService for day+sortOrder
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Update ItineraryRowItem Enum
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift` (lines 26-57)
|
|
|
|
**Step 1: Simplify ItineraryRowItem - remove anchor parameters from addButton**
|
|
|
|
Update the enum at the top of `ItineraryTableViewController.swift`:
|
|
|
|
```swift
|
|
/// Represents a row item in the itinerary
|
|
enum ItineraryRowItem: Identifiable, Equatable {
|
|
case dayHeader(dayNumber: Int, date: Date, games: [RichGame])
|
|
case travel(TravelSegment, dayNumber: Int)
|
|
case customItem(CustomItineraryItem)
|
|
case addButton(day: Int) // Simplified - just needs day
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .dayHeader(let dayNumber, _, _):
|
|
return "day:\(dayNumber)"
|
|
case .travel(let segment, _):
|
|
return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
|
case .customItem(let item):
|
|
return "item:\(item.id.uuidString)"
|
|
case .addButton(let day):
|
|
return "add:\(day)"
|
|
}
|
|
}
|
|
|
|
var isReorderable: Bool {
|
|
switch self {
|
|
case .dayHeader, .addButton:
|
|
return false
|
|
case .travel, .customItem:
|
|
return true
|
|
}
|
|
}
|
|
|
|
static func == (lhs: ItineraryRowItem, rhs: ItineraryRowItem) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Update callbacks in ItineraryTableViewController**
|
|
|
|
Change the callback signatures (around line 70):
|
|
|
|
```swift
|
|
// Callbacks
|
|
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
|
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
|
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
|
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
|
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
|
```
|
|
|
|
**Step 3: Run build to find remaining errors**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100`
|
|
|
|
Expected: Build errors - note which files need updates
|
|
|
|
**Step 4: Commit (partial)**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
|
git commit -m "refactor(ui): simplify ItineraryRowItem callbacks for day+sortOrder
|
|
|
|
WIP: Will fix remaining compilation errors in next tasks
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Update ItineraryTableViewController Move Logic
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewController.swift`
|
|
|
|
**Step 1: Replace determineAnchor with calculateSortOrder**
|
|
|
|
Remove `determineAnchor` function and add new sort order calculation (around line 438):
|
|
|
|
```swift
|
|
// MARK: - Sort Order Calculation
|
|
|
|
/// Calculate sort order for item dropped at a given row within its day
|
|
private func calculateSortOrder(droppedAtRow row: Int, inDay targetDay: Int) -> Double {
|
|
// Find all custom items currently in the target day
|
|
let itemsInDay = flatItems.enumerated().compactMap { (index, item) -> (index: Int, sortOrder: Double)? in
|
|
guard case .customItem(let custom) = item, custom.day == targetDay else { return nil }
|
|
return (index, custom.sortOrder)
|
|
}
|
|
|
|
// If no items in this day, start at 1.0
|
|
guard !itemsInDay.isEmpty else {
|
|
return 1.0
|
|
}
|
|
|
|
// Find where the drop position falls relative to existing items
|
|
let itemsBefore = itemsInDay.filter { $0.index < row }
|
|
let itemsAfter = itemsInDay.filter { $0.index >= row }
|
|
|
|
if itemsBefore.isEmpty {
|
|
// Dropping before all items - use half of first item's sortOrder
|
|
let firstOrder = itemsInDay.first?.sortOrder ?? 1.0
|
|
return firstOrder / 2.0
|
|
}
|
|
|
|
if itemsAfter.isEmpty {
|
|
// Dropping after all items - use last + 1
|
|
let lastOrder = itemsBefore.last?.sortOrder ?? 0.0
|
|
return lastOrder + 1.0
|
|
}
|
|
|
|
// Dropping between items - use midpoint
|
|
let beforeOrder = itemsBefore.last?.sortOrder ?? 0.0
|
|
let afterOrder = itemsAfter.first?.sortOrder ?? (beforeOrder + 2.0)
|
|
return (beforeOrder + afterOrder) / 2.0
|
|
}
|
|
```
|
|
|
|
**Step 2: Update moveRowAt to use new callback**
|
|
|
|
Replace the move handling in `tableView(_:moveRowAt:to:)` (around line 250):
|
|
|
|
```swift
|
|
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
|
let item = flatItems[sourceIndexPath.row]
|
|
|
|
// Remove from source
|
|
flatItems.remove(at: sourceIndexPath.row)
|
|
|
|
// Insert at destination
|
|
flatItems.insert(item, at: destinationIndexPath.row)
|
|
|
|
// Notify callbacks
|
|
switch item {
|
|
case .travel(let segment, _):
|
|
// Find which day this travel is now associated with
|
|
let newDay = dayForTravelAt(row: destinationIndexPath.row)
|
|
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
|
onTravelMoved?(travelId, newDay)
|
|
|
|
case .customItem(let customItem):
|
|
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
|
let newSortOrder = calculateSortOrder(droppedAtRow: destinationIndexPath.row, inDay: destinationDay)
|
|
onCustomItemMoved?(customItem.id, destinationDay, newSortOrder)
|
|
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 3: Update cell configuration for addButton**
|
|
|
|
Update `configureAddButtonCell` (around line 522):
|
|
|
|
```swift
|
|
private func configureAddButtonCell(_ cell: UITableViewCell, day: Int) {
|
|
cell.contentConfiguration = UIHostingConfiguration {
|
|
AddButtonRowView(colorScheme: colorScheme)
|
|
}
|
|
.margins(.all, 0)
|
|
.background(.clear)
|
|
|
|
cell.backgroundColor = .clear
|
|
cell.selectionStyle = .default
|
|
}
|
|
```
|
|
|
|
**Step 4: Update cellForRowAt for addButton case**
|
|
|
|
Update the switch case (around line 237):
|
|
|
|
```swift
|
|
case .addButton(let day):
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath)
|
|
configureAddButtonCell(cell, day: day)
|
|
return cell
|
|
```
|
|
|
|
**Step 5: Update didSelectRowAt for addButton case**
|
|
|
|
Update the selection handling (around line 406):
|
|
|
|
```swift
|
|
case .addButton(let day):
|
|
onAddButtonTapped?(day)
|
|
```
|
|
|
|
**Step 6: Run build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
|
|
git commit -m "refactor(ui): replace anchor inference with direct sortOrder calculation
|
|
|
|
- Remove determineAnchor function
|
|
- Add calculateSortOrder for direct position computation
|
|
- Simplify addButton to just use day number
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Update ItineraryTableViewWrapper
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift`
|
|
|
|
**Step 1: Update callback signatures**
|
|
|
|
Update the callback types (around line 20-24):
|
|
|
|
```swift
|
|
// Callbacks
|
|
var onTravelMoved: ((String, Int) -> Void)?
|
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, day, sortOrder
|
|
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
|
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
|
var onAddButtonTapped: ((Int) -> Void)? // Just day
|
|
```
|
|
|
|
**Step 2: Update init parameters**
|
|
|
|
Update the initializer (around line 26-48):
|
|
|
|
```swift
|
|
init(
|
|
trip: Trip,
|
|
games: [RichGame],
|
|
customItems: [CustomItineraryItem],
|
|
travelDayOverrides: [String: Int],
|
|
@ViewBuilder headerContent: () -> HeaderContent,
|
|
onTravelMoved: ((String, Int) -> Void)? = nil,
|
|
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
|
onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil,
|
|
onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil,
|
|
onAddButtonTapped: ((Int) -> Void)? = nil
|
|
) {
|
|
self.trip = trip
|
|
self.games = games
|
|
self.customItems = customItems
|
|
self.travelDayOverrides = travelDayOverrides
|
|
self.headerContent = headerContent()
|
|
self.onTravelMoved = onTravelMoved
|
|
self.onCustomItemMoved = onCustomItemMoved
|
|
self.onCustomItemTapped = onCustomItemTapped
|
|
self.onCustomItemDeleted = onCustomItemDeleted
|
|
self.onAddButtonTapped = onAddButtonTapped
|
|
}
|
|
```
|
|
|
|
**Step 3: Update buildItineraryData to use new model**
|
|
|
|
Replace the custom item handling in `buildItineraryData()` (around line 163-202):
|
|
|
|
```swift
|
|
// Custom items for this day (sorted by sortOrder)
|
|
let itemsForDay = customItems.filter { $0.day == dayNum }
|
|
.sorted { $0.sortOrder < $1.sortOrder }
|
|
|
|
for item in itemsForDay {
|
|
items.append(ItineraryRowItem.customItem(item))
|
|
}
|
|
|
|
// ONE Add button per day - at the end
|
|
items.append(ItineraryRowItem.addButton(day: dayNum))
|
|
```
|
|
|
|
**Step 4: Run build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100`
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
|
|
git commit -m "refactor(ui): update ItineraryTableViewWrapper for day+sortOrder
|
|
|
|
- Simplify custom item grouping (just filter by day, sort by sortOrder)
|
|
- Remove anchor-based item placement logic
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Update TripDetailView Callbacks
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Update onCustomItemMoved callback**
|
|
|
|
Find the `ItineraryTableViewWrapper` usage (around line 186-242) and update:
|
|
|
|
```swift
|
|
onCustomItemMoved: { itemId, day, sortOrder in
|
|
Task { @MainActor in
|
|
guard let item = customItems.first(where: { $0.id == itemId }) else { return }
|
|
await moveItem(item, toDay: day, sortOrder: sortOrder)
|
|
}
|
|
},
|
|
```
|
|
|
|
**Step 2: Update onAddButtonTapped callback**
|
|
|
|
```swift
|
|
onAddButtonTapped: { day in
|
|
addItemAnchor = AddItemAnchor(day: day)
|
|
}
|
|
```
|
|
|
|
**Step 3: Update AddItemAnchor struct**
|
|
|
|
Find the `AddItemAnchor` struct and simplify it:
|
|
|
|
```swift
|
|
struct AddItemAnchor: Identifiable {
|
|
let id = UUID()
|
|
let day: Int
|
|
}
|
|
```
|
|
|
|
**Step 4: Add new moveItem function**
|
|
|
|
Add a new simplified move function:
|
|
|
|
```swift
|
|
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, sortOrder: Double) async {
|
|
print("📦 [Move] Moving '\(item.title)' to day \(day), sortOrder \(sortOrder)")
|
|
|
|
// Update local state immediately
|
|
var updated = item
|
|
updated.day = day
|
|
updated.sortOrder = sortOrder
|
|
updated.modifiedAt = Date()
|
|
|
|
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
|
|
customItems[index] = updated
|
|
}
|
|
|
|
// Persist to CloudKit
|
|
do {
|
|
_ = try await CustomItemService.shared.updateItem(updated)
|
|
print("✅ [Move] Saved to CloudKit")
|
|
} catch {
|
|
print("❌ [Move] CloudKit error: \(error)")
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 5: Remove old moveItemToBeginning function**
|
|
|
|
Delete the old `moveItemToBeginning` function that used anchors.
|
|
|
|
**Step 6: Run build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100`
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "refactor(ui): update TripDetailView for day+sortOrder callbacks
|
|
|
|
- Simplify AddItemAnchor to just day
|
|
- Replace moveItemToBeginning with direct moveItem
|
|
- Update callback handlers
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Update AddItemSheet
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/AddItemSheet.swift`
|
|
|
|
**Step 1: Simplify AddItemSheet parameters**
|
|
|
|
Update the struct properties and initializer (around line 11-48):
|
|
|
|
```swift
|
|
struct AddItemSheet: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let tripId: UUID
|
|
let day: Int
|
|
let existingItem: CustomItineraryItem?
|
|
var onSave: (CustomItineraryItem) -> Void
|
|
|
|
// Entry mode
|
|
enum EntryMode: String, CaseIterable {
|
|
case searchPlaces = "Search Places"
|
|
case custom = "Custom"
|
|
}
|
|
|
|
@State private var entryMode: EntryMode = .searchPlaces
|
|
@State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant
|
|
@State private var title: String = ""
|
|
@State private var isSaving = false
|
|
|
|
// MapKit search state
|
|
@State private var searchQuery = ""
|
|
@State private var searchResults: [MKMapItem] = []
|
|
@State private var selectedPlace: MKMapItem?
|
|
@State private var isSearching = false
|
|
|
|
private var isEditing: Bool { existingItem != nil }
|
|
```
|
|
|
|
**Step 2: Update saveItem function**
|
|
|
|
Replace the `saveItem()` function (around line 247):
|
|
|
|
```swift
|
|
private func saveItem() {
|
|
isSaving = true
|
|
|
|
let item: CustomItineraryItem
|
|
|
|
if let existing = existingItem {
|
|
// Editing existing item - preserve day and sortOrder
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
item = CustomItineraryItem(
|
|
id: existing.id,
|
|
tripId: existing.tripId,
|
|
category: selectedCategory,
|
|
title: trimmedTitle,
|
|
day: existing.day,
|
|
sortOrder: existing.sortOrder,
|
|
createdAt: existing.createdAt,
|
|
modifiedAt: Date(),
|
|
latitude: existing.latitude,
|
|
longitude: existing.longitude,
|
|
address: existing.address
|
|
)
|
|
} else if entryMode == .searchPlaces, let place = selectedPlace {
|
|
// Creating from MapKit search
|
|
let placeName = place.name ?? "Unknown Place"
|
|
let coordinate = place.placemark.coordinate
|
|
|
|
item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: selectedCategory,
|
|
title: placeName,
|
|
day: day,
|
|
sortOrder: Double(Date().timeIntervalSince1970), // Use timestamp for unique sortOrder
|
|
latitude: coordinate.latitude,
|
|
longitude: coordinate.longitude,
|
|
address: formatAddress(for: place)
|
|
)
|
|
} else {
|
|
// Creating custom item (no location)
|
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmedTitle.isEmpty else { return }
|
|
|
|
item = CustomItineraryItem(
|
|
tripId: tripId,
|
|
category: selectedCategory,
|
|
title: trimmedTitle,
|
|
day: day,
|
|
sortOrder: Double(Date().timeIntervalSince1970)
|
|
)
|
|
}
|
|
|
|
onSave(item)
|
|
dismiss()
|
|
}
|
|
```
|
|
|
|
**Step 3: Update onAppear**
|
|
|
|
```swift
|
|
.onAppear {
|
|
if let existing = existingItem {
|
|
selectedCategory = existing.category
|
|
title = existing.title
|
|
entryMode = .custom
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 4: Update Preview**
|
|
|
|
```swift
|
|
#Preview {
|
|
AddItemSheet(
|
|
tripId: UUID(),
|
|
day: 1,
|
|
existingItem: nil
|
|
) { _ in }
|
|
}
|
|
```
|
|
|
|
**Step 5: Run build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | head -100`
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/AddItemSheet.swift
|
|
git commit -m "refactor(ui): simplify AddItemSheet to use day only
|
|
|
|
- Remove anchorType and anchorId parameters
|
|
- Use timestamp for initial sortOrder of new items
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Update TripDetailView Sheet Presentation
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Update sheet bindings for AddItemSheet**
|
|
|
|
Find the `.sheet(item: $addItemAnchor)` (around line 119) and update:
|
|
|
|
```swift
|
|
.sheet(item: $addItemAnchor) { anchor in
|
|
AddItemSheet(
|
|
tripId: trip.id,
|
|
day: anchor.day,
|
|
existingItem: nil
|
|
) { item in
|
|
Task { await saveCustomItem(item) }
|
|
}
|
|
}
|
|
.sheet(item: $editingItem) { item in
|
|
AddItemSheet(
|
|
tripId: trip.id,
|
|
day: item.day,
|
|
existingItem: item
|
|
) { updatedItem in
|
|
Task { await saveCustomItem(updatedItem) }
|
|
}
|
|
}
|
|
```
|
|
|
|
**Step 2: Run build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: PASS
|
|
|
|
**Step 3: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "refactor(ui): update TripDetailView sheet bindings for simplified AddItemSheet
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Update routeWaypoints for Simplified Model
|
|
|
|
**Files:**
|
|
- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift`
|
|
|
|
**Step 1: Simplify routeWaypoints computed property**
|
|
|
|
Replace the `routeWaypoints` property (around line 1154):
|
|
|
|
```swift
|
|
/// Route waypoints including both game stops and mappable custom items in itinerary order
|
|
private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] {
|
|
var waypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = []
|
|
let days = tripDays
|
|
|
|
// Group custom items by day, sorted by sortOrder
|
|
let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day }
|
|
|
|
for (dayIndex, dayDate) in days.enumerated() {
|
|
let dayNumber = dayIndex + 1
|
|
|
|
// Find games on this day
|
|
let gamesOnDay = gamesOn(date: dayDate)
|
|
let calendar = Calendar.current
|
|
let dayCity = gamesOnDay.first?.stadium.city ?? trip.stops.first(where: { stop in
|
|
let arrival = calendar.startOfDay(for: stop.arrivalDate)
|
|
let departure = calendar.startOfDay(for: stop.departureDate)
|
|
let day = calendar.startOfDay(for: dayDate)
|
|
return day >= arrival && day <= departure
|
|
})?.city
|
|
|
|
// Game stop for this day (only add once per city)
|
|
if let city = dayCity {
|
|
let alreadyHasCity = waypoints.contains(where: { wp in
|
|
if wp.isCustomItem { return false }
|
|
if wp.name == city { return true }
|
|
if let stop = trip.stops.first(where: { $0.city == city }),
|
|
let stadiumId = stop.stadium,
|
|
let stadium = dataProvider.stadium(for: stadiumId),
|
|
wp.name == stadium.name { return true }
|
|
return false
|
|
})
|
|
|
|
if !alreadyHasCity {
|
|
if let stop = trip.stops.first(where: { $0.city == city }) {
|
|
if let stadiumId = stop.stadium,
|
|
let stadium = dataProvider.stadium(for: stadiumId) {
|
|
waypoints.append((stadium.name, stadium.coordinate, false))
|
|
} else if let coord = stop.coordinate {
|
|
waypoints.append((city, coord, false))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Custom items for this day (in sortOrder)
|
|
if let items = itemsByDay[dayNumber] {
|
|
let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
|
|
for item in sortedItems {
|
|
if let coord = item.coordinate {
|
|
waypoints.append((item.title, coord, true))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return waypoints
|
|
}
|
|
```
|
|
|
|
**Step 2: Remove debug logging (optional cleanup)**
|
|
|
|
Remove the debug print statements if desired.
|
|
|
|
**Step 3: Run full test suite**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add SportsTime/Features/Trip/Views/TripDetailView.swift
|
|
git commit -m "refactor(routing): simplify routeWaypoints for day+sortOrder model
|
|
|
|
Route now follows exact visual position: games first, then custom items
|
|
sorted by sortOrder within each day.
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Final Cleanup and Testing
|
|
|
|
**Files:**
|
|
- All modified files
|
|
|
|
**Step 1: Run full build**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build`
|
|
|
|
Expected: BUILD SUCCEEDED
|
|
|
|
**Step 2: Run all tests**
|
|
|
|
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test`
|
|
|
|
Expected: All tests pass
|
|
|
|
**Step 3: Manual testing checklist**
|
|
|
|
- [ ] Add a custom item to Day 1
|
|
- [ ] Add a custom item to Day 3
|
|
- [ ] Drag item from Day 1 to Day 3
|
|
- [ ] Verify item appears in correct position
|
|
- [ ] Verify map route updates correctly
|
|
- [ ] Edit a custom item
|
|
- [ ] Delete a custom item
|
|
- [ ] Drag travel segment to different day
|
|
- [ ] Verify travel constraints are enforced
|
|
|
|
**Step 4: Final commit**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "feat(itinerary): complete refactor to day+sortOrder positioning
|
|
|
|
Replaces complex anchor-based system with simple (day, sortOrder) positioning.
|
|
Benefits:
|
|
- Items dropped exactly where you expect
|
|
- Route follows visual order
|
|
- No more disappearing items
|
|
- Simpler codebase
|
|
|
|
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>"
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Task | Description | Estimated Complexity |
|
|
|------|-------------|---------------------|
|
|
| 1 | Update CustomItineraryItem model | Simple |
|
|
| 2 | Update CKCustomItineraryItem CloudKit model | Simple |
|
|
| 3 | Update CustomItemService | Simple |
|
|
| 4 | Update ItineraryRowItem enum | Simple |
|
|
| 5 | Update ItineraryTableViewController move logic | Medium |
|
|
| 6 | Update ItineraryTableViewWrapper | Medium |
|
|
| 7 | Update TripDetailView callbacks | Medium |
|
|
| 8 | Update AddItemSheet | Simple |
|
|
| 9 | Update TripDetailView sheet presentation | Simple |
|
|
| 10 | Update routeWaypoints | Simple |
|
|
| 11 | Final cleanup and testing | Simple |
|
|
|
|
Total: 11 tasks, mostly simple refactoring with compile-time safety checks.
|