Stabilize beta release with warning cleanup and edge-case fixes
This commit is contained in:
@@ -7,12 +7,13 @@
|
|||||||
// - Expected Behavior:
|
// - Expected Behavior:
|
||||||
// - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity
|
// - itineraryDays() returns one ItineraryDay per calendar day from first arrival to last activity
|
||||||
// - Last activity day includes departure day if there are games on that day
|
// - Last activity day includes departure day if there are games on that day
|
||||||
// - tripDuration is max(1, days between first arrival and last departure + 1)
|
// - tripDuration is 0 when there are no stops, otherwise max(1, days between first arrival and last departure + 1)
|
||||||
// - cities returns deduplicated city list preserving visit order
|
// - cities returns deduplicated city list preserving visit order
|
||||||
// - displayName uses " → " separator between cities
|
// - displayName uses " → " separator between cities
|
||||||
//
|
//
|
||||||
// - Invariants:
|
// - Invariants:
|
||||||
// - tripDuration >= 1 (minimum 1 day)
|
// - tripDuration >= 0
|
||||||
|
// - tripDuration == 0 iff stops is empty
|
||||||
// - cities has no duplicates
|
// - cities has no duplicates
|
||||||
// - itineraryDays() dayNumber starts at 1 and increments
|
// - itineraryDays() dayNumber starts at 1 and increments
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ struct TripPoll: Identifiable, Codable, Hashable {
|
|||||||
private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
private static let shareCodeCharacters = Array("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
|
||||||
|
|
||||||
static func generateShareCode() -> String {
|
static func generateShareCode() -> String {
|
||||||
String((0..<6).map { _ in shareCodeCharacters.randomElement()! })
|
let fallback: Character = "A"
|
||||||
|
return String((0..<6).map { _ in shareCodeCharacters.randomElement() ?? fallback })
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Trip Hash
|
// MARK: - Trip Hash
|
||||||
@@ -73,7 +74,11 @@ struct TripPoll: Identifiable, Codable, Hashable {
|
|||||||
// MARK: - Deep Link URL
|
// MARK: - Deep Link URL
|
||||||
|
|
||||||
var shareURL: URL {
|
var shareURL: URL {
|
||||||
URL(string: "sportstime://poll/\(shareCode)")!
|
var components = URLComponents()
|
||||||
|
components.scheme = "sportstime"
|
||||||
|
components.host = "poll"
|
||||||
|
components.path = "/\(shareCode)"
|
||||||
|
return components.url ?? URL(string: "sportstime://poll")!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +112,11 @@ struct PollVote: Identifiable, Codable, Hashable {
|
|||||||
/// Returns array where index = trip index, value = score
|
/// Returns array where index = trip index, value = score
|
||||||
static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] {
|
static func calculateScores(rankings: [Int], tripCount: Int) -> [Int] {
|
||||||
var scores = Array(repeating: 0, count: tripCount)
|
var scores = Array(repeating: 0, count: tripCount)
|
||||||
|
var seenTripIndices = Set<Int>()
|
||||||
for (rank, tripIndex) in rankings.enumerated() {
|
for (rank, tripIndex) in rankings.enumerated() {
|
||||||
guard tripIndex < tripCount else { continue }
|
guard tripIndex >= 0, tripIndex < tripCount else { continue }
|
||||||
let points = tripCount - rank
|
guard seenTripIndices.insert(tripIndex).inserted else { continue }
|
||||||
|
let points = max(tripCount - rank, 0)
|
||||||
scores[tripIndex] = points
|
scores[tripIndex] = points
|
||||||
}
|
}
|
||||||
return scores
|
return scores
|
||||||
|
|||||||
@@ -556,15 +556,6 @@ final class CanonicalSport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sendable Conformance
|
|
||||||
|
|
||||||
// These SwiftData models are passed across actor boundaries during sync operations.
|
|
||||||
// Access is still coordinated by SwiftData contexts and higher-level sync orchestration.
|
|
||||||
extension StadiumAlias: @unchecked Sendable {}
|
|
||||||
extension TeamAlias: @unchecked Sendable {}
|
|
||||||
extension LeagueStructureModel: @unchecked Sendable {}
|
|
||||||
extension CanonicalSport: @unchecked Sendable {}
|
|
||||||
|
|
||||||
// MARK: - Bundled Data Timestamps
|
// MARK: - Bundled Data Timestamps
|
||||||
|
|
||||||
/// Timestamps for bundled data files.
|
/// Timestamps for bundled data files.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ final class AchievementEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
|
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
|
||||||
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
|
guard LeagueStructure.division(byId: divisionId) != nil else { return false }
|
||||||
|
|
||||||
// Get stadium IDs for teams in this division
|
// Get stadium IDs for teams in this division
|
||||||
let stadiumIds = getStadiumIdsForDivision(divisionId)
|
let stadiumIds = getStadiumIdsForDivision(divisionId)
|
||||||
@@ -237,7 +237,7 @@ final class AchievementEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
|
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
|
||||||
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false }
|
guard LeagueStructure.conference(byId: conferenceId) != nil else { return false }
|
||||||
|
|
||||||
// Get stadium IDs for all teams in this conference
|
// Get stadium IDs for all teams in this conference
|
||||||
let stadiumIds = getStadiumIdsForConference(conferenceId)
|
let stadiumIds = getStadiumIdsForConference(conferenceId)
|
||||||
|
|||||||
@@ -51,11 +51,8 @@ struct AppleMapsLauncher {
|
|||||||
return .noWaypoints
|
return .noWaypoints
|
||||||
}
|
}
|
||||||
|
|
||||||
let mapItems = waypoints.map { waypoint -> MKMapItem in
|
let mapItems = waypoints.map { waypoint in
|
||||||
let placemark = MKPlacemark(coordinate: waypoint.coordinate)
|
makeMapItem(name: waypoint.name, coordinate: waypoint.coordinate)
|
||||||
let item = MKMapItem(placemark: placemark)
|
|
||||||
item.name = waypoint.name
|
|
||||||
return item
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mapItems.count <= maxWaypoints {
|
if mapItems.count <= maxWaypoints {
|
||||||
@@ -90,9 +87,7 @@ struct AppleMapsLauncher {
|
|||||||
/// - coordinate: Location to display
|
/// - coordinate: Location to display
|
||||||
/// - name: Display name for the pin
|
/// - name: Display name for the pin
|
||||||
static func openLocation(coordinate: CLLocationCoordinate2D, name: String) {
|
static func openLocation(coordinate: CLLocationCoordinate2D, name: String) {
|
||||||
let placemark = MKPlacemark(coordinate: coordinate)
|
let mapItem = makeMapItem(name: name, coordinate: coordinate)
|
||||||
let mapItem = MKMapItem(placemark: placemark)
|
|
||||||
mapItem.name = name
|
|
||||||
mapItem.openInMaps(launchOptions: nil)
|
mapItem.openInMaps(launchOptions: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,13 +103,8 @@ struct AppleMapsLauncher {
|
|||||||
to: CLLocationCoordinate2D,
|
to: CLLocationCoordinate2D,
|
||||||
toName: String
|
toName: String
|
||||||
) {
|
) {
|
||||||
let fromPlacemark = MKPlacemark(coordinate: from)
|
let fromItem = makeMapItem(name: fromName, coordinate: from)
|
||||||
let fromItem = MKMapItem(placemark: fromPlacemark)
|
let toItem = makeMapItem(name: toName, coordinate: to)
|
||||||
fromItem.name = fromName
|
|
||||||
|
|
||||||
let toPlacemark = MKPlacemark(coordinate: to)
|
|
||||||
let toItem = MKMapItem(placemark: toPlacemark)
|
|
||||||
toItem.name = toName
|
|
||||||
|
|
||||||
MKMapItem.openMaps(with: [fromItem, toItem], launchOptions: [
|
MKMapItem.openMaps(with: [fromItem, toItem], launchOptions: [
|
||||||
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
MKLaunchOptionsDirectionsModeKey: MKLaunchOptionsDirectionsModeDriving
|
||||||
@@ -169,4 +159,17 @@ struct AppleMapsLauncher {
|
|||||||
Array(items[start..<min(start + size, items.count)])
|
Array(items[start..<min(start + size, items.count)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeMapItem(name: String, coordinate: CLLocationCoordinate2D) -> MKMapItem {
|
||||||
|
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||||
|
let item: MKMapItem
|
||||||
|
if #available(iOS 26.0, *) {
|
||||||
|
item = MKMapItem(location: location, address: nil)
|
||||||
|
} else {
|
||||||
|
let placemark = MKPlacemark(coordinate: coordinate)
|
||||||
|
item = MKMapItem(placemark: placemark)
|
||||||
|
}
|
||||||
|
item.name = name
|
||||||
|
return item
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -569,7 +569,19 @@ final class CanonicalSyncService {
|
|||||||
var skippedOlder = 0
|
var skippedOlder = 0
|
||||||
|
|
||||||
for remoteStructure in remoteStructures {
|
for remoteStructure in remoteStructures {
|
||||||
let result = try mergeLeagueStructure(remoteStructure, context: context)
|
let structureType = LeagueStructureType(rawValue: remoteStructure.structureTypeRaw.lowercased()) ?? .division
|
||||||
|
let model = LeagueStructureModel(
|
||||||
|
id: remoteStructure.id,
|
||||||
|
sport: remoteStructure.sport,
|
||||||
|
structureType: structureType,
|
||||||
|
name: remoteStructure.name,
|
||||||
|
abbreviation: remoteStructure.abbreviation,
|
||||||
|
parentId: remoteStructure.parentId,
|
||||||
|
displayOrder: remoteStructure.displayOrder,
|
||||||
|
schemaVersion: remoteStructure.schemaVersion,
|
||||||
|
lastModified: remoteStructure.lastModified
|
||||||
|
)
|
||||||
|
let result = try mergeLeagueStructure(model, context: context)
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .applied: updated += 1
|
case .applied: updated += 1
|
||||||
@@ -593,7 +605,18 @@ final class CanonicalSyncService {
|
|||||||
var skippedOlder = 0
|
var skippedOlder = 0
|
||||||
|
|
||||||
for remoteAlias in remoteAliases {
|
for remoteAlias in remoteAliases {
|
||||||
let result = try mergeTeamAlias(remoteAlias, context: context)
|
let aliasType = TeamAliasType(rawValue: remoteAlias.aliasTypeRaw.lowercased()) ?? .name
|
||||||
|
let model = TeamAlias(
|
||||||
|
id: remoteAlias.id,
|
||||||
|
teamCanonicalId: remoteAlias.teamCanonicalId,
|
||||||
|
aliasType: aliasType,
|
||||||
|
aliasValue: remoteAlias.aliasValue,
|
||||||
|
validFrom: remoteAlias.validFrom,
|
||||||
|
validUntil: remoteAlias.validUntil,
|
||||||
|
schemaVersion: remoteAlias.schemaVersion,
|
||||||
|
lastModified: remoteAlias.lastModified
|
||||||
|
)
|
||||||
|
let result = try mergeTeamAlias(model, context: context)
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .applied: updated += 1
|
case .applied: updated += 1
|
||||||
@@ -617,7 +640,15 @@ final class CanonicalSyncService {
|
|||||||
var skippedOlder = 0
|
var skippedOlder = 0
|
||||||
|
|
||||||
for remoteAlias in remoteAliases {
|
for remoteAlias in remoteAliases {
|
||||||
let result = try mergeStadiumAlias(remoteAlias, context: context)
|
let model = StadiumAlias(
|
||||||
|
aliasName: remoteAlias.aliasName,
|
||||||
|
stadiumCanonicalId: remoteAlias.stadiumCanonicalId,
|
||||||
|
validFrom: remoteAlias.validFrom,
|
||||||
|
validUntil: remoteAlias.validUntil,
|
||||||
|
schemaVersion: remoteAlias.schemaVersion,
|
||||||
|
lastModified: remoteAlias.lastModified
|
||||||
|
)
|
||||||
|
let result = try mergeStadiumAlias(model, context: context)
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .applied: updated += 1
|
case .applied: updated += 1
|
||||||
@@ -641,7 +672,20 @@ final class CanonicalSyncService {
|
|||||||
var skippedOlder = 0
|
var skippedOlder = 0
|
||||||
|
|
||||||
for remoteSport in remoteSports {
|
for remoteSport in remoteSports {
|
||||||
let result = try mergeSport(remoteSport, context: context)
|
let model = CanonicalSport(
|
||||||
|
id: remoteSport.id,
|
||||||
|
abbreviation: remoteSport.abbreviation,
|
||||||
|
displayName: remoteSport.displayName,
|
||||||
|
iconName: remoteSport.iconName,
|
||||||
|
colorHex: remoteSport.colorHex,
|
||||||
|
seasonStartMonth: remoteSport.seasonStartMonth,
|
||||||
|
seasonEndMonth: remoteSport.seasonEndMonth,
|
||||||
|
isActive: remoteSport.isActive,
|
||||||
|
lastModified: remoteSport.lastModified,
|
||||||
|
schemaVersion: remoteSport.schemaVersion,
|
||||||
|
source: .cloudKit
|
||||||
|
)
|
||||||
|
let result = try mergeSport(model, context: context)
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
case .applied: updated += 1
|
case .applied: updated += 1
|
||||||
|
|||||||
@@ -224,6 +224,51 @@ actor CloudKitService {
|
|||||||
let stadiumCanonicalId: String?
|
let stadiumCanonicalId: String?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SyncLeagueStructure: Sendable {
|
||||||
|
let id: String
|
||||||
|
let sport: String
|
||||||
|
let structureTypeRaw: String
|
||||||
|
let name: String
|
||||||
|
let abbreviation: String?
|
||||||
|
let parentId: String?
|
||||||
|
let displayOrder: Int
|
||||||
|
let schemaVersion: Int
|
||||||
|
let lastModified: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncTeamAlias: Sendable {
|
||||||
|
let id: String
|
||||||
|
let teamCanonicalId: String
|
||||||
|
let aliasTypeRaw: String
|
||||||
|
let aliasValue: String
|
||||||
|
let validFrom: Date?
|
||||||
|
let validUntil: Date?
|
||||||
|
let schemaVersion: Int
|
||||||
|
let lastModified: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncStadiumAlias: Sendable {
|
||||||
|
let aliasName: String
|
||||||
|
let stadiumCanonicalId: String
|
||||||
|
let validFrom: Date?
|
||||||
|
let validUntil: Date?
|
||||||
|
let schemaVersion: Int
|
||||||
|
let lastModified: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SyncSport: Sendable {
|
||||||
|
let id: String
|
||||||
|
let abbreviation: String
|
||||||
|
let displayName: String
|
||||||
|
let iconName: String
|
||||||
|
let colorHex: String
|
||||||
|
let seasonStartMonth: Int
|
||||||
|
let seasonEndMonth: Int
|
||||||
|
let isActive: Bool
|
||||||
|
let schemaVersion: Int
|
||||||
|
let lastModified: Date
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Availability Check
|
// MARK: - Availability Check
|
||||||
|
|
||||||
func isAvailable() async -> Bool {
|
func isAvailable() async -> Bool {
|
||||||
@@ -579,7 +624,7 @@ actor CloudKitService {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [LeagueStructureModel] {
|
func fetchLeagueStructureChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncLeagueStructure] {
|
||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
@@ -594,11 +639,23 @@ actor CloudKitService {
|
|||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||||
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
|
log.log("☁️ [CK] Received \(records.count) league structure records from CloudKit")
|
||||||
|
|
||||||
var parsed: [LeagueStructureModel] = []
|
var parsed: [SyncLeagueStructure] = []
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
for record in records {
|
for record in records {
|
||||||
if let model = CKLeagueStructure(record: record).toModel() {
|
if let model = CKLeagueStructure(record: record).toModel() {
|
||||||
parsed.append(model)
|
parsed.append(
|
||||||
|
SyncLeagueStructure(
|
||||||
|
id: model.id,
|
||||||
|
sport: model.sport,
|
||||||
|
structureTypeRaw: model.structureTypeRaw,
|
||||||
|
name: model.name,
|
||||||
|
abbreviation: model.abbreviation,
|
||||||
|
parentId: model.parentId,
|
||||||
|
displayOrder: model.displayOrder,
|
||||||
|
schemaVersion: model.schemaVersion,
|
||||||
|
lastModified: model.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
}
|
}
|
||||||
@@ -618,7 +675,7 @@ actor CloudKitService {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [TeamAlias] {
|
func fetchTeamAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncTeamAlias] {
|
||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
@@ -633,11 +690,22 @@ actor CloudKitService {
|
|||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||||
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
|
log.log("☁️ [CK] Received \(records.count) team alias records from CloudKit")
|
||||||
|
|
||||||
var parsed: [TeamAlias] = []
|
var parsed: [SyncTeamAlias] = []
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
for record in records {
|
for record in records {
|
||||||
if let model = CKTeamAlias(record: record).toModel() {
|
if let model = CKTeamAlias(record: record).toModel() {
|
||||||
parsed.append(model)
|
parsed.append(
|
||||||
|
SyncTeamAlias(
|
||||||
|
id: model.id,
|
||||||
|
teamCanonicalId: model.teamCanonicalId,
|
||||||
|
aliasTypeRaw: model.aliasTypeRaw,
|
||||||
|
aliasValue: model.aliasValue,
|
||||||
|
validFrom: model.validFrom,
|
||||||
|
validUntil: model.validUntil,
|
||||||
|
schemaVersion: model.schemaVersion,
|
||||||
|
lastModified: model.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
}
|
}
|
||||||
@@ -657,7 +725,7 @@ actor CloudKitService {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
/// - lastSync: If nil, fetches all records. If provided, fetches only records modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [StadiumAlias] {
|
func fetchStadiumAliasChanges(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncStadiumAlias] {
|
||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
@@ -672,11 +740,20 @@ actor CloudKitService {
|
|||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||||
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
|
log.log("☁️ [CK] Received \(records.count) stadium alias records from CloudKit")
|
||||||
|
|
||||||
var parsed: [StadiumAlias] = []
|
var parsed: [SyncStadiumAlias] = []
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
for record in records {
|
for record in records {
|
||||||
if let model = CKStadiumAlias(record: record).toModel() {
|
if let model = CKStadiumAlias(record: record).toModel() {
|
||||||
parsed.append(model)
|
parsed.append(
|
||||||
|
SyncStadiumAlias(
|
||||||
|
aliasName: model.aliasName,
|
||||||
|
stadiumCanonicalId: model.stadiumCanonicalId,
|
||||||
|
validFrom: model.validFrom,
|
||||||
|
validUntil: model.validUntil,
|
||||||
|
schemaVersion: model.schemaVersion,
|
||||||
|
lastModified: model.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
}
|
}
|
||||||
@@ -698,7 +775,7 @@ actor CloudKitService {
|
|||||||
/// - Parameters:
|
/// - Parameters:
|
||||||
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
/// - lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
||||||
/// - cancellationToken: Optional token to check for cancellation between pages
|
/// - cancellationToken: Optional token to check for cancellation between pages
|
||||||
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [CanonicalSport] {
|
func fetchSportsForSync(since lastSync: Date?, cancellationToken: SyncCancellationToken? = nil) async throws -> [SyncSport] {
|
||||||
let log = SyncLogger.shared
|
let log = SyncLogger.shared
|
||||||
let predicate: NSPredicate
|
let predicate: NSPredicate
|
||||||
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
if let deltaStart = effectiveDeltaStartDate(lastSync) {
|
||||||
@@ -713,11 +790,24 @@ actor CloudKitService {
|
|||||||
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
let records = try await fetchAllRecords(matching: query, cancellationToken: cancellationToken)
|
||||||
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
|
log.log("☁️ [CK] Received \(records.count) sport records from CloudKit")
|
||||||
|
|
||||||
var parsed: [CanonicalSport] = []
|
var parsed: [SyncSport] = []
|
||||||
var skipped = 0
|
var skipped = 0
|
||||||
for record in records {
|
for record in records {
|
||||||
if let sport = CKSport(record: record).toCanonical() {
|
if let sport = CKSport(record: record).toCanonical() {
|
||||||
parsed.append(sport)
|
parsed.append(
|
||||||
|
SyncSport(
|
||||||
|
id: sport.id,
|
||||||
|
abbreviation: sport.abbreviation,
|
||||||
|
displayName: sport.displayName,
|
||||||
|
iconName: sport.iconName,
|
||||||
|
colorHex: sport.colorHex,
|
||||||
|
seasonStartMonth: sport.seasonStartMonth,
|
||||||
|
seasonEndMonth: sport.seasonEndMonth,
|
||||||
|
isActive: sport.isActive,
|
||||||
|
schemaVersion: sport.schemaVersion,
|
||||||
|
lastModified: sport.lastModified
|
||||||
|
)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
skipped += 1
|
skipped += 1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import SwiftUI
|
|||||||
|
|
||||||
// MARK: - Identifiable Share Code
|
// MARK: - Identifiable Share Code
|
||||||
|
|
||||||
struct IdentifiableShareCode: Identifiable {
|
struct IdentifiableShareCode: Identifiable, Hashable {
|
||||||
let id: String
|
let id: String
|
||||||
var value: String { id }
|
var value: String { id }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,19 +128,14 @@ actor EVChargingService {
|
|||||||
let chargerType = detectChargerType(from: name)
|
let chargerType = detectChargerType(from: name)
|
||||||
let estimatedChargeTime = estimateChargeTime(for: chargerType)
|
let estimatedChargeTime = estimateChargeTime(for: chargerType)
|
||||||
|
|
||||||
var address: String? = nil
|
let address = formattedAddress(for: item)
|
||||||
if let placemark = item.placemark as MKPlacemark? {
|
let coordinate = coordinate(for: item)
|
||||||
var components: [String] = []
|
|
||||||
if let city = placemark.locality { components.append(city) }
|
|
||||||
if let state = placemark.administrativeArea { components.append(state) }
|
|
||||||
address = components.isEmpty ? nil : components.joined(separator: ", ")
|
|
||||||
}
|
|
||||||
|
|
||||||
return EVChargingStop(
|
return EVChargingStop(
|
||||||
name: name,
|
name: name,
|
||||||
location: LocationInput(
|
location: LocationInput(
|
||||||
name: name,
|
name: name,
|
||||||
coordinate: item.placemark.coordinate,
|
coordinate: coordinate,
|
||||||
address: address
|
address: address
|
||||||
),
|
),
|
||||||
chargerType: chargerType,
|
chargerType: chargerType,
|
||||||
@@ -148,6 +143,20 @@ actor EVChargingService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func formattedAddress(for item: MKMapItem) -> String? {
|
||||||
|
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
|
||||||
|
return cityContext
|
||||||
|
}
|
||||||
|
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
|
||||||
|
return shortAddress
|
||||||
|
}
|
||||||
|
return item.address?.fullAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
private func coordinate(for item: MKMapItem) -> CLLocationCoordinate2D {
|
||||||
|
item.location.coordinate
|
||||||
|
}
|
||||||
|
|
||||||
/// Detect charger type from name using heuristics
|
/// Detect charger type from name using heuristics
|
||||||
private func detectChargerType(from name: String) -> ChargerType {
|
private func detectChargerType(from name: String) -> ChargerType {
|
||||||
let lowercased = name.lowercased()
|
let lowercased = name.lowercased()
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ actor ItineraryItemService {
|
|||||||
// Debounce tracking
|
// Debounce tracking
|
||||||
private var pendingUpdates: [UUID: ItineraryItem] = [:]
|
private var pendingUpdates: [UUID: ItineraryItem] = [:]
|
||||||
private var retryCount: [UUID: Int] = [:]
|
private var retryCount: [UUID: Int] = [:]
|
||||||
private var debounceTask: Task<Void, Never>?
|
private var flushTask: Task<Void, Never>?
|
||||||
|
|
||||||
private let maxRetries = 3
|
private let maxRetries = 3
|
||||||
private let debounceInterval: Duration = .seconds(1.5)
|
private let debounceInterval: Duration = .seconds(1.5)
|
||||||
@@ -22,8 +22,8 @@ actor ItineraryItemService {
|
|||||||
|
|
||||||
/// Cancel any pending debounce task (for cleanup)
|
/// Cancel any pending debounce task (for cleanup)
|
||||||
func cancelPendingSync() {
|
func cancelPendingSync() {
|
||||||
debounceTask?.cancel()
|
flushTask?.cancel()
|
||||||
debounceTask = nil
|
flushTask = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CRUD Operations
|
// MARK: - CRUD Operations
|
||||||
@@ -52,26 +52,7 @@ actor ItineraryItemService {
|
|||||||
func updateItem(_ item: ItineraryItem) async {
|
func updateItem(_ item: ItineraryItem) async {
|
||||||
pendingUpdates[item.id] = item
|
pendingUpdates[item.id] = item
|
||||||
|
|
||||||
// Cancel existing debounce task
|
scheduleFlush(after: debounceInterval)
|
||||||
debounceTask?.cancel()
|
|
||||||
|
|
||||||
// Start new debounce task with proper cancellation handling
|
|
||||||
debounceTask = Task {
|
|
||||||
// Check cancellation before sleeping
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
|
|
||||||
do {
|
|
||||||
try await Task.sleep(for: debounceInterval)
|
|
||||||
} catch {
|
|
||||||
// Task was cancelled during sleep
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cancellation after sleeping (belt and suspenders)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
|
|
||||||
await flushPendingUpdates()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Force immediate sync of pending updates
|
/// Force immediate sync of pending updates
|
||||||
@@ -92,16 +73,48 @@ actor ItineraryItemService {
|
|||||||
retryCount[item.id] = nil
|
retryCount[item.id] = nil
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Add back to pending for retry, respecting max retry limit
|
// Keep failed updates queued and retry with backoff; never drop user edits.
|
||||||
|
var highestRetry = 0
|
||||||
for item in updates.values {
|
for item in updates.values {
|
||||||
let currentRetries = retryCount[item.id] ?? 0
|
let currentRetries = retryCount[item.id] ?? 0
|
||||||
if currentRetries < maxRetries {
|
let nextRetry = min(currentRetries + 1, maxRetries)
|
||||||
|
retryCount[item.id] = nextRetry
|
||||||
|
highestRetry = max(highestRetry, nextRetry)
|
||||||
pendingUpdates[item.id] = item
|
pendingUpdates[item.id] = item
|
||||||
retryCount[item.id] = currentRetries + 1
|
|
||||||
}
|
}
|
||||||
// If max retries exceeded, silently drop the update to prevent infinite loop
|
|
||||||
|
let retryDelay = retryDelay(for: highestRetry)
|
||||||
|
scheduleFlush(after: retryDelay)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Flush Scheduling
|
||||||
|
|
||||||
|
private func scheduleFlush(after delay: Duration) {
|
||||||
|
flushTask?.cancel()
|
||||||
|
flushTask = Task {
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try await Task.sleep(for: delay)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
await flushPendingUpdates()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func retryDelay(for retryLevel: Int) -> Duration {
|
||||||
|
switch retryLevel {
|
||||||
|
case 0, 1:
|
||||||
|
return .seconds(5)
|
||||||
|
case 2:
|
||||||
|
return .seconds(15)
|
||||||
|
default:
|
||||||
|
return .seconds(30)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete an item
|
/// Delete an item
|
||||||
@@ -116,7 +129,7 @@ actor ItineraryItemService {
|
|||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let recordId = CKRecord.ID(recordName: item.id.uuidString)
|
let recordId = CKRecord.ID(recordName: item.id.uuidString)
|
||||||
try? await database.deleteRecord(withID: recordId)
|
_ = try? await database.deleteRecord(withID: recordId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import Foundation
|
|||||||
import CoreLocation
|
import CoreLocation
|
||||||
import MapKit
|
import MapKit
|
||||||
|
|
||||||
extension MKPolyline: @unchecked Sendable {}
|
extension MKPolyline: @retroactive @unchecked Sendable {}
|
||||||
|
|
||||||
actor LocationService {
|
actor LocationService {
|
||||||
static let shared = LocationService()
|
static let shared = LocationService()
|
||||||
@@ -80,24 +80,31 @@ actor LocationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting")
|
|
||||||
private func formatMapItem(_ item: MKMapItem) -> String {
|
private func formatMapItem(_ item: MKMapItem) -> String {
|
||||||
var components: [String] = []
|
var components: [String] = []
|
||||||
if let locality = item.placemark.locality {
|
|
||||||
components.append(locality)
|
if let cityContext = item.addressRepresentations?.cityWithContext,
|
||||||
|
!cityContext.isEmpty {
|
||||||
|
components.append(cityContext)
|
||||||
}
|
}
|
||||||
if let state = item.placemark.administrativeArea {
|
if let regionName = item.addressRepresentations?.regionName,
|
||||||
components.append(state)
|
regionName != "United States" {
|
||||||
}
|
components.append(regionName)
|
||||||
if let country = item.placemark.country, country != "United States" {
|
|
||||||
components.append(country)
|
|
||||||
}
|
|
||||||
if components.isEmpty {
|
|
||||||
return item.name ?? ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !components.isEmpty {
|
||||||
return components.joined(separator: ", ")
|
return components.joined(separator: ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
|
||||||
|
return shortAddress
|
||||||
|
}
|
||||||
|
if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty {
|
||||||
|
return fullAddress
|
||||||
|
}
|
||||||
|
return item.name ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Distance Calculations
|
// MARK: - Distance Calculations
|
||||||
|
|
||||||
func calculateDistance(
|
func calculateDistance(
|
||||||
@@ -163,7 +170,7 @@ actor LocationService {
|
|||||||
struct RouteInfo: Sendable {
|
struct RouteInfo: Sendable {
|
||||||
let distance: CLLocationDistance // meters
|
let distance: CLLocationDistance // meters
|
||||||
let expectedTravelTime: TimeInterval // seconds
|
let expectedTravelTime: TimeInterval // seconds
|
||||||
nonisolated(unsafe) let polyline: MKPolyline?
|
let polyline: MKPolyline?
|
||||||
|
|
||||||
var distanceMiles: Double { distance * 0.000621371 }
|
var distanceMiles: Double { distance * 0.000621371 }
|
||||||
var travelTimeHours: Double { expectedTravelTime / 3600.0 }
|
var travelTimeHours: Double { expectedTravelTime / 3600.0 }
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ enum PollError: Error, LocalizedError {
|
|||||||
case pollNotFound
|
case pollNotFound
|
||||||
case alreadyVoted
|
case alreadyVoted
|
||||||
case notPollOwner
|
case notPollOwner
|
||||||
|
case notVoteOwner
|
||||||
case networkUnavailable
|
case networkUnavailable
|
||||||
case encodingError
|
case encodingError
|
||||||
case unknown(Error)
|
case unknown(Error)
|
||||||
@@ -30,6 +31,8 @@ enum PollError: Error, LocalizedError {
|
|||||||
return "You have already voted on this poll."
|
return "You have already voted on this poll."
|
||||||
case .notPollOwner:
|
case .notPollOwner:
|
||||||
return "Only the poll owner can perform this action."
|
return "Only the poll owner can perform this action."
|
||||||
|
case .notVoteOwner:
|
||||||
|
return "Only the vote owner can update this vote."
|
||||||
case .networkUnavailable:
|
case .networkUnavailable:
|
||||||
return "Unable to connect. Please check your internet connection."
|
return "Unable to connect. Please check your internet connection."
|
||||||
case .encodingError:
|
case .encodingError:
|
||||||
@@ -49,7 +52,7 @@ actor PollService {
|
|||||||
private let publicDatabase: CKDatabase
|
private let publicDatabase: CKDatabase
|
||||||
|
|
||||||
private var currentUserRecordID: String?
|
private var currentUserRecordID: String?
|
||||||
private var pollSubscriptionID: CKSubscription.ID?
|
private var pollSubscriptionIDs: Set<CKSubscription.ID> = []
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
self.container = CloudKitContainerConfig.makeContainer()
|
self.container = CloudKitContainerConfig.makeContainer()
|
||||||
@@ -231,7 +234,7 @@ actor PollService {
|
|||||||
func updateVote(_ vote: PollVote) async throws -> PollVote {
|
func updateVote(_ vote: PollVote) async throws -> PollVote {
|
||||||
let userId = try await getCurrentUserRecordID()
|
let userId = try await getCurrentUserRecordID()
|
||||||
guard vote.odg == userId else {
|
guard vote.odg == userId else {
|
||||||
throw PollError.notPollOwner // Using this error for now - voter can only update own vote
|
throw PollError.notVoteOwner
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the existing record to get the server's changeTag
|
// Fetch the existing record to get the server's changeTag
|
||||||
@@ -245,6 +248,13 @@ actor PollService {
|
|||||||
throw PollError.unknown(error)
|
throw PollError.unknown(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard let existingVoterId = existingRecord[CKPollVote.voterIdKey] as? String else {
|
||||||
|
throw PollError.encodingError
|
||||||
|
}
|
||||||
|
guard existingVoterId == userId else {
|
||||||
|
throw PollError.notVoteOwner
|
||||||
|
}
|
||||||
|
|
||||||
// Update the fields on the fetched record
|
// Update the fields on the fetched record
|
||||||
let now = Date()
|
let now = Date()
|
||||||
existingRecord[CKPollVote.rankingsKey] = vote.rankings
|
existingRecord[CKPollVote.rankingsKey] = vote.rankings
|
||||||
@@ -320,11 +330,12 @@ actor PollService {
|
|||||||
// MARK: - Subscriptions
|
// MARK: - Subscriptions
|
||||||
|
|
||||||
func subscribeToVoteUpdates(forPollId pollId: UUID) async throws {
|
func subscribeToVoteUpdates(forPollId pollId: UUID) async throws {
|
||||||
|
let subscriptionId = "poll-votes-\(pollId.uuidString)"
|
||||||
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
|
let predicate = NSPredicate(format: "%K == %@", CKPollVote.pollIdKey, pollId.uuidString)
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
recordType: CKRecordType.pollVote,
|
recordType: CKRecordType.pollVote,
|
||||||
predicate: predicate,
|
predicate: predicate,
|
||||||
subscriptionID: "poll-votes-\(pollId.uuidString)",
|
subscriptionID: subscriptionId,
|
||||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -334,10 +345,12 @@ actor PollService {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await publicDatabase.save(subscription)
|
try await publicDatabase.save(subscription)
|
||||||
pollSubscriptionID = subscription.subscriptionID
|
pollSubscriptionIDs.insert(subscription.subscriptionID)
|
||||||
} catch let error as CKError {
|
} catch let error as CKError {
|
||||||
// Subscription already exists is OK
|
// Subscription already exists is OK
|
||||||
if error.code != .serverRejectedRequest {
|
if error.code == .serverRejectedRequest {
|
||||||
|
pollSubscriptionIDs.insert(subscriptionId)
|
||||||
|
} else {
|
||||||
throw mapCloudKitError(error)
|
throw mapCloudKitError(error)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -345,15 +358,24 @@ actor PollService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func unsubscribeFromVoteUpdates() async throws {
|
func unsubscribeFromVoteUpdates(forPollId pollId: UUID? = nil) async throws {
|
||||||
guard let subscriptionID = pollSubscriptionID else { return }
|
let subscriptionIds: [CKSubscription.ID]
|
||||||
|
if let pollId {
|
||||||
|
subscriptionIds = ["poll-votes-\(pollId.uuidString)"]
|
||||||
|
} else {
|
||||||
|
subscriptionIds = Array(pollSubscriptionIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !subscriptionIds.isEmpty else { return }
|
||||||
|
|
||||||
|
for subscriptionID in subscriptionIds {
|
||||||
do {
|
do {
|
||||||
try await publicDatabase.deleteSubscription(withID: subscriptionID)
|
try await publicDatabase.deleteSubscription(withID: subscriptionID)
|
||||||
pollSubscriptionID = nil
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore errors - subscription may not exist
|
// Ignore errors - subscription may not exist
|
||||||
}
|
}
|
||||||
|
pollSubscriptionIDs.remove(subscriptionID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Results
|
// MARK: - Results
|
||||||
|
|||||||
@@ -25,24 +25,24 @@ final class RouteDescriptionGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func checkAvailability() {
|
private func checkAvailability() {
|
||||||
// TEMPORARILY DISABLED: Foundation Models crashes in iOS 26.2 Simulator
|
#if targetEnvironment(simulator)
|
||||||
// due to NLLanguageRecognizer.processString: assertion failure in Collection.suffix(from:)
|
// Keep simulator behavior deterministic and avoid known FoundationModels simulator crashes.
|
||||||
// TODO: Re-enable when Apple fixes the Foundation Models framework
|
|
||||||
isAvailable = false
|
isAvailable = false
|
||||||
return
|
session = nil
|
||||||
|
#else
|
||||||
// Original code (disabled):
|
switch SystemLanguageModel.default.availability {
|
||||||
// switch SystemLanguageModel.default.availability {
|
case .available:
|
||||||
// case .available:
|
isAvailable = true
|
||||||
// isAvailable = true
|
session = LanguageModelSession(instructions: """
|
||||||
// session = LanguageModelSession(instructions: """
|
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
||||||
// You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
||||||
// Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
Keep descriptions to 1-2 short sentences maximum.
|
||||||
// Keep descriptions to 1-2 short sentences maximum.
|
""")
|
||||||
// """)
|
case .unavailable:
|
||||||
// case .unavailable:
|
isAvailable = false
|
||||||
// isAvailable = false
|
session = nil
|
||||||
// }
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate a brief, exciting description for a route option
|
/// Generate a brief, exciting description for a route option
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ struct NBAStatsProvider: ScoreAPIProvider {
|
|||||||
// Get column indices
|
// Get column indices
|
||||||
let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID")
|
let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID")
|
||||||
let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID")
|
let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID")
|
||||||
let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT")
|
|
||||||
|
|
||||||
// Find the LineScore result set for scores
|
// Find the LineScore result set for scores
|
||||||
let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" })
|
let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" })
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ final class SyncLogger: @unchecked Sendable {
|
|||||||
private let queue = DispatchQueue(label: "com.88oakapps.SportsTime.synclogger")
|
private let queue = DispatchQueue(label: "com.88oakapps.SportsTime.synclogger")
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first
|
||||||
|
?? FileManager.default.temporaryDirectory
|
||||||
fileURL = docs.appendingPathComponent("sync_log.txt")
|
fileURL = docs.appendingPathComponent("sync_log.txt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ final class StoreManager {
|
|||||||
|
|
||||||
// MARK: - Transaction Listener
|
// MARK: - Transaction Listener
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
|
nonisolated(unsafe) private var transactionListenerTask: Task<Void, Never>?
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|||||||
@@ -75,9 +75,11 @@ final class ThemeManager: @unchecked Sendable {
|
|||||||
|
|
||||||
private func updateAppIcon(for theme: AppTheme) {
|
private func updateAppIcon(for theme: AppTheme) {
|
||||||
let iconName = theme.alternateIconName
|
let iconName = theme.alternateIconName
|
||||||
|
Task { @MainActor in
|
||||||
guard UIApplication.shared.alternateIconName != iconName else { return }
|
guard UIApplication.shared.alternateIconName != iconName else { return }
|
||||||
UIApplication.shared.setAlternateIconName(iconName)
|
UIApplication.shared.setAlternateIconName(iconName)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
if let saved = UserDefaults.standard.string(forKey: "selectedTheme"),
|
if let saved = UserDefaults.standard.string(forKey: "selectedTheme"),
|
||||||
@@ -460,11 +462,13 @@ enum Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the system Reduce Motion preference is enabled.
|
/// Whether the system Reduce Motion preference is enabled.
|
||||||
|
@MainActor
|
||||||
static var prefersReducedMotion: Bool {
|
static var prefersReducedMotion: Bool {
|
||||||
UIAccessibility.isReduceMotionEnabled
|
UIAccessibility.isReduceMotionEnabled
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
|
/// Performs a state change with animation, or instantly if Reduce Motion is enabled.
|
||||||
|
@MainActor
|
||||||
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
|
static func withMotion(_ animation: SwiftUI.Animation = spring, _ body: () -> Void) {
|
||||||
if prefersReducedMotion {
|
if prefersReducedMotion {
|
||||||
body()
|
body()
|
||||||
|
|||||||
@@ -225,18 +225,13 @@ actor POISearchService {
|
|||||||
return pois
|
return pois
|
||||||
}
|
}
|
||||||
|
|
||||||
@available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting")
|
|
||||||
private func formatAddress(_ item: MKMapItem) -> String? {
|
private func formatAddress(_ item: MKMapItem) -> String? {
|
||||||
var components: [String] = []
|
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
|
||||||
|
return shortAddress
|
||||||
if let subThoroughfare = item.placemark.subThoroughfare {
|
|
||||||
components.append(subThoroughfare)
|
|
||||||
}
|
}
|
||||||
if let thoroughfare = item.placemark.thoroughfare {
|
if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty {
|
||||||
components.append(thoroughfare)
|
return fullAddress
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
guard !components.isEmpty else { return nil }
|
|
||||||
return components.joined(separator: " ")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ final class PollDetailViewModel {
|
|||||||
var error: PollError?
|
var error: PollError?
|
||||||
|
|
||||||
private let pollService = PollService.shared
|
private let pollService = PollService.shared
|
||||||
|
private var subscribedPollId: UUID?
|
||||||
|
|
||||||
var results: PollResults? {
|
var results: PollResults? {
|
||||||
guard let poll else { return nil }
|
guard let poll else { return nil }
|
||||||
@@ -65,7 +66,7 @@ final class PollDetailViewModel {
|
|||||||
self.myVote = fetchedMyVote
|
self.myVote = fetchedMyVote
|
||||||
|
|
||||||
// Subscribe to vote updates
|
// Subscribe to vote updates
|
||||||
try? await pollService.subscribeToVoteUpdates(forPollId: pollId)
|
await subscribeToVoteUpdates(for: pollId)
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
error = pollError
|
error = pollError
|
||||||
} catch {
|
} catch {
|
||||||
@@ -92,7 +93,7 @@ final class PollDetailViewModel {
|
|||||||
self.myVote = fetchedMyVote
|
self.myVote = fetchedMyVote
|
||||||
|
|
||||||
// Subscribe to vote updates
|
// Subscribe to vote updates
|
||||||
try? await pollService.subscribeToVoteUpdates(forPollId: fetchedPoll.id)
|
await subscribeToVoteUpdates(for: fetchedPoll.id)
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
error = pollError
|
error = pollError
|
||||||
} catch {
|
} catch {
|
||||||
@@ -119,7 +120,7 @@ final class PollDetailViewModel {
|
|||||||
self.myVote = fetchedMyVote
|
self.myVote = fetchedMyVote
|
||||||
|
|
||||||
// Subscribe to vote updates
|
// Subscribe to vote updates
|
||||||
try? await pollService.subscribeToVoteUpdates(forPollId: existingPoll.id)
|
await subscribeToVoteUpdates(for: existingPoll.id)
|
||||||
} catch {
|
} catch {
|
||||||
// Votes fetch failed, but we have the poll - non-critical error
|
// Votes fetch failed, but we have the poll - non-critical error
|
||||||
self.votes = []
|
self.votes = []
|
||||||
@@ -155,7 +156,8 @@ final class PollDetailViewModel {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try await pollService.deletePoll(poll.id)
|
try await pollService.deletePoll(poll.id)
|
||||||
try? await pollService.unsubscribeFromVoteUpdates()
|
try? await pollService.unsubscribeFromVoteUpdates(forPollId: poll.id)
|
||||||
|
subscribedPollId = nil
|
||||||
self.poll = nil
|
self.poll = nil
|
||||||
return true
|
return true
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
@@ -169,6 +171,17 @@ final class PollDetailViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cleanup() async {
|
func cleanup() async {
|
||||||
try? await pollService.unsubscribeFromVoteUpdates()
|
guard let subscribedPollId else { return }
|
||||||
|
try? await pollService.unsubscribeFromVoteUpdates(forPollId: subscribedPollId)
|
||||||
|
self.subscribedPollId = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func subscribeToVoteUpdates(for pollId: UUID) async {
|
||||||
|
if let existingPollId = subscribedPollId, existingPollId != pollId {
|
||||||
|
try? await pollService.unsubscribeFromVoteUpdates(forPollId: existingPollId)
|
||||||
|
}
|
||||||
|
|
||||||
|
try? await pollService.subscribeToVoteUpdates(forPollId: pollId)
|
||||||
|
subscribedPollId = pollId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ struct PollsListView: View {
|
|||||||
@State private var polls: [TripPoll] = []
|
@State private var polls: [TripPoll] = []
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var hasLoadedInitially = false
|
@State private var hasLoadedInitially = false
|
||||||
@State private var error: PollError?
|
@State private var errorMessage: String?
|
||||||
@State private var showJoinPoll = false
|
@State private var showJoinPoll = false
|
||||||
@State private var joinCode = ""
|
@State private var joinCode = ""
|
||||||
|
@State private var pendingJoinCode: IdentifiableShareCode?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Group {
|
Group {
|
||||||
@@ -50,10 +51,17 @@ struct PollsListView: View {
|
|||||||
TextField("Enter code", text: $joinCode)
|
TextField("Enter code", text: $joinCode)
|
||||||
.textInputAutocapitalization(.characters)
|
.textInputAutocapitalization(.characters)
|
||||||
Button("Join") {
|
Button("Join") {
|
||||||
// Navigation will be handled by deep link
|
let normalizedCode = joinCode
|
||||||
if !joinCode.isEmpty {
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
// TODO: Navigate to poll detail
|
.uppercased()
|
||||||
|
|
||||||
|
guard normalizedCode.count == 6 else {
|
||||||
|
errorMessage = "Share code must be exactly 6 characters."
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingJoinCode = IdentifiableShareCode(id: normalizedCode)
|
||||||
|
joinCode = ""
|
||||||
}
|
}
|
||||||
Button("Cancel", role: .cancel) {
|
Button("Cancel", role: .cancel) {
|
||||||
joinCode = ""
|
joinCode = ""
|
||||||
@@ -61,14 +69,21 @@ struct PollsListView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Enter the 6-character poll code")
|
Text("Enter the 6-character poll code")
|
||||||
}
|
}
|
||||||
.alert("Error", isPresented: .constant(error != nil)) {
|
.navigationDestination(item: $pendingJoinCode) { code in
|
||||||
Button("OK") {
|
PollDetailView(shareCode: code.value)
|
||||||
error = nil
|
}
|
||||||
|
.alert(
|
||||||
|
"Error",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { errorMessage != nil },
|
||||||
|
set: { if !$0 { errorMessage = nil } }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button("OK", role: .cancel) {
|
||||||
|
errorMessage = nil
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
if let error {
|
Text(errorMessage ?? "Please try again.")
|
||||||
Text(error.localizedDescription)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,14 +114,14 @@ struct PollsListView: View {
|
|||||||
|
|
||||||
private func loadPolls() async {
|
private func loadPolls() async {
|
||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
errorMessage = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
polls = try await PollService.shared.fetchMyPolls()
|
polls = try await PollService.shared.fetchMyPolls()
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
error = pollError
|
errorMessage = pollError.localizedDescription
|
||||||
} catch {
|
} catch {
|
||||||
self.error = .unknown(error)
|
self.errorMessage = PollError.unknown(error).localizedDescription
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ final class ProgressViewModel {
|
|||||||
|
|
||||||
/// Count of trips for the selected sport (stub - can be enhanced)
|
/// Count of trips for the selected sport (stub - can be enhanced)
|
||||||
var tripCount: Int {
|
var tripCount: Int {
|
||||||
// TODO: Fetch saved trips count from SwiftData
|
savedTripCount
|
||||||
0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recent visits sorted by date (cached, recomputed on data change)
|
/// Recent visits sorted by date (cached, recomputed on data change)
|
||||||
private(set) var recentVisits: [VisitSummary] = []
|
private(set) var recentVisits: [VisitSummary] = []
|
||||||
|
private(set) var savedTripCount: Int = 0
|
||||||
|
|
||||||
// MARK: - Configuration
|
// MARK: - Configuration
|
||||||
|
|
||||||
@@ -89,6 +89,7 @@ final class ProgressViewModel {
|
|||||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||||
)
|
)
|
||||||
visits = try context.fetch(descriptor)
|
visits = try context.fetch(descriptor)
|
||||||
|
savedTripCount = try context.fetchCount(FetchDescriptor<SavedTrip>())
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
self.error = error
|
self.error = error
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ final class ScheduleViewModel {
|
|||||||
// MARK: - Filter State
|
// MARK: - Filter State
|
||||||
|
|
||||||
var selectedSports: Set<Sport> = Set(Sport.supported)
|
var selectedSports: Set<Sport> = Set(Sport.supported)
|
||||||
var startDate: Date = Calendar.current.startOfDay(for: Date())
|
var startDate: Date = ScheduleViewModel.defaultDateRange().start
|
||||||
var endDate: Date = Calendar.current.date(byAdding: .day, value: 14, to: Calendar.current.startOfDay(for: Date())) ?? Date()
|
var endDate: Date = ScheduleViewModel.defaultDateRange().end
|
||||||
var searchText: String = ""
|
var searchText: String = ""
|
||||||
|
|
||||||
// MARK: - Data State
|
// MARK: - Data State
|
||||||
@@ -28,7 +28,9 @@ final class ScheduleViewModel {
|
|||||||
private(set) var errorMessage: String?
|
private(set) var errorMessage: String?
|
||||||
|
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
|
@ObservationIgnored
|
||||||
nonisolated(unsafe) private var loadTask: Task<Void, Never>?
|
nonisolated(unsafe) private var loadTask: Task<Void, Never>?
|
||||||
|
private var latestLoadRequestID = UUID()
|
||||||
|
|
||||||
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
// MARK: - Pre-computed Groupings (avoid computed property overhead)
|
||||||
|
|
||||||
@@ -48,14 +50,43 @@ final class ScheduleViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var hasFilters: Bool {
|
var hasFilters: Bool {
|
||||||
selectedSports.count < Sport.supported.count || !searchText.isEmpty
|
let defaults = Self.defaultDateRange()
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let hasDateFilter = !calendar.isDate(startDate, inSameDayAs: defaults.start) ||
|
||||||
|
!calendar.isDate(endDate, inSameDayAs: defaults.end)
|
||||||
|
return selectedSports.count < Sport.supported.count || !searchText.isEmpty || hasDateFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func defaultDateRange() -> (start: Date, end: Date) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let start = calendar.startOfDay(for: Date())
|
||||||
|
let endDay = calendar.date(byAdding: .day, value: 14, to: start) ?? start
|
||||||
|
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
return (start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedDateRange(start: Date, end: Date) -> (start: Date, end: Date) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let normalizedStart = calendar.startOfDay(for: start)
|
||||||
|
let endDay = calendar.startOfDay(for: end)
|
||||||
|
let normalizedEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
return (normalizedStart, normalizedEnd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
func loadGames() async {
|
func loadGames() async {
|
||||||
guard !selectedSports.isEmpty else {
|
let requestID = beginLoadRequest()
|
||||||
|
let queryStartDate = startDate
|
||||||
|
let queryEndDate = endDate
|
||||||
|
let querySports = selectedSports
|
||||||
|
|
||||||
|
guard !querySports.isEmpty else {
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
games = []
|
games = []
|
||||||
|
isLoading = false
|
||||||
|
error = nil
|
||||||
|
errorMessage = nil
|
||||||
updateFilteredGames()
|
updateFilteredGames()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -64,11 +95,6 @@ final class ScheduleViewModel {
|
|||||||
error = nil
|
error = nil
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
// Start diagnostics
|
|
||||||
let queryStartDate = startDate
|
|
||||||
let queryEndDate = endDate
|
|
||||||
let querySports = selectedSports
|
|
||||||
|
|
||||||
logger.info("📅 Loading games: \(querySports.map(\.rawValue).joined(separator: ", "))")
|
logger.info("📅 Loading games: \(querySports.map(\.rawValue).joined(separator: ", "))")
|
||||||
logger.info("📅 Date range: \(queryStartDate.formatted()) to \(queryEndDate.formatted())")
|
logger.info("📅 Date range: \(queryStartDate.formatted()) to \(queryEndDate.formatted())")
|
||||||
|
|
||||||
@@ -78,9 +104,11 @@ final class ScheduleViewModel {
|
|||||||
logger.info("📅 Teams empty, loading initial data...")
|
logger.info("📅 Teams empty, loading initial data...")
|
||||||
await dataProvider.loadInitialData()
|
await dataProvider.loadInitialData()
|
||||||
}
|
}
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
|
|
||||||
// Check if data provider had an error
|
// Check if data provider had an error
|
||||||
if let providerError = dataProvider.errorMessage {
|
if let providerError = dataProvider.errorMessage {
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
self.errorMessage = providerError
|
self.errorMessage = providerError
|
||||||
self.error = dataProvider.error
|
self.error = dataProvider.error
|
||||||
isLoading = false
|
isLoading = false
|
||||||
@@ -92,11 +120,13 @@ final class ScheduleViewModel {
|
|||||||
// Log team/stadium counts for diagnostics
|
// Log team/stadium counts for diagnostics
|
||||||
logger.info("📅 Loaded \(self.dataProvider.teams.count) teams, \(self.dataProvider.stadiums.count) stadiums")
|
logger.info("📅 Loaded \(self.dataProvider.teams.count) teams, \(self.dataProvider.stadiums.count) stadiums")
|
||||||
|
|
||||||
games = try await dataProvider.filterRichGames(
|
let loadedGames = try await dataProvider.filterRichGames(
|
||||||
sports: selectedSports,
|
sports: querySports,
|
||||||
startDate: startDate,
|
startDate: queryStartDate,
|
||||||
endDate: endDate
|
endDate: queryEndDate
|
||||||
)
|
)
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
|
games = loadedGames
|
||||||
|
|
||||||
// Update diagnostics
|
// Update diagnostics
|
||||||
var newDiagnostics = ScheduleDiagnostics()
|
var newDiagnostics = ScheduleDiagnostics()
|
||||||
@@ -116,7 +146,7 @@ final class ScheduleViewModel {
|
|||||||
|
|
||||||
self.diagnostics = newDiagnostics
|
self.diagnostics = newDiagnostics
|
||||||
|
|
||||||
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(selectedSports).map(\.rawValue)))
|
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(querySports).map(\.rawValue)))
|
||||||
logger.info("📅 Returned \(self.games.count) games")
|
logger.info("📅 Returned \(self.games.count) games")
|
||||||
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
|
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
|
||||||
logger.info("📅 \(sport.rawValue): \(count) games")
|
logger.info("📅 \(sport.rawValue): \(count) games")
|
||||||
@@ -133,15 +163,18 @@ final class ScheduleViewModel {
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
} catch let cloudKitError as CloudKitError {
|
} catch let cloudKitError as CloudKitError {
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
self.error = cloudKitError
|
self.error = cloudKitError
|
||||||
self.errorMessage = cloudKitError.errorDescription
|
self.errorMessage = cloudKitError.errorDescription
|
||||||
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
|
logger.error("📅 CloudKit error: \(cloudKitError.errorDescription ?? "unknown")")
|
||||||
} catch {
|
} catch {
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
self.error = error
|
self.error = error
|
||||||
self.errorMessage = error.localizedDescription
|
self.errorMessage = error.localizedDescription
|
||||||
logger.error("📅 Error loading games: \(error.localizedDescription)")
|
logger.error("📅 Error loading games: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard !isStaleLoad(requestID) else { return }
|
||||||
isLoading = false
|
isLoading = false
|
||||||
updateFilteredGames()
|
updateFilteredGames()
|
||||||
}
|
}
|
||||||
@@ -165,15 +198,17 @@ final class ScheduleViewModel {
|
|||||||
func resetFilters() {
|
func resetFilters() {
|
||||||
selectedSports = Set(Sport.supported)
|
selectedSports = Set(Sport.supported)
|
||||||
searchText = ""
|
searchText = ""
|
||||||
startDate = Date()
|
let defaults = Self.defaultDateRange()
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
startDate = defaults.start
|
||||||
|
endDate = defaults.end
|
||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
loadTask = Task { await loadGames() }
|
loadTask = Task { await loadGames() }
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateDateRange(start: Date, end: Date) {
|
func updateDateRange(start: Date, end: Date) {
|
||||||
startDate = start
|
let normalized = Self.normalizedDateRange(start: start, end: end)
|
||||||
endDate = end
|
startDate = normalized.start
|
||||||
|
endDate = normalized.end
|
||||||
loadTask?.cancel()
|
loadTask?.cancel()
|
||||||
loadTask = Task { await loadGames() }
|
loadTask = Task { await loadGames() }
|
||||||
}
|
}
|
||||||
@@ -212,6 +247,16 @@ final class ScheduleViewModel {
|
|||||||
return lhs.game.dateTime < rhs.game.dateTime
|
return lhs.game.dateTime < rhs.game.dateTime
|
||||||
}) }
|
}) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func beginLoadRequest() -> UUID {
|
||||||
|
let requestID = UUID()
|
||||||
|
latestLoadRequestID = requestID
|
||||||
|
return requestID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStaleLoad(_ requestID: UUID) -> Bool {
|
||||||
|
Task.isCancelled || latestLoadRequestID != requestID
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Diagnostics Model
|
// MARK: - Diagnostics Model
|
||||||
|
|||||||
@@ -362,18 +362,15 @@ struct DateRangePickerSheet: View {
|
|||||||
|
|
||||||
Section {
|
Section {
|
||||||
Button("Next 7 Days") {
|
Button("Next 7 Days") {
|
||||||
startDate = Date()
|
applyQuickRange(days: 7)
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Next 14 Days") {
|
Button("Next 14 Days") {
|
||||||
startDate = Date()
|
applyQuickRange(days: 14)
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Button("Next 30 Days") {
|
Button("Next 30 Days") {
|
||||||
startDate = Date()
|
applyQuickRange(days: 30)
|
||||||
endDate = Calendar.current.date(byAdding: .day, value: 30, to: Date()) ?? Date()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +391,15 @@ struct DateRangePickerSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyQuickRange(days: Int) {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let start = calendar.startOfDay(for: Date())
|
||||||
|
let endDay = calendar.date(byAdding: .day, value: days, to: start) ?? start
|
||||||
|
let end = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
startDate = start
|
||||||
|
endDate = end
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Schedule Diagnostics Sheet
|
// MARK: - Schedule Diagnostics Sheet
|
||||||
|
|||||||
@@ -216,10 +216,10 @@ struct SportsIconImageGeneratorView: View {
|
|||||||
.disabled(isGenerating)
|
.disabled(isGenerating)
|
||||||
|
|
||||||
// Share button (only visible when image exists)
|
// Share button (only visible when image exists)
|
||||||
if generatedImage != nil {
|
if let generatedImage {
|
||||||
ShareLink(
|
ShareLink(
|
||||||
item: Image(uiImage: generatedImage!),
|
item: Image(uiImage: generatedImage),
|
||||||
preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage!))
|
preview: SharePreview("Sports Icons", image: Image(uiImage: generatedImage))
|
||||||
) {
|
) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
let syncService = CanonicalSyncService()
|
let syncService = CanonicalSyncService()
|
||||||
await syncService.resumeSync(context: modelContext)
|
syncService.resumeSync(context: modelContext)
|
||||||
syncActionMessage = "Sync has been re-enabled."
|
syncActionMessage = "Sync has been re-enabled."
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
@@ -670,7 +670,7 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
let syncService = CanonicalSyncService()
|
let syncService = CanonicalSyncService()
|
||||||
await syncService.resumeSync(context: modelContext)
|
syncService.resumeSync(context: modelContext)
|
||||||
print("[SyncDebug] Sync re-enabled by user")
|
print("[SyncDebug] Sync re-enabled by user")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
@@ -27,8 +27,13 @@ final class TripWizardViewModel {
|
|||||||
|
|
||||||
// MARK: - Dates
|
// MARK: - Dates
|
||||||
|
|
||||||
var startDate: Date = Date()
|
var startDate: Date = Calendar.current.startOfDay(for: Date())
|
||||||
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
var endDate: Date = {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let start = calendar.startOfDay(for: Date())
|
||||||
|
let endDay = calendar.date(byAdding: .day, value: 7, to: start) ?? start
|
||||||
|
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
}()
|
||||||
var hasSetDates: Bool = false
|
var hasSetDates: Bool = false
|
||||||
|
|
||||||
// MARK: - Regions
|
// MARK: - Regions
|
||||||
@@ -212,13 +217,17 @@ final class TripWizardViewModel {
|
|||||||
defer { isLoadingSportAvailability = false }
|
defer { isLoadingSportAvailability = false }
|
||||||
|
|
||||||
var availability: [Sport: Bool] = [:]
|
var availability: [Sport: Bool] = [:]
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let queryStart = calendar.startOfDay(for: startDate)
|
||||||
|
let endDay = calendar.startOfDay(for: endDate)
|
||||||
|
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
|
||||||
for sport in Sport.supported {
|
for sport in Sport.supported {
|
||||||
do {
|
do {
|
||||||
let games = try await AppDataProvider.shared.filterGames(
|
let games = try await AppDataProvider.shared.filterGames(
|
||||||
sports: [sport],
|
sports: [sport],
|
||||||
startDate: startDate,
|
startDate: queryStart,
|
||||||
endDate: endDate
|
endDate: queryEnd
|
||||||
)
|
)
|
||||||
availability[sport] = !games.isEmpty
|
availability[sport] = !games.isEmpty
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -449,20 +449,13 @@ private struct PlaceRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var formattedAddress: String? {
|
private var formattedAddress: String? {
|
||||||
let placemark = place.placemark
|
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
|
||||||
var components: [String] = []
|
return shortAddress
|
||||||
|
|
||||||
if let thoroughfare = placemark.thoroughfare {
|
|
||||||
components.append(thoroughfare)
|
|
||||||
}
|
}
|
||||||
if let locality = placemark.locality {
|
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
|
||||||
components.append(locality)
|
return fullAddress
|
||||||
}
|
}
|
||||||
if let administrativeArea = placemark.administrativeArea {
|
return place.addressRepresentations?.cityWithContext
|
||||||
components.append(administrativeArea)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -429,10 +429,14 @@ struct QuickAddItemSheet: View {
|
|||||||
// Restore location if present
|
// Restore location if present
|
||||||
if let lat = info.latitude,
|
if let lat = info.latitude,
|
||||||
let lon = info.longitude {
|
let lon = info.longitude {
|
||||||
let placemark = MKPlacemark(
|
let location = CLLocation(latitude: lat, longitude: lon)
|
||||||
coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
let mapItem: MKMapItem
|
||||||
)
|
if #available(iOS 26.0, *) {
|
||||||
let mapItem = MKMapItem(placemark: placemark)
|
mapItem = MKMapItem(location: location, address: nil)
|
||||||
|
} else {
|
||||||
|
let placemark = MKPlacemark(coordinate: location.coordinate)
|
||||||
|
mapItem = MKMapItem(placemark: placemark)
|
||||||
|
}
|
||||||
mapItem.name = info.title
|
mapItem.name = info.title
|
||||||
selectedPlace = mapItem
|
selectedPlace = mapItem
|
||||||
}
|
}
|
||||||
@@ -447,7 +451,7 @@ struct QuickAddItemSheet: View {
|
|||||||
|
|
||||||
if let place = selectedPlace {
|
if let place = selectedPlace {
|
||||||
// Item with location
|
// Item with location
|
||||||
let coordinate = place.placemark.coordinate
|
let coordinate = mapItemCoordinate(for: place)
|
||||||
customInfo = CustomInfo(
|
customInfo = CustomInfo(
|
||||||
title: trimmedTitle,
|
title: trimmedTitle,
|
||||||
icon: "\u{1F4CC}",
|
icon: "\u{1F4CC}",
|
||||||
@@ -489,21 +493,18 @@ struct QuickAddItemSheet: View {
|
|||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func mapItemCoordinate(for place: MKMapItem) -> CLLocationCoordinate2D {
|
||||||
|
place.location.coordinate
|
||||||
|
}
|
||||||
|
|
||||||
private func formatAddress(for place: MKMapItem) -> String? {
|
private func formatAddress(for place: MKMapItem) -> String? {
|
||||||
let placemark = place.placemark
|
if let shortAddress = place.address?.shortAddress, !shortAddress.isEmpty {
|
||||||
var components: [String] = []
|
return shortAddress
|
||||||
|
|
||||||
if let thoroughfare = placemark.thoroughfare {
|
|
||||||
components.append(thoroughfare)
|
|
||||||
}
|
}
|
||||||
if let locality = placemark.locality {
|
if let fullAddress = place.address?.fullAddress, !fullAddress.isEmpty {
|
||||||
components.append(locality)
|
return fullAddress
|
||||||
}
|
}
|
||||||
if let administrativeArea = placemark.administrativeArea {
|
return place.addressRepresentations?.cityWithContext
|
||||||
components.append(administrativeArea)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - POI Loading
|
// MARK: - POI Loading
|
||||||
|
|||||||
@@ -340,24 +340,9 @@ private struct PlaceResultRow: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var formattedAddress: String? {
|
private var formattedAddress: String? {
|
||||||
let placemark = item.placemark
|
if let cityContext = item.addressRepresentations?.cityWithContext, !cityContext.isEmpty {
|
||||||
var components: [String] = []
|
return cityContext
|
||||||
|
|
||||||
if let locality = placemark.locality {
|
|
||||||
components.append(locality)
|
|
||||||
}
|
}
|
||||||
if let administrativeArea = placemark.administrativeArea {
|
return item.address?.shortAddress ?? item.address?.fullAddress
|
||||||
components.append(administrativeArea)
|
|
||||||
}
|
|
||||||
|
|
||||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
|
||||||
AddItemSheet(
|
|
||||||
tripId: UUID(),
|
|
||||||
day: 1,
|
|
||||||
existingItem: nil
|
|
||||||
) { _ in }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -715,10 +715,7 @@ enum ItineraryReorderingLogic {
|
|||||||
travelValidRanges: [:], // Custom items don't use travel ranges
|
travelValidRanges: [:], // Custom items don't use travel ranges
|
||||||
constraints: constraints,
|
constraints: constraints,
|
||||||
findTravelItem: { _ in nil },
|
findTravelItem: { _ in nil },
|
||||||
makeTravelItem: { _ in
|
makeTravelItem: { _ in item },
|
||||||
// This won't be called for custom items
|
|
||||||
fatalError("makeTravelItem called for custom item")
|
|
||||||
},
|
|
||||||
findTravelSortOrder: findTravelSortOrder
|
findTravelSortOrder: findTravelSortOrder
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
.sorted { $0.game.dateTime < $1.game.dateTime }
|
.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
nonisolated private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
|
||||||
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
|
||||||
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
|
||||||
return "travel:\(index):\(from)->\(to)"
|
return "travel:\(index):\(from)->\(to)"
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
@Query private var savedTrips: [SavedTrip]
|
@Query private var savedTrips: [SavedTrip]
|
||||||
@State private var showProPaywall = false
|
@State private var showProPaywall = false
|
||||||
|
@State private var persistenceErrorMessage: String?
|
||||||
@State private var selectedDay: ItineraryDay?
|
@State private var selectedDay: ItineraryDay?
|
||||||
@State private var showExportSheet = false
|
@State private var showExportSheet = false
|
||||||
@State private var exportURL: URL?
|
@State private var exportURL: URL?
|
||||||
@@ -119,6 +120,17 @@ struct TripDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
||||||
}
|
}
|
||||||
|
.alert(
|
||||||
|
"Unable to Save Changes",
|
||||||
|
isPresented: Binding(
|
||||||
|
get: { persistenceErrorMessage != nil },
|
||||||
|
set: { if !$0 { persistenceErrorMessage = nil } }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Button("OK", role: .cancel) { persistenceErrorMessage = nil }
|
||||||
|
} message: {
|
||||||
|
Text(persistenceErrorMessage ?? "Please try again.")
|
||||||
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new"))
|
AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new"))
|
||||||
checkIfSaved()
|
checkIfSaved()
|
||||||
@@ -1354,7 +1366,7 @@ struct TripDetailView: View {
|
|||||||
gameCount: trip.totalGames
|
gameCount: trip.totalGames
|
||||||
))
|
))
|
||||||
} catch {
|
} catch {
|
||||||
// Save failed silently
|
persistenceErrorMessage = "Failed to save this trip. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1375,7 +1387,7 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
|
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
|
||||||
} catch {
|
} catch {
|
||||||
// Unsave failed silently
|
persistenceErrorMessage = "Failed to remove this saved trip. Please try again."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1404,7 +1416,13 @@ struct TripDetailView: View {
|
|||||||
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
||||||
predicate: #Predicate { $0.tripId == tripId }
|
predicate: #Predicate { $0.tripId == tripId }
|
||||||
)
|
)
|
||||||
let localItems = (try? modelContext.fetch(descriptor))?.compactMap(\.toItem) ?? []
|
let localModels = (try? modelContext.fetch(descriptor)) ?? []
|
||||||
|
let localItems = localModels.compactMap(\.toItem)
|
||||||
|
let pendingLocalItems = localModels.compactMap { local -> ItineraryItem? in
|
||||||
|
guard local.pendingSync else { return nil }
|
||||||
|
return local.toItem
|
||||||
|
}
|
||||||
|
|
||||||
if !localItems.isEmpty {
|
if !localItems.isEmpty {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache")
|
print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache")
|
||||||
@@ -1420,16 +1438,42 @@ struct TripDetailView: View {
|
|||||||
print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit")
|
print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Merge: use CloudKit as source of truth when available
|
// Merge: cloud as baseline, but always preserve local pending edits.
|
||||||
if !cloudItems.isEmpty || localItems.isEmpty {
|
let cloudById = Dictionary(uniqueKeysWithValues: cloudItems.map { ($0.id, $0) })
|
||||||
itineraryItems = cloudItems
|
let unresolvedPendingItems = pendingLocalItems.filter { localPending in
|
||||||
extractTravelOverrides(from: cloudItems)
|
guard let cloudItem = cloudById[localPending.id] else { return true }
|
||||||
syncLocalCache(with: cloudItems)
|
return cloudItem.modifiedAt < localPending.modifiedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var mergedById = cloudById
|
||||||
|
for localPending in unresolvedPendingItems {
|
||||||
|
mergedById[localPending.id] = localPending
|
||||||
|
}
|
||||||
|
|
||||||
|
let mergedItems = mergedById.values.sorted { lhs, rhs in
|
||||||
|
if lhs.day != rhs.day { return lhs.day < rhs.day }
|
||||||
|
if lhs.sortOrder != rhs.sortOrder { return lhs.sortOrder < rhs.sortOrder }
|
||||||
|
return lhs.modifiedAt < rhs.modifiedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mergedItems.isEmpty || localItems.isEmpty {
|
||||||
|
itineraryItems = mergedItems
|
||||||
|
extractTravelOverrides(from: mergedItems)
|
||||||
|
syncLocalCache(with: mergedItems, pendingSyncItemIDs: Set(unresolvedPendingItems.map(\.id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure unsynced local edits continue retrying in the background.
|
||||||
|
for pendingItem in unresolvedPendingItems {
|
||||||
|
await ItineraryItemService.shared.updateItem(pendingItem)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)")
|
print("⚠️ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)")
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
for pendingItem in pendingLocalItems {
|
||||||
|
await ItineraryItemService.shared.updateItem(pendingItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1475,7 +1519,7 @@ struct TripDetailView: View {
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncLocalCache(with items: [ItineraryItem]) {
|
private func syncLocalCache(with items: [ItineraryItem], pendingSyncItemIDs: Set<UUID> = []) {
|
||||||
let tripId = trip.id
|
let tripId = trip.id
|
||||||
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
let descriptor = FetchDescriptor<LocalItineraryItem>(
|
||||||
predicate: #Predicate { $0.tripId == tripId }
|
predicate: #Predicate { $0.tripId == tripId }
|
||||||
@@ -1483,7 +1527,7 @@ struct TripDetailView: View {
|
|||||||
let existing = (try? modelContext.fetch(descriptor)) ?? []
|
let existing = (try? modelContext.fetch(descriptor)) ?? []
|
||||||
for old in existing { modelContext.delete(old) }
|
for old in existing { modelContext.delete(old) }
|
||||||
for item in items {
|
for item in items {
|
||||||
if let local = LocalItineraryItem.from(item) {
|
if let local = LocalItineraryItem.from(item, pendingSync: pendingSyncItemIDs.contains(item.id)) {
|
||||||
modelContext.insert(local)
|
modelContext.insert(local)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1524,6 +1568,7 @@ struct TripDetailView: View {
|
|||||||
markLocalItemSynced(item.id)
|
markLocalItemSynced(item.id)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
await ItineraryItemService.shared.updateItem(item)
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)")
|
print("⚠️ [ItineraryItems] CloudKit save failed (saved locally): \(error)")
|
||||||
#endif
|
#endif
|
||||||
@@ -1542,6 +1587,8 @@ struct TripDetailView: View {
|
|||||||
existing.kindData = (try? JSONEncoder().encode(item.kind)) ?? existing.kindData
|
existing.kindData = (try? JSONEncoder().encode(item.kind)) ?? existing.kindData
|
||||||
existing.modifiedAt = item.modifiedAt
|
existing.modifiedAt = item.modifiedAt
|
||||||
existing.pendingSync = true
|
existing.pendingSync = true
|
||||||
|
} else if let local = LocalItineraryItem.from(item, pendingSync: true) {
|
||||||
|
modelContext.insert(local)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if let local = LocalItineraryItem.from(item, pendingSync: true) {
|
if let local = LocalItineraryItem.from(item, pendingSync: true) {
|
||||||
@@ -1598,8 +1645,6 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
// Capture and clear drag state immediately (synchronously) before async work
|
// Capture and clear drag state immediately (synchronously) before async work
|
||||||
// This ensures the UI resets even if validation fails
|
// This ensures the UI resets even if validation fails
|
||||||
let capturedTravelId = draggedTravelId
|
|
||||||
let capturedItem = draggedItem
|
|
||||||
draggedTravelId = nil
|
draggedTravelId = nil
|
||||||
draggedItem = nil
|
draggedItem = nil
|
||||||
dropTargetId = nil
|
dropTargetId = nil
|
||||||
|
|||||||
@@ -338,8 +338,7 @@ struct GamePickerStep: View {
|
|||||||
endDate = calendar.startOfDay(for: newEnd)
|
endDate = calendar.startOfDay(for: newEnd)
|
||||||
} else {
|
} else {
|
||||||
// Multiple games: span from first to last with 1-day buffer
|
// Multiple games: span from first to last with 1-day buffer
|
||||||
let firstGameDate = gameDates.first!
|
guard let firstGameDate = gameDates.first, let lastGameDate = gameDates.last else { return }
|
||||||
let lastGameDate = gameDates.last!
|
|
||||||
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
|
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
|
||||||
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
|
||||||
startDate = calendar.startOfDay(for: newStart)
|
startDate = calendar.startOfDay(for: newStart)
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ struct TripWizardView: View {
|
|||||||
defer { viewModel.isPlanning = false }
|
defer { viewModel.isPlanning = false }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
var preferences = buildPreferences()
|
let preferences = buildPreferences()
|
||||||
|
|
||||||
// Build dictionaries from arrays
|
// Build dictionaries from arrays
|
||||||
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
|
||||||
@@ -231,10 +231,15 @@ struct TripWizardView: View {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Standard mode: fetch games for date range
|
// Standard mode: fetch games for date range
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let queryStart = calendar.startOfDay(for: preferences.startDate)
|
||||||
|
let endDay = calendar.startOfDay(for: preferences.endDate)
|
||||||
|
let queryEnd = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: endDay) ?? endDay
|
||||||
|
|
||||||
games = try await AppDataProvider.shared.filterGames(
|
games = try await AppDataProvider.shared.filterGames(
|
||||||
sports: preferences.sports,
|
sports: preferences.sports,
|
||||||
startDate: preferences.startDate,
|
startDate: queryStart,
|
||||||
endDate: preferences.endDate
|
endDate: queryEnd
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -292,6 +297,10 @@ struct TripWizardView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func buildPreferences() -> TripPreferences {
|
private func buildPreferences() -> TripPreferences {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let normalizedStartDate = calendar.startOfDay(for: viewModel.startDate)
|
||||||
|
let normalizedEndDate = calendar.startOfDay(for: viewModel.endDate)
|
||||||
|
|
||||||
// Determine which sports to use based on mode
|
// Determine which sports to use based on mode
|
||||||
let sports: Set<Sport>
|
let sports: Set<Sport>
|
||||||
if viewModel.planningMode == .gameFirst {
|
if viewModel.planningMode == .gameFirst {
|
||||||
@@ -310,8 +319,8 @@ struct TripWizardView: View {
|
|||||||
endLocation: viewModel.endLocation,
|
endLocation: viewModel.endLocation,
|
||||||
sports: sports,
|
sports: sports,
|
||||||
mustSeeGameIds: viewModel.selectedGameIds,
|
mustSeeGameIds: viewModel.selectedGameIds,
|
||||||
startDate: viewModel.startDate,
|
startDate: normalizedStartDate,
|
||||||
endDate: viewModel.endDate,
|
endDate: normalizedEndDate,
|
||||||
mustStopLocations: viewModel.mustStopLocations,
|
mustStopLocations: viewModel.mustStopLocations,
|
||||||
routePreference: viewModel.routePreference,
|
routePreference: viewModel.routePreference,
|
||||||
allowRepeatCities: viewModel.allowRepeatCities,
|
allowRepeatCities: viewModel.allowRepeatCities,
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ enum RouteFilters {
|
|||||||
var cityDays: [String: Set<Date>] = [:]
|
var cityDays: [String: Set<Date>] = [:]
|
||||||
|
|
||||||
for stop in option.stops {
|
for stop in option.stops {
|
||||||
let city = stop.city
|
let city = normalizedCityKey(stop.city)
|
||||||
|
guard !city.isEmpty else { continue }
|
||||||
let day = calendar.startOfDay(for: stop.arrivalDate)
|
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||||
cityDays[city, default: []].insert(day)
|
cityDays[city, default: []].insert(day)
|
||||||
}
|
}
|
||||||
@@ -83,18 +84,34 @@ enum RouteFilters {
|
|||||||
|
|
||||||
for option in options {
|
for option in options {
|
||||||
var cityDays: [String: Set<Date>] = [:]
|
var cityDays: [String: Set<Date>] = [:]
|
||||||
|
var displayNames: [String: String] = [:]
|
||||||
for stop in option.stops {
|
for stop in option.stops {
|
||||||
let day = calendar.startOfDay(for: stop.arrivalDate)
|
let normalized = normalizedCityKey(stop.city)
|
||||||
cityDays[stop.city, default: []].insert(day)
|
guard !normalized.isEmpty else { continue }
|
||||||
|
|
||||||
|
if displayNames[normalized] == nil {
|
||||||
|
displayNames[normalized] = stop.city
|
||||||
}
|
}
|
||||||
for (city, days) in cityDays where days.count > 1 {
|
let day = calendar.startOfDay(for: stop.arrivalDate)
|
||||||
violatingCities.insert(city)
|
cityDays[normalized, default: []].insert(day)
|
||||||
|
}
|
||||||
|
for (normalized, days) in cityDays where days.count > 1 {
|
||||||
|
violatingCities.insert(displayNames[normalized] ?? normalized)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array(violatingCities).sorted()
|
return Array(violatingCities).sorted()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func normalizedCityKey(_ city: String) -> String {
|
||||||
|
let cityPart = city.split(separator: ",", maxSplits: 1).first.map(String.init) ?? city
|
||||||
|
return cityPart
|
||||||
|
.lowercased()
|
||||||
|
.replacingOccurrences(of: ".", with: "")
|
||||||
|
.split(whereSeparator: \.isWhitespace)
|
||||||
|
.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Trip List Filters
|
// MARK: - Trip List Filters
|
||||||
|
|
||||||
/// Filters trips by sport.
|
/// Filters trips by sport.
|
||||||
@@ -133,8 +150,11 @@ enum RouteFilters {
|
|||||||
/// - Uses calendar start-of-day for comparison
|
/// - Uses calendar start-of-day for comparison
|
||||||
static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] {
|
static func filterByDateRange(_ trips: [Trip], start: Date, end: Date) -> [Trip] {
|
||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let rangeStart = calendar.startOfDay(for: start)
|
// Be tolerant of inverted inputs from UI/state restores by normalizing bounds.
|
||||||
let rangeEnd = calendar.startOfDay(for: end)
|
let normalizedStart = min(start, end)
|
||||||
|
let normalizedEnd = max(start, end)
|
||||||
|
let rangeStart = calendar.startOfDay(for: normalizedStart)
|
||||||
|
let rangeEnd = calendar.startOfDay(for: normalizedEnd)
|
||||||
|
|
||||||
return trips.filter { trip in
|
return trips.filter { trip in
|
||||||
let tripStart = calendar.startOfDay(for: trip.startDate)
|
let tripStart = calendar.startOfDay(for: trip.startDate)
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ final class ScenarioAPlanner: ScenarioPlanner {
|
|||||||
travelSegments: itinerary.travelSegments,
|
travelSegments: itinerary.travelSegments,
|
||||||
totalDrivingHours: itinerary.totalDrivingHours,
|
totalDrivingHours: itinerary.totalDrivingHours,
|
||||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||||
geographicRationale: "\(stops.count) games: \(cities)"
|
geographicRationale: "\(routeGames.count) games: \(cities)"
|
||||||
)
|
)
|
||||||
itineraryOptions.append(option)
|
itineraryOptions.append(option)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,16 +298,9 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
to: lastGameDate
|
to: lastGameDate
|
||||||
).day ?? 0
|
).day ?? 0
|
||||||
|
|
||||||
// If selected games span more days than trip duration, can't fit
|
// If selected games span more days than trip duration, no valid window exists.
|
||||||
if gameSpanDays >= duration {
|
if gameSpanDays >= duration {
|
||||||
// Just return one window that exactly covers the games
|
return []
|
||||||
let start = firstGameDate
|
|
||||||
let end = Calendar.current.date(
|
|
||||||
byAdding: .day,
|
|
||||||
value: gameSpanDays + 1,
|
|
||||||
to: start
|
|
||||||
) ?? lastGameDate
|
|
||||||
return [DateInterval(start: start, end: end)]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate sliding windows
|
// Generate sliding windows
|
||||||
|
|||||||
@@ -73,7 +73,17 @@ struct SportsTimeApp: App {
|
|||||||
do {
|
do {
|
||||||
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
return try ModelContainer(for: schema, configurations: [modelConfiguration])
|
||||||
} catch {
|
} catch {
|
||||||
fatalError("Could not create ModelContainer: \(error)")
|
assertionFailure("Could not create persistent ModelContainer: \(error)")
|
||||||
|
do {
|
||||||
|
let fallbackConfiguration = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
isStoredInMemoryOnly: true,
|
||||||
|
cloudKitDatabase: .none
|
||||||
|
)
|
||||||
|
return try ModelContainer(for: schema, configurations: [fallbackConfiguration])
|
||||||
|
} catch {
|
||||||
|
preconditionFailure("Could not create fallback ModelContainer: \(error)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|||||||
@@ -229,6 +229,24 @@ struct PollVoteTests {
|
|||||||
// Index 5 is ignored since it's >= tripCount
|
// Index 5 is ignored since it's >= tripCount
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("calculateScores: ignores negative trip indices")
|
||||||
|
func calculateScores_negativeIndex() {
|
||||||
|
let scores = PollVote.calculateScores(rankings: [0, -1, 1], tripCount: 3)
|
||||||
|
|
||||||
|
#expect(scores[0] == 3)
|
||||||
|
#expect(scores[1] == 1)
|
||||||
|
#expect(scores[2] == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("calculateScores: ignores duplicate trip indices after first occurrence")
|
||||||
|
func calculateScores_duplicateIndices() {
|
||||||
|
let scores = PollVote.calculateScores(rankings: [1, 1, 0], tripCount: 3)
|
||||||
|
|
||||||
|
#expect(scores[1] == 3)
|
||||||
|
#expect(scores[0] == 1)
|
||||||
|
#expect(scores[2] == 0)
|
||||||
|
}
|
||||||
|
|
||||||
@Test("calculateScores: empty rankings returns zeros")
|
@Test("calculateScores: empty rankings returns zeros")
|
||||||
func calculateScores_emptyRankings() {
|
func calculateScores_emptyRankings() {
|
||||||
let scores = PollVote.calculateScores(rankings: [], tripCount: 3)
|
let scores = PollVote.calculateScores(rankings: [], tripCount: 3)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryCustomItemTests: XCTestCase {
|
final class ItineraryCustomItemTests: XCTestCase {
|
||||||
|
|
||||||
private let testTripId = H.testTripId
|
private let testTripId = H.testTripId
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryEdgeCaseTests: XCTestCase {
|
final class ItineraryEdgeCaseTests: XCTestCase {
|
||||||
|
|
||||||
private let testDate = H.testDate
|
private let testDate = H.testDate
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import XCTest
|
|||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
private typealias Logic = ItineraryReorderingLogic
|
private typealias Logic = ItineraryReorderingLogic
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryReorderingLogicTests: XCTestCase {
|
final class ItineraryReorderingLogicTests: XCTestCase {
|
||||||
|
|
||||||
private let testDate = H.testDate
|
private let testDate = H.testDate
|
||||||
@@ -302,13 +303,8 @@ final class ItineraryReorderingLogicTests: XCTestCase {
|
|||||||
|
|
||||||
func test_calculateSortOrder_emptyDay_returns1() {
|
func test_calculateSortOrder_emptyDay_returns1() {
|
||||||
// Day with only header, no items
|
// Day with only header, no items
|
||||||
let items = buildFlatItems([
|
// Simulating drop right after day 1 header (row 0). After inserting
|
||||||
.day(1),
|
// at row 1, day 1 has no other items.
|
||||||
.day(2)
|
|
||||||
])
|
|
||||||
|
|
||||||
// Simulating drop right after day 1 header (row 0)
|
|
||||||
// After inserting at row 1, day 1 has no other items
|
|
||||||
let mockItems = buildFlatItems([
|
let mockItems = buildFlatItems([
|
||||||
.day(1),
|
.day(1),
|
||||||
.custom("New", sortOrder: 999, day: 1), // Placeholder for dropped item
|
.custom("New", sortOrder: 999, day: 1), // Placeholder for dropped item
|
||||||
@@ -810,16 +806,18 @@ final class ItineraryReorderingLogicTests: XCTestCase {
|
|||||||
])
|
])
|
||||||
|
|
||||||
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
|
let customItem = H.makeCustomItem(day: 1, sortOrder: 1.0, title: "A")
|
||||||
let zones = Logic.calculateCustomItemDragZones(item: customItem, flatItems: items)
|
let zones = Logic.calculateCustomItemDragZones(
|
||||||
|
item: customItem,
|
||||||
|
sourceRow: 1,
|
||||||
|
flatItems: items,
|
||||||
|
constraints: nil,
|
||||||
|
findTravelSortOrder: { _ in nil }
|
||||||
|
)
|
||||||
|
|
||||||
// Headers at rows 0, 2, 4 should be invalid
|
// New API excludes source row from both valid and invalid sets.
|
||||||
XCTAssertTrue(zones.invalidRowIndices.contains(0))
|
XCTAssertEqual(zones.invalidRowIndices, Set([0]))
|
||||||
XCTAssertTrue(zones.invalidRowIndices.contains(2))
|
XCTAssertEqual(Set(zones.validDropRows), Set([2, 3, 4, 5]))
|
||||||
XCTAssertTrue(zones.invalidRowIndices.contains(4))
|
XCTAssertFalse(zones.validDropRows.contains(1))
|
||||||
|
|
||||||
// Items at rows 1, 3 should be valid
|
|
||||||
XCTAssertTrue(zones.validDropRows.contains(1))
|
|
||||||
XCTAssertTrue(zones.validDropRows.contains(3))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func test_calculateTravelDragZones_respectsDayRange() {
|
func test_calculateTravelDragZones_respectsDayRange() {
|
||||||
@@ -834,17 +832,21 @@ final class ItineraryReorderingLogicTests: XCTestCase {
|
|||||||
|
|
||||||
let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
|
let segment = H.makeTravelSegment(from: "CityA", to: "CityB")
|
||||||
let travelValidRanges = ["travel:0:citya->cityb": 1...3]
|
let travelValidRanges = ["travel:0:citya->cityb": 1...3]
|
||||||
|
let travelItem = H.makeTravelItem(from: "CityA", to: "CityB", day: 2, sortOrder: 1.0)
|
||||||
|
|
||||||
let zones = Logic.calculateTravelDragZones(
|
let zones = Logic.calculateTravelDragZones(
|
||||||
segment: segment,
|
segment: segment,
|
||||||
|
sourceRow: 3,
|
||||||
flatItems: items,
|
flatItems: items,
|
||||||
travelValidRanges: travelValidRanges,
|
travelValidRanges: travelValidRanges,
|
||||||
constraints: nil,
|
constraints: nil,
|
||||||
findTravelItem: { _ in nil }
|
findTravelItem: { _ in travelItem },
|
||||||
|
makeTravelItem: { _ in travelItem },
|
||||||
|
findTravelSortOrder: { _ in travelItem.sortOrder }
|
||||||
)
|
)
|
||||||
|
|
||||||
// All days 1-3 should be valid (6 rows total)
|
XCTAssertEqual(Set(zones.validDropRows), Set([1, 2, 4, 5, 6]))
|
||||||
XCTAssertEqual(zones.validDropRows.count, 6)
|
XCTAssertEqual(zones.invalidRowIndices, Set([0]))
|
||||||
XCTAssertTrue(zones.invalidRowIndices.isEmpty)
|
XCTAssertFalse(zones.validDropRows.contains(3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryReorderingTests: XCTestCase {
|
final class ItineraryReorderingTests: XCTestCase {
|
||||||
|
|
||||||
private let testDate = H.testDate
|
private let testDate = H.testDate
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryRowFlatteningTests: XCTestCase {
|
final class ItineraryRowFlatteningTests: XCTestCase {
|
||||||
|
|
||||||
private let testDate = H.testDate
|
private let testDate = H.testDate
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItinerarySortOrderTests: XCTestCase {
|
final class ItinerarySortOrderTests: XCTestCase {
|
||||||
|
|
||||||
private let testDate = H.testDate
|
private let testDate = H.testDate
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import XCTest
|
|||||||
|
|
||||||
private typealias H = ItineraryTestHelpers
|
private typealias H = ItineraryTestHelpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class ItineraryTravelConstraintTests: XCTestCase {
|
final class ItineraryTravelConstraintTests: XCTestCase {
|
||||||
|
|
||||||
private let testTripId = H.testTripId
|
private let testTripId = H.testTripId
|
||||||
@@ -176,10 +177,6 @@ final class ItineraryTravelConstraintTests: XCTestCase {
|
|||||||
)
|
)
|
||||||
|
|
||||||
let controller = ItineraryTableViewController(style: .plain)
|
let controller = ItineraryTableViewController(style: .plain)
|
||||||
var capturedSortOrder: Double = 0
|
|
||||||
controller.onTravelMoved = { _, _, sortOrder in
|
|
||||||
capturedSortOrder = sortOrder
|
|
||||||
}
|
|
||||||
controller.reloadData(
|
controller.reloadData(
|
||||||
days: [dayData],
|
days: [dayData],
|
||||||
travelValidRanges: ["travel:0:chicago->detroit": 1...1],
|
travelValidRanges: ["travel:0:chicago->detroit": 1...1],
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import Testing
|
|||||||
@testable import SportsTime
|
@testable import SportsTime
|
||||||
|
|
||||||
@Suite("RegionMapSelector — regionForCoordinate")
|
@Suite("RegionMapSelector — regionForCoordinate")
|
||||||
|
@MainActor
|
||||||
struct RegionMapSelectorTests {
|
struct RegionMapSelectorTests {
|
||||||
|
|
||||||
// MARK: - West Region (longitude < -102)
|
// MARK: - West Region (longitude < -102)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
@testable import SportsTime
|
@testable import SportsTime
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class TravelPlacementTests: XCTestCase {
|
final class TravelPlacementTests: XCTestCase {
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
@@ -417,14 +418,14 @@ final class TravelPlacementTests: XCTestCase {
|
|||||||
// Follow Team pattern: Houston→Cincinnati→Houston→Cincinnati
|
// Follow Team pattern: Houston→Cincinnati→Houston→Cincinnati
|
||||||
// Two segments share the same city pair (Houston→Cincinnati)
|
// Two segments share the same city pair (Houston→Cincinnati)
|
||||||
// Each must get a unique travel anchor ID so overrides don't collide.
|
// Each must get a unique travel anchor ID so overrides don't collide.
|
||||||
let stops = [
|
_ = [
|
||||||
makeStop(city: "Houston", arrival: may(1), departure: may(2)), // Stop 0
|
makeStop(city: "Houston", arrival: may(1), departure: may(2)), // Stop 0
|
||||||
makeStop(city: "Cincinnati", arrival: may(4), departure: may(5)), // Stop 1
|
makeStop(city: "Cincinnati", arrival: may(4), departure: may(5)), // Stop 1
|
||||||
makeStop(city: "Houston", arrival: may(7), departure: may(8)), // Stop 2
|
makeStop(city: "Houston", arrival: may(7), departure: may(8)), // Stop 2
|
||||||
makeStop(city: "Cincinnati", arrival: may(10), departure: may(11)) // Stop 3
|
makeStop(city: "Cincinnati", arrival: may(10), departure: may(11)) // Stop 3
|
||||||
]
|
]
|
||||||
|
|
||||||
let segments = [
|
_ = [
|
||||||
makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] → stops[1]
|
makeSegment(from: "Houston", to: "Cincinnati"), // Seg 0: stops[0] → stops[1]
|
||||||
makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] → stops[2]
|
makeSegment(from: "Cincinnati", to: "Houston"), // Seg 1: stops[1] → stops[2]
|
||||||
makeSegment(from: "Houston", to: "Cincinnati") // Seg 2: stops[2] → stops[3]
|
makeSegment(from: "Houston", to: "Cincinnati") // Seg 2: stops[2] → stops[3]
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ struct GameDAGRouterTests {
|
|||||||
|
|
||||||
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
|
let (game1, stadium1) = makeGameAndStadium(city: "New York", date: dates[0], coord: nycCoord)
|
||||||
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
|
let (game2, stadium2) = makeGameAndStadium(city: "Boston", date: dates[1], coord: bostonCoord)
|
||||||
let (game3, stadium3) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city
|
let (game3, _) = makeGameAndStadium(city: "New York", date: dates[2], coord: nycCoord) // Same city
|
||||||
|
|
||||||
// Use same stadium ID for same city
|
// Use same stadium ID for same city
|
||||||
let nyStadium = stadium1
|
let nyStadium = stadium1
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ struct Bug2_InfiniteLoopTests {
|
|||||||
|
|
||||||
@Test("calculateRestDays does not hang for normal multi-day stop")
|
@Test("calculateRestDays does not hang for normal multi-day stop")
|
||||||
func calculateRestDays_normalMultiDay_terminates() {
|
func calculateRestDays_normalMultiDay_terminates() {
|
||||||
let calendar = TestClock.calendar
|
|
||||||
let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12)
|
let arrival = TestFixtures.date(year: 2026, month: 6, day: 10, hour: 12)
|
||||||
let departure = TestFixtures.date(year: 2026, month: 6, day: 14, hour: 12)
|
let departure = TestFixtures.date(year: 2026, month: 6, day: 14, hour: 12)
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,17 @@ struct RouteFiltersTests {
|
|||||||
#expect(result.count == 1)
|
#expect(result.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test("filterByDateRange: inverted range bounds are normalized")
|
||||||
|
func filterByDateRange_invertedBounds_normalized() {
|
||||||
|
let dayAfterTomorrow = calendar.date(byAdding: .day, value: 2, to: today)!
|
||||||
|
let trip = makeTrip(startDate: tomorrow, endDate: dayAfterTomorrow)
|
||||||
|
|
||||||
|
// Callers can accidentally pass (end, start); filter should still behave as expected.
|
||||||
|
let result = RouteFilters.filterByDateRange([trip], start: nextWeek, end: today)
|
||||||
|
|
||||||
|
#expect(result.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Specification Tests: filterByStatus
|
// MARK: - Specification Tests: filterByStatus
|
||||||
|
|
||||||
@Test("filterByStatus: returns matching status only")
|
@Test("filterByStatus: returns matching status only")
|
||||||
@@ -369,15 +380,24 @@ struct RouteFiltersTests {
|
|||||||
|
|
||||||
// MARK: - Edge Case Tests
|
// MARK: - Edge Case Tests
|
||||||
|
|
||||||
@Test("Edge: city names are case-sensitive")
|
@Test("Edge: city names are normalized case-insensitively")
|
||||||
func edge_cityNamesCaseSensitive() {
|
func edge_cityNamesCaseInsensitive() {
|
||||||
let option = makeOption(stops: [
|
let option = makeOption(stops: [
|
||||||
makeStop(city: "New York", date: today),
|
makeStop(city: "New York", date: today),
|
||||||
makeStop(city: "new york", date: tomorrow) // Different case
|
makeStop(city: "new york", date: tomorrow) // Different case
|
||||||
])
|
])
|
||||||
|
|
||||||
// Currently case-sensitive, so these are different cities
|
#expect(RouteFilters.hasRepeatCityViolation(option))
|
||||||
#expect(!RouteFilters.hasRepeatCityViolation(option))
|
}
|
||||||
|
|
||||||
|
@Test("Edge: city names are normalized for punctuation and spacing")
|
||||||
|
func edge_cityNamesNormalizedForPunctuationAndSpacing() {
|
||||||
|
let option = makeOption(stops: [
|
||||||
|
makeStop(city: "St. Louis", date: today),
|
||||||
|
makeStop(city: "st louis", date: tomorrow)
|
||||||
|
])
|
||||||
|
|
||||||
|
#expect(RouteFilters.hasRepeatCityViolation(option))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test("Edge: very long trip with many stops")
|
@Test("Edge: very long trip with many stops")
|
||||||
|
|||||||
@@ -48,6 +48,14 @@ struct PollErrorTests {
|
|||||||
#expect(error.errorDescription!.lowercased().contains("owner"))
|
#expect(error.errorDescription!.lowercased().contains("owner"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// - Expected Behavior: notVoteOwner explains vote ownership requirement
|
||||||
|
@Test("errorDescription: notVoteOwner mentions owner")
|
||||||
|
func errorDescription_notVoteOwner() {
|
||||||
|
let error = PollError.notVoteOwner
|
||||||
|
#expect(error.errorDescription != nil)
|
||||||
|
#expect(error.errorDescription!.lowercased().contains("owner"))
|
||||||
|
}
|
||||||
|
|
||||||
/// - Expected Behavior: networkUnavailable explains connection issue
|
/// - Expected Behavior: networkUnavailable explains connection issue
|
||||||
@Test("errorDescription: networkUnavailable mentions connection")
|
@Test("errorDescription: networkUnavailable mentions connection")
|
||||||
func errorDescription_networkUnavailable() {
|
func errorDescription_networkUnavailable() {
|
||||||
@@ -83,6 +91,7 @@ struct PollErrorTests {
|
|||||||
.pollNotFound,
|
.pollNotFound,
|
||||||
.alreadyVoted,
|
.alreadyVoted,
|
||||||
.notPollOwner,
|
.notPollOwner,
|
||||||
|
.notVoteOwner,
|
||||||
.networkUnavailable,
|
.networkUnavailable,
|
||||||
.encodingError,
|
.encodingError,
|
||||||
.unknown(NSError(domain: "", code: 0))
|
.unknown(NSError(domain: "", code: 0))
|
||||||
@@ -102,6 +111,7 @@ struct PollErrorTests {
|
|||||||
.pollNotFound,
|
.pollNotFound,
|
||||||
.alreadyVoted,
|
.alreadyVoted,
|
||||||
.notPollOwner,
|
.notPollOwner,
|
||||||
|
.notVoteOwner,
|
||||||
.networkUnavailable,
|
.networkUnavailable,
|
||||||
.encodingError
|
.encodingError
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ class BaseUITestCase: XCTestCase {
|
|||||||
|
|
||||||
// MARK: - Wait Helpers
|
// MARK: - Wait Helpers
|
||||||
|
|
||||||
|
@MainActor
|
||||||
extension XCUIElement {
|
extension XCUIElement {
|
||||||
|
|
||||||
/// Waits until the element exists, failing with a descriptive message.
|
/// Waits until the element exists, failing with a descriptive message.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import XCTest
|
|||||||
|
|
||||||
// MARK: - Home Screen
|
// MARK: - Home Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct HomeScreen {
|
struct HomeScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ struct HomeScreen {
|
|||||||
|
|
||||||
// MARK: - Trip Wizard Screen
|
// MARK: - Trip Wizard Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct TripWizardScreen {
|
struct TripWizardScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -343,6 +345,7 @@ struct TripWizardScreen {
|
|||||||
|
|
||||||
// MARK: - Trip Options Screen
|
// MARK: - Trip Options Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct TripOptionsScreen {
|
struct TripOptionsScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -397,6 +400,7 @@ struct TripOptionsScreen {
|
|||||||
|
|
||||||
// MARK: - Trip Detail Screen
|
// MARK: - Trip Detail Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct TripDetailScreen {
|
struct TripDetailScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -508,6 +512,7 @@ struct TripDetailScreen {
|
|||||||
|
|
||||||
// MARK: - My Trips Screen
|
// MARK: - My Trips Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct MyTripsScreen {
|
struct MyTripsScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -572,6 +577,7 @@ struct MyTripsScreen {
|
|||||||
|
|
||||||
// MARK: - Schedule Screen
|
// MARK: - Schedule Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct ScheduleScreen {
|
struct ScheduleScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -612,6 +618,7 @@ struct ScheduleScreen {
|
|||||||
|
|
||||||
// MARK: - Settings Screen
|
// MARK: - Settings Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct SettingsScreen {
|
struct SettingsScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -702,6 +709,7 @@ struct SettingsScreen {
|
|||||||
|
|
||||||
// MARK: - Progress Screen
|
// MARK: - Progress Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct ProgressScreen {
|
struct ProgressScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -783,6 +791,7 @@ struct ProgressScreen {
|
|||||||
|
|
||||||
// MARK: - Stadium Visit Sheet Screen
|
// MARK: - Stadium Visit Sheet Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct StadiumVisitSheetScreen {
|
struct StadiumVisitSheetScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -842,6 +851,7 @@ struct StadiumVisitSheetScreen {
|
|||||||
|
|
||||||
// MARK: - Quick Add Item Sheet Screen
|
// MARK: - Quick Add Item Sheet Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct QuickAddItemSheetScreen {
|
struct QuickAddItemSheetScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -904,6 +914,7 @@ struct QuickAddItemSheetScreen {
|
|||||||
|
|
||||||
// MARK: - Games History Screen
|
// MARK: - Games History Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct GamesHistoryScreen {
|
struct GamesHistoryScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -931,6 +942,7 @@ struct GamesHistoryScreen {
|
|||||||
|
|
||||||
// MARK: - Polls Screen
|
// MARK: - Polls Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct PollsScreen {
|
struct PollsScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -980,6 +992,7 @@ struct PollsScreen {
|
|||||||
|
|
||||||
// MARK: - Paywall Screen
|
// MARK: - Paywall Screen
|
||||||
|
|
||||||
|
@MainActor
|
||||||
struct PaywallScreen {
|
struct PaywallScreen {
|
||||||
let app: XCUIApplication
|
let app: XCUIApplication
|
||||||
|
|
||||||
@@ -1031,6 +1044,7 @@ struct PaywallScreen {
|
|||||||
|
|
||||||
/// Reusable multi-step flows that multiple tests share.
|
/// Reusable multi-step flows that multiple tests share.
|
||||||
/// Avoids duplicating the full wizard sequence across test files.
|
/// Avoids duplicating the full wizard sequence across test files.
|
||||||
|
@MainActor
|
||||||
enum TestFlows {
|
enum TestFlows {
|
||||||
|
|
||||||
/// Opens the wizard, plans a date-range trip (June 11-16 2026, MLB, Central), and
|
/// Opens the wizard, plans a date-range trip (June 11-16 2026, MLB, Central), and
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
|
@MainActor
|
||||||
final class SportsTimeUITests: XCTestCase {
|
final class SportsTimeUITests: XCTestCase {
|
||||||
|
|
||||||
override func setUpWithError() throws {
|
override func setUpWithError() throws {
|
||||||
|
|||||||
Reference in New Issue
Block a user