From 94c5471d2afe5fa4558d43f807d3dc7740ed391f Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Mon, 24 Aug 2026 04:38:00 +1000 Subject: [PATCH 1/4] Improve map performance and parking discovery UX --- ParkAlong.xcodeproj/project.pbxproj | 16 + ParkAlong/App/AppEnvironment.swift | 52 +- ParkAlong/Data/BundleDataLoader.swift | 8 +- ParkAlong/Data/ParkingAPIClient.swift | 9 +- ParkAlong/Data/StaticParkingRepository.swift | 193 +++++- .../Data/StaticParkingSpatialIndex.swift | 85 +++ ParkAlong/Features/Map/AvailabilityPin.swift | 40 +- ParkAlong/Features/Map/MapControls.swift | 116 ++-- ParkAlong/Features/Map/ParkingMapView.swift | 105 +-- .../Features/Map/ParkingMapViewModel.swift | 231 ++++++- .../Features/Map/ParkingMarkerSelector.swift | 100 +++ ParkAlong/Services/LocationService.swift | 143 +++- ParkAlongTests/ParkingMapViewModelTests.swift | 609 ++++++++++++++++++ .../ParkingMarkerSelectorTests.swift | 116 ++++ .../RemoteParkingRepositoryTests.swift | 36 +- .../StaticParkingRepositoryTests.swift | 54 ++ .../StaticParkingSpatialIndexTests.swift | 92 +++ ParkAlongUITests/ParkAlongUITests.swift | 85 ++- .../performance/2026-08-24-map-performance.md | 34 + .../2026-08-24-map-performance-location-ux.md | 145 +++++ 20 files changed, 2049 insertions(+), 220 deletions(-) create mode 100644 ParkAlong/Data/StaticParkingSpatialIndex.swift create mode 100644 ParkAlong/Features/Map/ParkingMarkerSelector.swift create mode 100644 ParkAlongTests/ParkingMarkerSelectorTests.swift create mode 100644 ParkAlongTests/StaticParkingSpatialIndexTests.swift create mode 100644 docs/performance/2026-08-24-map-performance.md create mode 100644 docs/superpowers/plans/2026-08-24-map-performance-location-ux.md diff --git a/ParkAlong.xcodeproj/project.pbxproj b/ParkAlong.xcodeproj/project.pbxproj index b49c3fd..309d975 100644 --- a/ParkAlong.xcodeproj/project.pbxproj +++ b/ParkAlong.xcodeproj/project.pbxproj @@ -11,6 +11,7 @@ 11B09167F99F4ADCA9F2B8B3 /* ParkingMapViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3CC03EE5AF64E9F03489E57 /* ParkingMapViewModel.swift */; }; 1EF0E538B24BB0866B43EC33 /* ParkingOptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44451E8A231A417084799826 /* ParkingOptionTests.swift */; }; 24C7DB94BAB87535780FFF18 /* ParkingRuleResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0F1FBD74529A7AC8B799805 /* ParkingRuleResolverTests.swift */; }; + 2A06F16AF0ECA1DF4422AD5A /* ParkingMarkerSelectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75F160AACE3259123DB422B2 /* ParkingMarkerSelectorTests.swift */; }; 35CB24FAE3BF5411927E7D7D /* victoria_static_manifest.json in Resources */ = {isa = PBXBuildFile; fileRef = 787E6973F51F16CA9F442FAA /* victoria_static_manifest.json */; }; 3D670DC418F7B59DA15BB54C /* ParkingMapViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3B6C431C3B32FBA27C8D3B8 /* ParkingMapViewModelTests.swift */; }; 3FC2A0E0788A2925C735DDFF /* PredictionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAAE045CF41482B2703EF3D4 /* PredictionEngine.swift */; }; @@ -34,6 +35,7 @@ 89B2020A7EE8E17A79AB89D4 /* AvailabilityEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15F45422F42AD40407B83008 /* AvailabilityEngine.swift */; }; 8A09B17F5D5E6E949CBBA31C /* ParkingAPIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F1A693FCE60AD13B71A085A /* ParkingAPIClient.swift */; }; 931270936035531BF4503C57 /* DestinationSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAEFCC4099D4E7442BDD7330 /* DestinationSearchView.swift */; }; + 933B04A1230D74EC011C00E1 /* ParkingMarkerSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48A1F9B281CAB7262BC46821 /* ParkingMarkerSelector.swift */; }; 958BAD5C8A67BA4E96166339 /* AdaptiveChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8C1152F6E69EBA200086512 /* AdaptiveChrome.swift */; }; 9707BF4169722A2D66A1FC73 /* victoria_static_parking.json in Resources */ = {isa = PBXBuildFile; fileRef = F9DCE4DDFD1BC73F153C592D /* victoria_static_parking.json */; }; 9733AB89A15DE9C297366863 /* DestinationSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22B784BB00A338E4068B9DB /* DestinationSearchService.swift */; }; @@ -46,6 +48,8 @@ B0A073681E86EF11EDAA2E4D /* ParkingOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B4CD237284CEE57B25D537B /* ParkingOption.swift */; }; B132F3D38D21B719CF25B387 /* RankingEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314BC67F93762607C5A7BFDE /* RankingEngine.swift */; }; BBEB5AE4CA3213E0A17D13C5 /* RemoteParkingRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E38BEE15124C90557D7DCA3 /* RemoteParkingRepository.swift */; }; + CBE11642628CECC01D843412 /* StaticParkingSpatialIndexTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297FD2D2EE86B081CDFAF634 /* StaticParkingSpatialIndexTests.swift */; }; + D8B07902E9FCAC0372A71A9D /* StaticParkingSpatialIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99ADCE048BB2757EE03F52E6 /* StaticParkingSpatialIndex.swift */; }; DC3C3F8D35AF238910771637 /* restrictions.json in Resources */ = {isa = PBXBuildFile; fileRef = 301A559806C71BAC78A917B7 /* restrictions.json */; }; DF1E9E28BDD4DCC8BE88EAF2 /* StaticParkingRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47BF6FD4E4F9D00FAE448D71 /* StaticParkingRepositoryTests.swift */; }; E68C35B8778EE002B99ABB51 /* AvailabilityPin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B70525C712110B663350AA1 /* AvailabilityPin.swift */; }; @@ -80,6 +84,7 @@ 1B4CD237284CEE57B25D537B /* ParkingOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingOption.swift; sourceTree = ""; }; 22B7D0E92A86AAFE17AD247D /* ParkAlongUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkAlongUITests.swift; sourceTree = ""; }; 294A199F4552998C76C67604 /* AvailabilityEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailabilityEngineTests.swift; sourceTree = ""; }; + 297FD2D2EE86B081CDFAF634 /* StaticParkingSpatialIndexTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticParkingSpatialIndexTests.swift; sourceTree = ""; }; 301A559806C71BAC78A917B7 /* restrictions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = restrictions.json; sourceTree = ""; }; 3069812EAA382A66BCDCA377 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; 314BC67F93762607C5A7BFDE /* RankingEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RankingEngine.swift; sourceTree = ""; }; @@ -90,6 +95,7 @@ 4394FAA3B127B8A8ADB01D25 /* RestrictionEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestrictionEngineTests.swift; sourceTree = ""; }; 44451E8A231A417084799826 /* ParkingOptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingOptionTests.swift; sourceTree = ""; }; 47BF6FD4E4F9D00FAE448D71 /* StaticParkingRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticParkingRepositoryTests.swift; sourceTree = ""; }; + 48A1F9B281CAB7262BC46821 /* ParkingMarkerSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingMarkerSelector.swift; sourceTree = ""; }; 50E588B1BBF03C8B8623C3DE /* historical_availability.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = historical_availability.json; sourceTree = ""; }; 6009E4D94206EB0F128454A6 /* ParkingMapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingMapView.swift; sourceTree = ""; }; 6325E13364B1EF1B96468BF3 /* BundleDataLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleDataLoader.swift; sourceTree = ""; }; @@ -98,11 +104,13 @@ 6CDA7A548B875B3D3B9C254B /* ParkAlong.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = ParkAlong.app; sourceTree = BUILT_PRODUCTS_DIR; }; 6DEB158348AD5645DD9EF0B0 /* RestrictionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestrictionEngine.swift; sourceTree = ""; }; 72226E6CA6CC2FE10D595BEA /* ParkingRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingRepositoryTests.swift; sourceTree = ""; }; + 75F160AACE3259123DB422B2 /* ParkingMarkerSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingMarkerSelectorTests.swift; sourceTree = ""; }; 787E6973F51F16CA9F442FAA /* victoria_static_manifest.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = victoria_static_manifest.json; sourceTree = ""; }; 7B6489A07DCDE835BF51A5BB /* PredictionRankingCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PredictionRankingCacheTests.swift; sourceTree = ""; }; 7B70525C712110B663350AA1 /* AvailabilityPin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AvailabilityPin.swift; sourceTree = ""; }; 7E38BEE15124C90557D7DCA3 /* RemoteParkingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteParkingRepository.swift; sourceTree = ""; }; 8CFB2D1566E81623B5E1BFC5 /* ParkAlongUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = ParkAlongUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 99ADCE048BB2757EE03F52E6 /* StaticParkingSpatialIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticParkingSpatialIndex.swift; sourceTree = ""; }; AE9B12F2049BD91A9ECB5DAE /* ParkingRuleResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingRuleResolver.swift; sourceTree = ""; }; B0F1FBD74529A7AC8B799805 /* ParkingRuleResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingRuleResolverTests.swift; sourceTree = ""; }; B1DE8CF816FEF78018F98A3D /* ParkingRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParkingRepository.swift; sourceTree = ""; }; @@ -191,6 +199,7 @@ 38CEB50FF79DDDFCD0A6A0D8 /* DestinationSearchServiceTests.swift */, 16A45176AD665B3782116DA9 /* ParkingAPIClientTests.swift */, C3B6C431C3B32FBA27C8D3B8 /* ParkingMapViewModelTests.swift */, + 75F160AACE3259123DB422B2 /* ParkingMarkerSelectorTests.swift */, 44451E8A231A417084799826 /* ParkingOptionTests.swift */, 72226E6CA6CC2FE10D595BEA /* ParkingRepositoryTests.swift */, B0F1FBD74529A7AC8B799805 /* ParkingRuleResolverTests.swift */, @@ -198,6 +207,7 @@ B62F3E21CE52BD95E0694744 /* RemoteParkingRepositoryTests.swift */, 4394FAA3B127B8A8ADB01D25 /* RestrictionEngineTests.swift */, 47BF6FD4E4F9D00FAE448D71 /* StaticParkingRepositoryTests.swift */, + 297FD2D2EE86B081CDFAF634 /* StaticParkingSpatialIndexTests.swift */, ); path = ParkAlongTests; sourceTree = ""; @@ -209,6 +219,7 @@ F72CEB631D4233646A08BE3B /* MapControls.swift */, 6009E4D94206EB0F128454A6 /* ParkingMapView.swift */, C3CC03EE5AF64E9F03489E57 /* ParkingMapViewModel.swift */, + 48A1F9B281CAB7262BC46821 /* ParkingMarkerSelector.swift */, ); path = Map; sourceTree = ""; @@ -244,6 +255,7 @@ B1DE8CF816FEF78018F98A3D /* ParkingRepository.swift */, 7E38BEE15124C90557D7DCA3 /* RemoteParkingRepository.swift */, BA5411D75F09370209CAF6AD /* StaticParkingRepository.swift */, + 99ADCE048BB2757EE03F52E6 /* StaticParkingSpatialIndex.swift */, ); path = Data; sourceTree = ""; @@ -426,6 +438,7 @@ 84346CCB3AC74DDB58885B25 /* DestinationSearchServiceTests.swift in Sources */, 97F154CDF26681B345925314 /* ParkingAPIClientTests.swift in Sources */, 3D670DC418F7B59DA15BB54C /* ParkingMapViewModelTests.swift in Sources */, + 2A06F16AF0ECA1DF4422AD5A /* ParkingMarkerSelectorTests.swift in Sources */, 1EF0E538B24BB0866B43EC33 /* ParkingOptionTests.swift in Sources */, 5BF78C35C92ACFD8C3E020D5 /* ParkingRepositoryTests.swift in Sources */, 24C7DB94BAB87535780FFF18 /* ParkingRuleResolverTests.swift in Sources */, @@ -433,6 +446,7 @@ F209205630C58EA50D41A6EE /* RemoteParkingRepositoryTests.swift in Sources */, 9BCC369289366A376FBC85F0 /* RestrictionEngineTests.swift in Sources */, DF1E9E28BDD4DCC8BE88EAF2 /* StaticParkingRepositoryTests.swift in Sources */, + CBE11642628CECC01D843412 /* StaticParkingSpatialIndexTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -455,6 +469,7 @@ 8A09B17F5D5E6E949CBBA31C /* ParkingAPIClient.swift in Sources */, 7F357FFECF281222991DEF44 /* ParkingMapView.swift in Sources */, 11B09167F99F4ADCA9F2B8B3 /* ParkingMapViewModel.swift in Sources */, + 933B04A1230D74EC011C00E1 /* ParkingMarkerSelector.swift in Sources */, F4940DCE1EB4F6925B9348BD /* ParkingModels.swift in Sources */, B0A073681E86EF11EDAA2E4D /* ParkingOption.swift in Sources */, 7830976AE7942BC2CD1B5B12 /* ParkingRepository.swift in Sources */, @@ -465,6 +480,7 @@ BBEB5AE4CA3213E0A17D13C5 /* RemoteParkingRepository.swift in Sources */, 45BE0C2A83C318A8FE91F629 /* RestrictionEngine.swift in Sources */, E75657F650BDBF60D6AC8C05 /* StaticParkingRepository.swift in Sources */, + D8B07902E9FCAC0372A71A9D /* StaticParkingSpatialIndex.swift in Sources */, 5545685B14AAEAD2FB3FB934 /* ZoneDetailView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ParkAlong/App/AppEnvironment.swift b/ParkAlong/App/AppEnvironment.swift index b98453d..540d77a 100644 --- a/ParkAlong/App/AppEnvironment.swift +++ b/ParkAlong/App/AppEnvironment.swift @@ -4,7 +4,19 @@ import Foundation enum AppEnvironment { static func makeViewModel(arguments: [String] = ProcessInfo.processInfo.arguments) -> ParkingMapViewModel { let isUITesting = arguments.contains("-ui-testing") - let location = FixtureLocationService(denied: arguments.contains("-location-denied")) + let locationResult: LocationRequestResult + if arguments.contains("-location-denied") { + locationResult = .denied + } else if arguments.contains("-location-restricted") { + locationResult = .restricted + } else if arguments.contains("-location-timeout") { + locationResult = .timedOut + } else if arguments.contains("-location-unavailable") { + locationResult = .unavailable + } else { + locationResult = .success(.init(latitude: -37.8582, longitude: 145.0582)) + } + let location = FixtureLocationService(result: locationResult) if isUITesting { let totalError = arguments.contains("-fixture-error") let repositoryError = totalError || arguments.contains("-fixture-live-error") @@ -17,7 +29,10 @@ enum AppEnvironment { destinationSearch: FixtureDestinationSearchService(), navigator: AppleMapsNavigator(intercept: arguments.contains("-intercept-navigation")), offStreetService: FixtureOffStreetParkingService(includeResult: !totalError), - staticParkingService: FixtureStaticParkingService(includeResult: !totalError) + staticParkingService: FixtureStaticParkingService( + includeResult: !totalError, + resultCount: arguments.contains("-fixture-dense") ? 160 : 1 + ) ) } @@ -56,6 +71,7 @@ enum AppEnvironment { struct FixtureStaticParkingService: StaticParkingProviding { var includeResult = true + var resultCount = 1 func options(in viewport: ParkingViewport, plan: ParkingPlan) async -> [ParkingOption] { guard includeResult else { return [] } @@ -67,18 +83,26 @@ struct FixtureStaticParkingService: StaticParkingProviding { licenseName: "Official council information", licenseURL: nil, datasetUpdatedAt: date.addingTimeInterval(-86_400), checkedAt: date ) - let location = StaticParkingLocation( - id: "fixture-ballarat", name: "Sturt Street parking", municipality: "Ballarat", - coordinate: .init(latitude: destination.latitude + 0.0012, longitude: destination.longitude - 0.0018), - kind: .onStreet, archetype: .cbdRetail, capacity: 36, accessibleSpaces: nil, - schedules: [.init(days: Array(1...7), startMinutes: 0, endMinutes: 24 * 60, maxStayMinutes: nil, - restrictionText: "No signed maximum", appliesOnPublicHolidays: true, outsideWindowMeansUnrestricted: false)], - tariffs: [.init(effectiveFrom: date.addingTimeInterval(-86_400), effectiveTo: nil, days: Array(1...7), - startMinutes: 0, endMinutes: 24 * 60, hourlyCents: 360, freeMinutes: 60, - dailyCapCents: nil, tiers: [])], - source: source, classification: .staticOnly, predictionEvidence: nil - ) - return await StaticParkingRepository(locations: [location]).options(in: viewport, plan: plan) + let locations = (0..(_ type: T.Type, named name: String, bundle: Bundle = .main) throws -> T { guard let url = bundle.url(forResource: name, withExtension: "json") else { throw BundleDataError.missing(name) } return try decoder().decode(type, from: Data(contentsOf: url, options: .mappedIfSafe)) @@ -29,11 +31,7 @@ enum BundleDataLoader { decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let raw = try container.decode(String.self) - let withFractional = ISO8601DateFormatter() - withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let standard = ISO8601DateFormatter() - standard.formatOptions = [.withInternetDateTime] - if let date = withFractional.date(from: raw) ?? standard.date(from: raw) { return date } + if let date = try? iso8601.parse(raw) { return date } throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid ISO-8601 date: \(raw)") } return decoder diff --git a/ParkAlong/Data/ParkingAPIClient.swift b/ParkAlong/Data/ParkingAPIClient.swift index 873e0e2..a1f79c1 100644 --- a/ParkAlong/Data/ParkingAPIClient.swift +++ b/ParkAlong/Data/ParkingAPIClient.swift @@ -46,6 +46,7 @@ struct ParkingAPIClient: ParkingAPIProviding, Sendable { private let transport: any HTTPTransport private let pageSize: Int private let baseURL = URL(string: "https://data.melbourne.vic.gov.au/api/explore/v2.1/catalog/datasets/on-street-parking-bay-sensors/records")! + fileprivate static let apiDateStyle = Date.ISO8601FormatStyle() init(transport: any HTTPTransport = URLSessionTransport(), pageSize: Int = 100) { self.transport = transport @@ -127,9 +128,7 @@ struct ParkingAPIClient: ParkingAPIProviding, Sendable { } private static func apiDate(_ date: Date) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter.string(from: date) + date.formatted(apiDateStyle) } } @@ -159,7 +158,7 @@ private struct SensorDTO: Decodable { kerbsideID: kerbsideID, zoneNumber: zoneNumber, status: SensorStatus(apiValue: statusDescription), - timestamp: ISO8601DateFormatter().date(from: statusTimestamp) ?? .distantPast, + timestamp: (try? Date(statusTimestamp, strategy: ParkingAPIClient.apiDateStyle)) ?? .distantPast, coordinate: .init(latitude: location.lat, longitude: location.lon) ) } @@ -185,7 +184,7 @@ private struct AggregateDTO: Decodable { zoneNumber: zoneNumber, status: SensorStatus(apiValue: statusDescription), bayCount: bayCount, - newestTimestamp: ISO8601DateFormatter().date(from: newestTimestamp) ?? .distantPast + newestTimestamp: (try? Date(newestTimestamp, strategy: ParkingAPIClient.apiDateStyle)) ?? .distantPast ) } } diff --git a/ParkAlong/Data/StaticParkingRepository.swift b/ParkAlong/Data/StaticParkingRepository.swift index cd8611e..0e16cdb 100644 --- a/ParkAlong/Data/StaticParkingRepository.swift +++ b/ParkAlong/Data/StaticParkingRepository.swift @@ -1,13 +1,27 @@ import Foundation +struct StaticParkingCacheMetrics: Equatable, Sendable { + let hits: Int + let misses: Int + let entries: Int +} + actor StaticParkingRepository: StaticParkingProviding { private var locations: [StaticParkingLocation] + private var spatialIndex: StaticParkingSpatialIndex? + private var searchEntries: [SearchEntry] private var loader: (@Sendable () throws -> [StaticParkingLocation])? private var didAttemptLoad: Bool private let resultLimit: Int private let ruleResolver: ParkingRuleResolver private let remote: (any RemoteParkingProviding)? private let catalogVersion: String + private var queryCache: [QueryKey: [ParkingOption]] = [:] + private var queryCacheOrder: [QueryKey] = [] + private var queryCacheHits = 0 + private var queryCacheMisses = 0 + private var resolutionPlanKey: ResolutionPlanKey? + private var resolutionCache: [String: StaticResolution] = [:] init( locations: [StaticParkingLocation], @@ -17,6 +31,8 @@ actor StaticParkingRepository: StaticParkingProviding { catalogVersion: String = "bundled-v1" ) { self.locations = locations + self.spatialIndex = StaticParkingSpatialIndex(locations: locations) + self.searchEntries = Self.makeSearchEntries(locations) self.loader = nil self.didAttemptLoad = true self.resultLimit = resultLimit @@ -33,6 +49,8 @@ actor StaticParkingRepository: StaticParkingProviding { catalogVersion: String = "bundled-v1" ) { self.locations = [] + self.spatialIndex = nil + self.searchEntries = [] self.loader = loader self.didAttemptLoad = false self.resultLimit = resultLimit @@ -42,26 +60,31 @@ actor StaticParkingRepository: StaticParkingProviding { } func options(in viewport: ParkingViewport, plan: ParkingPlan) async -> [ParkingOption] { + guard !Task.isCancelled else { return [] } + let cacheKey = QueryKey(viewport: viewport, plan: plan) + if remote == nil, let cached = queryCache[cacheKey] { + queryCacheHits += 1 + return cached + } + queryCacheMisses += 1 let queryViewport = viewport.padded(by: 0.2) let availableLocations = await availableLocations(viewport: queryViewport, plan: plan) - let candidates = availableLocations.compactMap { location -> Candidate? in - guard queryViewport.contains(location.coordinate) else { return nil } + guard !Task.isCancelled else { return [] } + prepareResolutionCache(for: plan) + var candidates: [Candidate] = [] + candidates.reserveCapacity(min(availableLocations.count, resultLimit * 4)) + for (offset, location) in availableLocations.enumerated() { + if offset.isMultiple(of: 64), Task.isCancelled { return [] } + guard queryViewport.contains(location.coordinate) else { continue } let distance = ParkingRepository.distance(from: location.coordinate, to: viewport.center) - guard - let rule = ruleResolver.resolve(location: location, plan: plan), - rule.isEligible else { return nil } - let prediction = location.predictionEvidence.flatMap { - PredictionEngine.staticEstimate( - capacity: location.capacity, - evidence: $0, - archetype: location.archetype, - context: demandContext(for: location, plan: plan), - forecastDate: plan.arrival - ) - } - return Candidate(location: location, rule: rule, distance: distance, prediction: prediction) + guard case .eligible(let rule, let prediction) = resolution( + for: location, + plan: plan, + cacheAllowed: remote == nil + ) else { continue } + candidates.append(Candidate(location: location, rule: rule, distance: distance, prediction: prediction)) } - .sorted { + candidates.sort { if $0.isOpenStreetMap != $1.isOpenStreetMap { return !$0.isOpenStreetMap } return $0.distance < $1.distance } @@ -76,29 +99,32 @@ actor StaticParkingRepository: StaticParkingProviding { let visibleCandidates = viewport.zoomLevel < 13 ? deduplicated : Array(deduplicated.prefix(resultLimit)) + guard !Task.isCancelled else { return [] } let options = visibleCandidates.map { makeOption($0, plan: plan) } - return viewport.zoomLevel < 13 ? clustered(options, in: viewport) : options + let result = viewport.zoomLevel < 13 ? clustered(options, in: viewport) : options + if remote == nil { cache(result, for: cacheKey) } + return result + } + + func cacheMetrics() -> StaticParkingCacheMetrics { + StaticParkingCacheMetrics(hits: queryCacheHits, misses: queryCacheMisses, entries: queryCache.count) } func search(_ query: String, near viewport: ParkingViewport, plan: ParkingPlan, limit: Int = 20) async -> [ParkingOption] { let normalized = Self.normalizedSearchText(query) let tokens = Set(normalized.split(separator: " ").map(String.init)) guard !tokens.isEmpty else { return [] } - return loadLocationsIfNeeded().compactMap { location -> (Candidate, Double)? in - let haystack = Self.normalizedSearchText("\(location.name) \(location.municipality) \(location.source.name)") - let haystackTokens = Set(haystack.split(separator: " ").map(String.init)) - let coverage = Double(tokens.intersection(haystackTokens).count) / Double(tokens.count) - let exact = haystack.contains(normalized) ? 1.0 : 0 + _ = loadLocationsIfNeeded() + prepareResolutionCache(for: plan) + return searchEntries.compactMap { entry -> (Candidate, Double)? in + if Task.isCancelled { return nil } + let coverage = Double(tokens.intersection(entry.tokens).count) / Double(tokens.count) + let exact = entry.normalizedText.contains(normalized) ? 1.0 : 0 let textScore = max(exact, coverage * 0.8) + let location = entry.location guard textScore >= 0.45, - let rule = ruleResolver.resolve(location: location, plan: plan), rule.isEligible else { return nil } + case .eligible(let rule, let prediction) = resolution(for: location, plan: plan) else { return nil } let distance = ParkingRepository.distance(from: location.coordinate, to: viewport.center) - let prediction = location.predictionEvidence.flatMap { - PredictionEngine.staticEstimate( - capacity: location.capacity, evidence: $0, archetype: location.archetype, - context: demandContext(for: location, plan: plan), forecastDate: plan.arrival - ) - } let sourceBoost = location.source.id == "openstreetmap-victoria-parking" ? 0 : 0.08 let proximity = max(0, 1 - min(distance, 100_000) / 100_000) return (Candidate(location: location, rule: rule, distance: distance, prediction: prediction), textScore * 0.8 + proximity * 0.12 + sourceBoost) @@ -109,11 +135,15 @@ actor StaticParkingRepository: StaticParkingProviding { } private func availableLocations(viewport: ParkingViewport, plan: ParkingPlan) async -> [StaticParkingLocation] { - var records = loadLocationsIfNeeded() + _ = loadLocationsIfNeeded() + var records = spatialIndex?.locations(in: viewport) ?? locations.filter { viewport.contains($0.coordinate) } if let remote { let query = RemoteParkingQuery(viewport: viewport, plan: plan, catalogVersion: catalogVersion) if let remoteLocations = try? await remote.locations(for: query) { - records = RemoteParkingMerger.merge(bundled: records, remote: remoteLocations) + records = RemoteParkingMerger.merge( + bundled: records, + remote: remoteLocations.filter { viewport.contains($0.coordinate) } + ) } } return records @@ -132,9 +162,65 @@ actor StaticParkingRepository: StaticParkingProviding { didAttemptLoad = true defer { loader = nil } locations = (try? loader?()) ?? [] + spatialIndex = StaticParkingSpatialIndex(locations: locations) + searchEntries = Self.makeSearchEntries(locations) return locations } + private func prepareResolutionCache(for plan: ParkingPlan) { + let key = ResolutionPlanKey(plan: plan) + guard key != resolutionPlanKey else { return } + resolutionPlanKey = key + resolutionCache.removeAll(keepingCapacity: true) + queryCache.removeAll(keepingCapacity: true) + queryCacheOrder.removeAll(keepingCapacity: true) + } + + private func resolution( + for location: StaticParkingLocation, + plan: ParkingPlan, + cacheAllowed: Bool = true + ) -> StaticResolution { + if cacheAllowed, let cached = resolutionCache[location.id] { return cached } + let result: StaticResolution + if let rule = ruleResolver.resolve(location: location, plan: plan), rule.isEligible { + let prediction = location.predictionEvidence.flatMap { + PredictionEngine.staticEstimate( + capacity: location.capacity, + evidence: $0, + archetype: location.archetype, + context: demandContext(for: location, plan: plan), + forecastDate: plan.arrival + ) + } + result = .eligible(rule, prediction) + } else { + result = .ineligible + } + if cacheAllowed { resolutionCache[location.id] = result } + return result + } + + private func cache(_ options: [ParkingOption], for key: QueryKey) { + queryCache[key] = options + queryCacheOrder.removeAll { $0 == key } + queryCacheOrder.append(key) + while queryCacheOrder.count > 16 { + queryCache.removeValue(forKey: queryCacheOrder.removeFirst()) + } + } + + private static func makeSearchEntries(_ locations: [StaticParkingLocation]) -> [SearchEntry] { + locations.map { location in + let normalized = normalizedSearchText("\(location.name) \(location.municipality) \(location.source.name)") + return SearchEntry( + location: location, + normalizedText: normalized, + tokens: Set(normalized.split(separator: " ").map(String.init)) + ) + } + } + private func makeOption(_ candidate: Candidate, plan: ParkingPlan) -> ParkingOption { let location = candidate.location let kind: ParkingOptionKind = location.kind == .onStreet ? .onStreet : .offStreet @@ -259,6 +345,51 @@ actor StaticParkingRepository: StaticParkingProviding { } } + private struct SearchEntry: Sendable { + let location: StaticParkingLocation + let normalizedText: String + let tokens: Set + } + + private enum StaticResolution: Sendable { + case eligible(ResolvedParkingRule, AvailabilityPrediction?) + case ineligible + } + + private struct ResolutionPlanKey: Equatable, Sendable { + let arrivalMinute: Int + let durationMinutes: Int + let isPublicHoliday: Bool + + init(plan: ParkingPlan) { + arrivalMinute = Int(plan.arrival.timeIntervalSince1970) / 60 + durationMinutes = plan.durationMinutes + isPublicHoliday = plan.isPublicHoliday + } + } + + private struct QueryKey: Hashable, Sendable { + let south: Int + let west: Int + let north: Int + let east: Int + let zoom: Int + let arrivalMinute: Int + let durationMinutes: Int + let isPublicHoliday: Bool + + init(viewport: ParkingViewport, plan: ParkingPlan) { + south = Int((viewport.south * 100_000).rounded()) + west = Int((viewport.west * 100_000).rounded()) + north = Int((viewport.north * 100_000).rounded()) + east = Int((viewport.east * 100_000).rounded()) + zoom = Int((viewport.zoomLevel * 10).rounded()) + arrivalMinute = Int(plan.arrival.timeIntervalSince1970) / 60 + durationMinutes = plan.durationMinutes + isPublicHoliday = plan.isPublicHoliday + } + } + private struct GridKey: Hashable { let row: Int let column: Int diff --git a/ParkAlong/Data/StaticParkingSpatialIndex.swift b/ParkAlong/Data/StaticParkingSpatialIndex.swift new file mode 100644 index 0000000..d458c0e --- /dev/null +++ b/ParkAlong/Data/StaticParkingSpatialIndex.swift @@ -0,0 +1,85 @@ +import Foundation + +struct StaticParkingSpatialQuery: Sendable { + let locations: [StaticParkingLocation] + let inspectedLocationCount: Int +} + +/// An immutable latitude/longitude grid built once for the statewide catalog. +/// Street-scale queries inspect only nearby grid cells instead of every Victorian record. +struct StaticParkingSpatialIndex: Sendable { + private struct Cell: Hashable, Sendable { + let latitude: Int + let longitude: Int + } + + private let locations: [StaticParkingLocation] + private let buckets: [Cell: [Int]] + private let occupiedCells: [Cell] + private let cellSizeDegrees: Double + + init(locations: [StaticParkingLocation], cellSizeDegrees: Double = 0.02) { + precondition(cellSizeDegrees > 0, "Spatial-index cells must have positive area") + self.locations = locations + self.cellSizeDegrees = cellSizeDegrees + + var buckets: [Cell: [Int]] = [:] + buckets.reserveCapacity(locations.count / 2) + for (index, location) in locations.enumerated() { + buckets[Self.cell(for: location.coordinate, size: cellSizeDegrees), default: []].append(index) + } + self.buckets = buckets + self.occupiedCells = buckets.keys.sorted { + if $0.latitude != $1.latitude { return $0.latitude < $1.latitude } + return $0.longitude < $1.longitude + } + } + + func locations(in viewport: ParkingViewport) -> [StaticParkingLocation] { + query(in: viewport).locations + } + + func query(in viewport: ParkingViewport) -> StaticParkingSpatialQuery { + let minimum = cell(for: .init(latitude: viewport.south, longitude: viewport.west)) + let maximum = cell(for: .init(latitude: viewport.north, longitude: viewport.east)) + let latitudeCellCount = max(1, maximum.latitude - minimum.latitude + 1) + let longitudeCellCount = max(1, maximum.longitude - minimum.longitude + 1) + let requestedCellCount = latitudeCellCount.multipliedReportingOverflow(by: longitudeCellCount) + + var candidateIndices: [Int] = [] + if !requestedCellCount.overflow, requestedCellCount.partialValue <= occupiedCells.count * 2 { + for latitude in minimum.latitude...maximum.latitude { + for longitude in minimum.longitude...maximum.longitude { + candidateIndices.append(contentsOf: buckets[Cell(latitude: latitude, longitude: longitude)] ?? []) + } + } + } else { + for cell in occupiedCells where + (minimum.latitude...maximum.latitude).contains(cell.latitude) + && (minimum.longitude...maximum.longitude).contains(cell.longitude) { + candidateIndices.append(contentsOf: buckets[cell] ?? []) + } + } + + candidateIndices.sort() + let matches = candidateIndices.compactMap { index -> StaticParkingLocation? in + let location = locations[index] + return viewport.contains(location.coordinate) ? location : nil + } + return StaticParkingSpatialQuery( + locations: matches, + inspectedLocationCount: candidateIndices.count + ) + } + + private func cell(for coordinate: Coordinate) -> Cell { + Self.cell(for: coordinate, size: cellSizeDegrees) + } + + private static func cell(for coordinate: Coordinate, size: Double) -> Cell { + Cell( + latitude: Int(floor(coordinate.latitude / size)), + longitude: Int(floor(coordinate.longitude / size)) + ) + } +} diff --git a/ParkAlong/Features/Map/AvailabilityPin.swift b/ParkAlong/Features/Map/AvailabilityPin.swift index fbbc3ee..70d5ecd 100644 --- a/ParkAlong/Features/Map/AvailabilityPin.swift +++ b/ParkAlong/Features/Map/AvailabilityPin.swift @@ -12,6 +12,7 @@ enum ParkingPinPalette: Equatable { static let predictedPlumColor = Color(red: 107 / 255, green: 58 / 255, blue: 110 / 255) static let warningAmber = Color(red: 0.93, green: 0.62, blue: 0.12) static let clusterBlue = Color(red: 0.18, green: 0.39, blue: 0.72) + static let locationMutedColor = Color(red: 0.48, green: 0.29, blue: 0.31) static func live(for available: Int) -> ParkingPinPalette { switch available { @@ -25,7 +26,8 @@ enum ParkingPinPalette: Equatable { switch self { case .liveAvailable: .green case .liveLimited: .orange - case .liveFull, .locationRed: .red + case .liveFull: .red + case .locationRed: Self.locationMutedColor case .predictedPlum: Self.predictedPlumColor case .cluster: Self.clusterBlue } @@ -103,6 +105,7 @@ struct AvailabilityPin: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion @ScaledMetric(relativeTo: .caption) private var badgeHeight = 28.0 + @ScaledMetric(relativeTo: .caption2) private var compactBadgeHeight = 22.0 @ScaledMetric(relativeTo: .caption2) private var warningSize = 10.0 private var presentation: ParkingPinPresentation { @@ -142,12 +145,12 @@ struct AvailabilityPin: View { } } .compositingGroup() - .shadow(color: .black.opacity(isSelected ? 0.2 : 0.1), radius: isSelected ? 2.5 : 1, y: 1) + .opacity(presentation.palette == .locationRed && !isSelected ? 0.78 : 1) + .shadow(color: .black.opacity(isSelected ? 0.2 : 0.08), radius: isSelected ? 2.5 : 1, y: 1) .scaleEffect(isSelected ? 1.1 : 1) .animation(reduceMotion ? nil : .smooth(duration: 0.22), value: isSelected) .frame(minWidth: 44, minHeight: 44, alignment: .bottom) .contentShape(Rectangle()) - .accessibilityHidden(true) } private func pinBody(fill: Color, selected: Bool) -> some View { @@ -158,7 +161,10 @@ struct AvailabilityPin: View { .lineLimit(1) .minimumScaleFactor(0.75) .padding(.horizontal, presentation.label.count > 2 ? 6 : 8) - .frame(minWidth: presentation.isCluster ? 34 : 30, minHeight: badgeHeight) + .frame( + minWidth: presentation.isCluster ? 34 : (presentation.palette == .locationRed ? 24 : 30), + minHeight: presentation.palette == .locationRed ? compactBadgeHeight : badgeHeight + ) .background(fill, in: RoundedRectangle(cornerRadius: presentation.isCluster ? 12 : 8, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: presentation.isCluster ? 12 : 8, style: .continuous) @@ -175,7 +181,7 @@ struct AvailabilityPin: View { private var labelFont: Font { switch presentation.palette { case .locationRed: - .caption.weight(.heavy) + .caption2.weight(.semibold) case .cluster: .caption.weight(.heavy).monospacedDigit() default: @@ -184,30 +190,6 @@ struct AvailabilityPin: View { } } -struct ParkingPinButton: View { - let option: ParkingOption - var isSelected = false - let identifier: String - var hint = "Shows parking details" - var action: () -> Void - - private var resolvedHint: String { - option.clusterCount == nil ? hint : "Zooms into this area" - } - - var body: some View { - Button(action: action) { - AvailabilityPin(option: option, isSelected: isSelected) - } - .buttonStyle(.plain) - .frame(minWidth: 44, minHeight: 44, alignment: .bottom) - .contentShape(Rectangle()) - .accessibilityLabel(ParkingPinPresentation(option: option).accessibilityLabel) - .accessibilityHint(resolvedHint) - .accessibilityIdentifier(identifier) - } -} - struct DestinationMarker: View { var body: some View { Image(systemName: "mappin.circle.fill") diff --git a/ParkAlong/Features/Map/MapControls.swift b/ParkAlong/Features/Map/MapControls.swift index a0de7ee..cdf28d4 100644 --- a/ParkAlong/Features/Map/MapControls.swift +++ b/ParkAlong/Features/Map/MapControls.swift @@ -233,6 +233,14 @@ struct StayDurationBar: View { @State private var showingPlanner = false @State private var plannerPrefersEightHours = false + private var trackShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 18, style: .continuous) + } + + private var thumbShape: RoundedRectangle { + RoundedRectangle(cornerRadius: 13, style: .continuous) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { if let caption = StayPlanFormatting.arrivalCaption(for: viewModel.plan) { @@ -243,10 +251,13 @@ struct StayDurationBar: View { .accessibilityIdentifier("planned-arrival-caption") } - HStack(alignment: .center, spacing: 8) { - stayTrack - plannerButton - refreshButton + AdaptiveGlassContainer(spacing: 8) { + HStack(alignment: .center, spacing: 8) { + stayTrack + plannerButton + refreshButton + } + .adaptiveControlDock(cornerRadius: 22) } } .animation(reduceMotion ? nil : .snappy(duration: 0.22), value: viewModel.plan) @@ -259,40 +270,38 @@ struct StayDurationBar: View { } } - @ViewBuilder private var stayTrack: some View { - AdaptiveGlassContainer(spacing: 4) { - if dynamicTypeSize.isAccessibilitySize { - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 4) { - trackChips - } - .padding(6) - } else { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 2) { - trackChips - } - .scrollTargetLayout() - .padding(4) - } - .scrollTargetBehavior(.viewAligned) - .onAppear { - proxy.scrollTo(selectedItem.id, anchor: .center) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(Array(StayTrackItem.all.enumerated()), id: \.element.id) { index, item in + sizedSegment(item, index: index) } - .onChange(of: selectedItem) { _, item in - if reduceMotion { - proxy.scrollTo(item.id, anchor: .center) - } else { - withAnimation(.snappy(duration: 0.28)) { - proxy.scrollTo(item.id, anchor: .center) - } - } + } + .scrollTargetLayout() + } + .scrollTargetBehavior(.viewAligned) + .scrollBounceBehavior(.basedOnSize) + .onAppear { + if dynamicTypeSize.isAccessibilitySize { + proxy.scrollTo(StayTrackItem.all[0].id, anchor: .leading) + } else { + proxy.scrollTo(selectedItem.id, anchor: .center) + } + } + .onChange(of: selectedItem) { _, item in + if reduceMotion { + proxy.scrollTo(item.id, anchor: .center) + } else { + withAnimation(.snappy(duration: 0.28)) { + proxy.scrollTo(item.id, anchor: .center) } } } } - .adaptiveControlDock(cornerRadius: 24) + .padding(.horizontal, 5) + .padding(.vertical, 4) + .adaptiveGlassSurface(shape: trackShape, isInteractive: true) .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) .accessibilityIdentifier("stay-duration-track") @@ -302,38 +311,50 @@ struct StayDurationBar: View { } @ViewBuilder - private var trackChips: some View { - ForEach(StayTrackItem.presets) { item in - stayChip(item) + private func sizedSegment(_ item: StayTrackItem, index: Int) -> some View { + if dynamicTypeSize.isAccessibilitySize { + staySegment(item, index: index) + .fixedSize(horizontal: true, vertical: false) + } else { + staySegment(item, index: index) + .containerRelativeFrame(.horizontal) { length, _ in + max(44, length / CGFloat(StayTrackItem.all.count)) + } } - stayChip(.extended) } - private func stayChip(_ item: StayTrackItem) -> some View { + private func staySegment(_ item: StayTrackItem, index: Int) -> some View { let selected = selectedItem == item return Button { select(item) } label: { Text(chipLabel(for: item, selected: selected)) - .font(.subheadline.weight(selected ? .semibold : .medium)) + .font(.footnote.weight(selected ? .semibold : .medium)) .monospacedDigit() .foregroundStyle(selected ? Color.white : Color.primary) .lineLimit(1) - .minimumScaleFactor(0.75) - .padding(.horizontal, 12) - .frame(minWidth: 44, minHeight: 44) + .minimumScaleFactor(0.7) + .padding(.horizontal, dynamicTypeSize.isAccessibilitySize ? 12 : 4) + .frame(minWidth: 44, maxWidth: .infinity, minHeight: 44) .background { if selected { if reduceMotion { - Capsule().fill(Color.accentColor) + thumbShape.fill(Color.accentColor) } else { - Capsule() + thumbShape .fill(Color.accentColor) .matchedGeometryEffect(id: "stay-selection", in: staySelection) } } } - .contentShape(Capsule()) + .overlay(alignment: .leading) { + if showsDivider(before: index) { + Capsule() + .fill(.primary.opacity(0.16)) + .frame(width: 1, height: 16) + } + } + .contentShape(Rectangle()) } .buttonStyle(.plain) .id(item.id) @@ -343,6 +364,12 @@ struct StayDurationBar: View { .accessibilityAddTraits(selected ? [.isSelected] : []) } + private func showsDivider(before index: Int) -> Bool { + guard index > 0 else { return false } + let items = StayTrackItem.all + return selectedItem != items[index] && selectedItem != items[index - 1] + } + private var plannerButton: some View { Button { plannerPrefersEightHours = false @@ -429,6 +456,7 @@ private enum StayTrackItem: Hashable, Identifiable { static let presetMinutes = [15, 60, 120, 180, 240, 360] static let presets = presetMinutes.map(StayTrackItem.minutes) + static let all = presets + [.extended] var id: String { identifier } diff --git a/ParkAlong/Features/Map/ParkingMapView.swift b/ParkAlong/Features/Map/ParkingMapView.swift index 2282039..9d3a9c7 100644 --- a/ParkAlong/Features/Map/ParkingMapView.swift +++ b/ParkAlong/Features/Map/ParkingMapView.swift @@ -5,7 +5,8 @@ struct ParkingMapView: View { @Bindable var viewModel: ParkingMapViewModel @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var cameraPosition: MapCameraPosition = .region(Self.region(for: .melbourneCBD)) + @State private var cameraPosition: MapCameraPosition = .automatic + @State private var selectedMarkerID: String? @State private var showingAbout = false var body: some View { @@ -42,20 +43,31 @@ struct ParkingMapView: View { await viewModel.refresh(force: true) } } + .onDisappear { + viewModel.cancelOutstandingWork() + } .onChange(of: scenePhase) { _, phase in guard phase == .active else { return } - Task { await viewModel.refresh(force: true) } + Task { await viewModel.refreshAfterActivation() } } .onChange(of: CameraIntent(destination: viewModel.destination, focus: viewModel.mapFocusRequest)) { oldIntent, newIntent in applyCameraIntent(from: oldIntent, to: newIntent) } + .onChange(of: selectedMarkerID) { _, markerID in + guard let markerID, + let option = viewModel.mapOptions.first(where: { $0.id == markerID }) else { return } + viewModel.selectMapOption(option) + } + .onChange(of: viewModel.selectedOption?.id) { _, optionID in + selectedMarkerID = optionID + } } private var map: some View { - Map(position: $cameraPosition) { + Map(position: $cameraPosition, selection: $selectedMarkerID) { UserAnnotation() - if viewModel.destination.id != "current" { + if viewModel.destination.id != "current", viewModel.destination.id != "locating" { Annotation(viewModel.destination.name, coordinate: viewModel.destination.coordinate.locationCoordinate, anchor: .center) { DestinationMarker() } @@ -69,50 +81,25 @@ struct ParkingMapView: View { } } - ForEach(viewModel.staticOptions) { option in + ForEach(renderedMapOptions) { option in Annotation("", coordinate: option.coordinate.locationCoordinate, anchor: .bottom) { - ParkingPinButton( - option: option, - isSelected: viewModel.selectedOption?.id == option.id, - identifier: "static-pin-\(option.id)" - ) { - viewModel.selectStatic(option) - } - } - } - - ForEach(viewModel.offStreetOptions) { option in - Annotation("", coordinate: option.coordinate.locationCoordinate, anchor: .bottom) { - ParkingPinButton( - option: option, - isSelected: viewModel.selectedOption?.id == option.id, - identifier: "offstreet-pin-\(option.id)" - ) { - viewModel.selectOffStreet(option) - } - } - } - - ForEach(viewModel.mapZones) { zone in - let option = ParkingOption.onStreet(zone, plan: viewModel.plan) - Annotation("", coordinate: zone.coordinate.locationCoordinate, anchor: .bottom) { - ParkingPinButton( - option: option, - isSelected: viewModel.selectedZone?.zoneNumber == zone.zoneNumber, - identifier: "zone-pin-\(zone.zoneNumber)", - hint: "Shows zone details" - ) { - Task { await viewModel.selectZone(zone) } - } + AvailabilityPin(option: option, isSelected: viewModel.selectedOption?.id == option.id) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ParkingPinPresentation(option: option).accessibilityLabel) + .accessibilityHint(option.clusterCount == nil ? "Shows parking details" : "Zooms into this area") + .accessibilityIdentifier(markerIdentifier(for: option)) } + .tag(option.id) } } .mapStyle(.standard(pointsOfInterest: .excludingAll)) .mapControlVisibility(.hidden) + .accessibilityIdentifier("parking-map") + .accessibilityValue(String(format: "%.2f", viewModel.viewport.zoomLevel)) .ignoresSafeArea() .onMapCameraChange(frequency: .onEnd) { context in // Report the settled visible region, including after a user pan/zoom. - applyVisibleRegion(context.region) + applyVisibleRegion(context.region, userInitiated: cameraPosition.positionedByUser) } .overlay(alignment: .top) { MapTopChrome(viewModel: viewModel, showingAbout: $showingAbout) @@ -132,12 +119,42 @@ struct ParkingMapView: View { Binding( get: { viewModel.selectedOption != nil }, set: { presented in - if !presented { viewModel.dismissZone() } + if !presented { + selectedMarkerID = nil + viewModel.dismissZone() + } } ) } + private func markerIdentifier(for option: ParkingOption) -> String { + if let zoneNumber = option.zoneNumber { return "zone-pin-\(zoneNumber)" } + if viewModel.staticOptions.contains(where: { $0.id == option.id }) { return "static-pin-\(option.id)" } + return "offstreet-pin-\(option.id)" + } + + /// Lower-value warnings are painted first so useful or selected results remain legible on top. + private var renderedMapOptions: [ParkingOption] { + viewModel.mapOptions.sorted { lhs, rhs in + let lhsPriority = renderPriority(for: lhs) + let rhsPriority = renderPriority(for: rhs) + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.id < rhs.id + } + } + + private func renderPriority(for option: ParkingOption) -> Int { + if option.id == viewModel.selectedOption?.id { return 4 } + if option.classification == .verifiedLive { return 3 } + if option.classification == .predicted { return 2 } + if option.clusterCount != nil { return 1 } + return 0 + } + private func applyCameraIntent(from old: CameraIntent, to new: CameraIntent) { + // `map-area` records that the person took control during startup; it is + // descriptive state, not a request to move the camera again. + guard new.destination.id != "map-area" else { return } if let focus = new.focus { moveCamera(to: focus, animated: !reduceMotion) viewModel.clearMapFocusRequest() @@ -147,7 +164,7 @@ struct ParkingMapView: View { moveCamera(to: new.destination.coordinate, animated: !reduceMotion) } - private func applyVisibleRegion(_ region: MKCoordinateRegion) { + private func applyVisibleRegion(_ region: MKCoordinateRegion, userInitiated: Bool) { let south = region.center.latitude - region.span.latitudeDelta / 2 let north = region.center.latitude + region.span.latitudeDelta / 2 let west = region.center.longitude - region.span.longitudeDelta / 2 @@ -160,7 +177,11 @@ struct ParkingMapView: View { east: east, zoomLevel: Self.zoomLevel(longitudeSpan: region.span.longitudeDelta) ) - viewModel.updateViewport(viewport, interactionEnded: true) + viewModel.updateViewport( + viewport, + interactionEnded: true, + userInitiated: userInitiated + ) } private func moveCamera(to coordinate: Coordinate, animated: Bool) { diff --git a/ParkAlong/Features/Map/ParkingMapViewModel.swift b/ParkAlong/Features/Map/ParkingMapViewModel.swift index 653ed2b..fac4e33 100644 --- a/ParkAlong/Features/Map/ParkingMapViewModel.swift +++ b/ParkAlong/Features/Map/ParkingMapViewModel.swift @@ -25,12 +25,22 @@ final class ParkingMapViewModel { private let navigator: any ParkingNavigating private let offStreetService: any OffStreetParkingProviding private let staticParkingService: any StaticParkingProviding + private let viewportDebounce: Duration private var refreshGeneration = 0 private var searchGeneration = 0 + private var locationRequestGeneration = 0 private var searchParkingOptions: [String: ParkingOption] = [:] + private var hasStarted = false @ObservationIgnored private var viewportRefreshTask: Task? + @ObservationIgnored private var activeRefreshTask: Task? + @ObservationIgnored private var locationRequestTask: Task? - var destination = ParkingDestination(id: "cbd", name: "Melbourne CBD", subtitle: "Central Melbourne", coordinate: .melbourneCBD) + var destination = ParkingDestination( + id: "locating", + name: "Current location", + subtitle: "Locating…", + coordinate: .melbourneCBD + ) var plan = ParkingPlan(arrival: .now, duration: .oneHour) var duration: StayDuration { StayDuration(rawValue: plan.durationMinutes) ?? .eightHours } var viewport = ParkingMapViewModel.viewport(centeredAt: .melbourneCBD) @@ -49,56 +59,116 @@ final class ParkingMapViewModel { var isSearching = false var navigationWasIntercepted = false var mapFocusRequest: ParkingViewport? + var isLocating: Bool { destination.id == "locating" } - /// Keeps the map legible while retaining the highest-ranked availability-first options. - var mapZones: [ParkingZone] { Array(zones.prefix(24)) } + /// Keeps useful live/predicted results ahead of location-only warnings and protects map gestures. + var mapOptions: [ParkingOption] { + var options = zones.prefix(24).map { ParkingOption.onStreet($0, plan: plan) } + options.append(contentsOf: offStreetOptions) + options.append(contentsOf: staticOptions) + if let selectedOption, !options.contains(where: { $0.id == selectedOption.id }) { + options.append(selectedOption) + } + return ParkingMarkerSelector.select( + options: options, + selectedID: selectedOption?.id, + viewport: viewport + ) + } + + var mapZones: [ParkingZone] { + let visibleIDs = Set(mapOptions.compactMap(\.zoneNumber)) + return zones.filter { visibleIDs.contains($0.zoneNumber) } + } var selectedOption: ParkingOption? { if let selectedZone { return .onStreet(selectedZone, plan: plan) } return selectedOffStreetOption } - init(repository: any ParkingRepositoryProviding, locationService: any LocationProviding, destinationSearch: any DestinationSearching, navigator: any ParkingNavigating, offStreetService: any OffStreetParkingProviding, staticParkingService: any StaticParkingProviding) { + init( + repository: any ParkingRepositoryProviding, + locationService: any LocationProviding, + destinationSearch: any DestinationSearching, + navigator: any ParkingNavigating, + offStreetService: any OffStreetParkingProviding, + staticParkingService: any StaticParkingProviding, + viewportDebounce: Duration = .milliseconds(250) + ) { self.repository = repository self.locationService = locationService self.destinationSearch = destinationSearch self.navigator = navigator self.offStreetService = offStreetService self.staticParkingService = staticParkingService + self.viewportDebounce = viewportDebounce } - func start() async { await refresh(force: false) } + func start() async { + guard !hasStarted else { return } + hasStarted = true + await useCurrentLocation() + } func refresh(force: Bool = true) async { + activeRefreshTask?.cancel() refreshGeneration += 1 let generation = refreshGeneration - state = .loading let plan = self.plan let now = Date.now let viewport = self.viewport + state = .loading + let task = Task { [weak self] in + guard let self else { return } + await self.performRefresh( + generation: generation, + viewport: viewport, + plan: plan, + now: now, + force: force + ) + } + activeRefreshTask = task + await withTaskCancellationHandler { + await task.value + } onCancel: { + task.cancel() + } + if generation == refreshGeneration { activeRefreshTask = nil } + } + + private func performRefresh( + generation: Int, + viewport: ParkingViewport, + plan: ParkingPlan, + now: Date, + force: Bool + ) async { async let nearbyFacilities = offStreetService.options(in: viewport) async let mappedParking = staticParkingService.options(in: viewport, plan: plan) do { let result = try await repository.refresh(viewport: viewport, plan: plan, now: now, force: force) - guard generation == refreshGeneration else { return } + let facilities = await nearbyFacilities + let staticLocations = await mappedParking + guard !Task.isCancelled, generation == refreshGeneration else { return } zones = result.zones + offStreetOptions = facilities + staticOptions = staticLocations mode = result.mode notice = result.notice checkedAt = result.checkedAt if let selected = selectedZone { selectedZone = zones.first(where: { $0.zoneNumber == selected.zoneNumber }) } + rebindSelectedOption(facilities: facilities, staticLocations: staticLocations) state = .loaded - let facilities = await nearbyFacilities - let staticLocations = await mappedParking - guard generation == refreshGeneration else { return } - offStreetOptions = facilities - staticOptions = staticLocations + } catch is CancellationError { + return } catch { - guard generation == refreshGeneration else { return } let facilities = await nearbyFacilities let staticLocations = await mappedParking - guard generation == refreshGeneration else { return } + guard !Task.isCancelled, generation == refreshGeneration else { return } offStreetOptions = facilities staticOptions = staticLocations + rebindSelectedOption(facilities: facilities, staticLocations: staticLocations) if zones.isEmpty && facilities.isEmpty && staticLocations.isEmpty { state = .failed("Parking information couldn’t be updated. Try again in a moment.") } else { @@ -115,6 +185,11 @@ final class ParkingMapViewModel { await refresh(force: true) } + func refreshAfterActivation() async { + guard !isLocating else { return } + await refresh(force: true) + } + /// Applies the visible selection immediately, then refreshes parking in the background. func selectDuration(_ value: StayDuration) { guard preparePlanUpdate(ParkingPlan(arrival: plan.arrival, duration: value, isPublicHoliday: plan.isPublicHoliday)) else { return } @@ -129,6 +204,8 @@ final class ParkingMapViewModel { private func preparePlanUpdate(_ value: ParkingPlan) -> Bool { guard plan != value else { return false } plan = value + activeRefreshTask?.cancel() + viewportRefreshTask?.cancel() refreshGeneration += 1 selectedZone = nil selectedOffStreetOption = nil @@ -136,14 +213,25 @@ final class ParkingMapViewModel { return true } - func updateViewport(_ value: ParkingViewport, interactionEnded: Bool) { + func updateViewport( + _ value: ParkingViewport, + interactionEnded: Bool, + userInitiated: Bool = true + ) { + if userInitiated { noteMapInteraction(at: value.center) } + // MapKit reports an automatic camera settle while Core Location is + // pending. It is not a real viewport choice and should not kick off a + // statewide/default-area refresh. + guard destination.id != "locating" else { return } let changed = value.materiallyDiffers(from: viewport) viewport = value guard interactionEnded, changed else { return } viewportRefreshTask?.cancel() + activeRefreshTask?.cancel() + refreshGeneration += 1 viewportRefreshTask = Task { [weak self] in do { - try await Task.sleep(for: .milliseconds(250)) + try await Task.sleep(for: self?.viewportDebounce ?? .milliseconds(250)) } catch { return } @@ -152,18 +240,77 @@ final class ParkingMapViewModel { } } + /// A deliberate pan or pinch takes ownership of the camera. A late startup + /// location fix must never pull the map away from where the person moved it. + private func noteMapInteraction(at coordinate: Coordinate) { + guard destination.id == "locating" else { return } + invalidatePendingLocationRequest() + destination = .init( + id: "map-area", + name: "Map area", + subtitle: "Using the area you chose", + coordinate: coordinate + ) + } + func useCurrentLocation() async { - guard let coordinate = await locationService.requestCoordinate() else { - destination = .init(id: "cbd", name: "Melbourne CBD", subtitle: "Location unavailable", coordinate: .melbourneCBD) - viewport = Self.viewport(centeredAt: .melbourneCBD) + locationRequestTask?.cancel() + locationRequestGeneration += 1 + let generation = locationRequestGeneration + destination = .init( + id: "locating", + name: "Current location", + subtitle: "Locating…", + coordinate: destination.coordinate + ) + + let requestTask = Task { await locationService.requestCoordinate() } + locationRequestTask = requestTask + let result = await withTaskCancellationHandler { + await requestTask.value + } onCancel: { + requestTask.cancel() + } + guard !Task.isCancelled, generation == locationRequestGeneration else { return } + locationRequestTask = nil + + switch result { + case let .success(coordinate): + destination = .init(id: "current", name: "Current location", subtitle: "Near you", coordinate: coordinate) + viewport = Self.viewport(centeredAt: coordinate) mapFocusRequest = viewport - await refresh(force: false) + await refresh(force: true) + case .denied: + await applyLocationFallback( + subtitle: "Location permission denied", + notice: "Location permission denied · showing Melbourne CBD" + ) + case .restricted: + await applyLocationFallback( + subtitle: "Location access restricted", + notice: "Location access restricted · showing Melbourne CBD" + ) + case .timedOut: + await applyLocationFallback( + subtitle: "Current location timed out", + notice: "Location timed out · showing Melbourne CBD" + ) + case .unavailable: + await applyLocationFallback( + subtitle: "Current location unavailable", + notice: "Location unavailable · showing Melbourne CBD" + ) + case .cancelled: return } - destination = .init(id: "current", name: "Current location", subtitle: "Near you", coordinate: coordinate) - viewport = Self.viewport(centeredAt: coordinate) + } + + private func applyLocationFallback(subtitle: String, notice fallbackNotice: String) async { + destination = .init(id: "cbd", name: "Melbourne CBD", subtitle: subtitle, coordinate: .melbourneCBD) + viewport = Self.viewport(centeredAt: .melbourneCBD) mapFocusRequest = viewport - await refresh(force: true) + await refresh(force: false) + notice = fallbackNotice } func search(query: String) async { @@ -213,6 +360,7 @@ final class ParkingMapViewModel { } func chooseDestination(_ value: ParkingDestination) async { + invalidatePendingLocationRequest() if let optionID = value.parkingOptionID, let option = searchParkingOptions[optionID] { isSearching = false searchResults = [] @@ -257,10 +405,31 @@ final class ParkingMapViewModel { selectOffStreet(option) } + func selectMapOption(_ option: ParkingOption) { + if let zoneNumber = option.zoneNumber, + let zone = zones.first(where: { $0.zoneNumber == zoneNumber }) { + Task { await selectZone(zone) } + } else if staticOptions.contains(where: { $0.id == option.id }) || option.clusterCount != nil { + selectStatic(option) + } else { + selectOffStreet(option) + } + } + func clearMapFocusRequest() { mapFocusRequest = nil } + func cancelOutstandingWork() { + let cancelledStartup = destination.id == "locating" && locationRequestTask != nil + invalidatePendingLocationRequest() + if cancelledStartup { hasStarted = false } + viewportRefreshTask?.cancel() + viewportRefreshTask = nil + activeRefreshTask?.cancel() + activeRefreshTask = nil + } + func dismissZone() { selectedZone = nil selectedOffStreetOption = nil @@ -291,4 +460,20 @@ final class ParkingMapViewModel { zoomLevel: log2(360 / longitudeSpan) ) } + + private func invalidatePendingLocationRequest() { + guard locationRequestTask != nil else { return } + locationRequestGeneration += 1 + locationRequestTask?.cancel() + locationRequestTask = nil + } + + private func rebindSelectedOption( + facilities: [ParkingOption], + staticLocations: [ParkingOption] + ) { + guard let selected = selectedOffStreetOption, + let updated = (facilities + staticLocations).first(where: { $0.id == selected.id }) else { return } + selectedOffStreetOption = updated + } } diff --git a/ParkAlong/Features/Map/ParkingMarkerSelector.swift b/ParkAlong/Features/Map/ParkingMarkerSelector.swift new file mode 100644 index 0000000..0d9d9eb --- /dev/null +++ b/ParkAlong/Features/Map/ParkingMarkerSelector.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Keeps map content useful and tappable without letting location-only warnings cover the map. +enum ParkingMarkerSelector { + static func markerBudget(for zoomLevel: Double) -> Int { + switch zoomLevel { + case ..<10: 20 + case ..<13: 28 + case ..<15: 36 + default: 48 + } + } + + static func select( + options: S, + selectedID: String?, + viewport: ParkingViewport + ) -> [ParkingOption] where S.Element == ParkingOption { + let all = options.sorted(by: priorityOrder) + let budget = markerBudget(for: viewport.zoomLevel) + let selected = selectedID.flatMap { id in all.first(where: { $0.id == id }) } + let useful = all.filter { option in + option.classification == .verifiedLive + || option.classification == .predicted + || option.clusterCount != nil + } + var result = Array(useful.prefix(budget)) + var selectedIDs = Set(result.map(\.id)) + + if let selected, !selectedIDs.contains(selected.id) { + if result.count == budget, let removed = result.popLast() { + selectedIDs.remove(removed.id) + } + result.append(selected) + selectedIDs.insert(selected.id) + } + + let remainingCapacity = max(0, budget - result.count) + let lowValueBudget = useful.isEmpty ? min(12, remainingCapacity) : min(4, remainingCapacity) + guard lowValueBudget > 0 else { return result.sorted(by: priorityOrder) } + + let warnings = all.filter { !selectedIDs.contains($0.id) && $0.clusterCount == nil } + let gridDimension = max(3, Int(ceil(sqrt(Double(lowValueBudget) * 1.5)))) + var occupiedCells = Set() + var distributed: [ParkingOption] = [] + distributed.reserveCapacity(lowValueBudget) + + for option in warnings { + let cell = gridCell(for: option.coordinate, viewport: viewport, dimension: gridDimension) + guard occupiedCells.insert(cell).inserted else { continue } + distributed.append(option) + if distributed.count == lowValueBudget { break } + } + + if distributed.count < lowValueBudget { + let distributedIDs = Set(distributed.map(\.id)) + distributed.append(contentsOf: warnings.lazy.filter { !distributedIDs.contains($0.id) }.prefix(lowValueBudget - distributed.count)) + } + result.append(contentsOf: distributed) + return result.sorted(by: priorityOrder) + } + + private static func priorityOrder(_ lhs: ParkingOption, _ rhs: ParkingOption) -> Bool { + let lhsRank = priority(of: lhs) + let rhsRank = priority(of: rhs) + if lhsRank != rhsRank { return lhsRank < rhsRank } + if lhs.isBestBet != rhs.isBestBet { return lhs.isBestBet } + if lhs.walkingMetres != rhs.walkingMetres { return lhs.walkingMetres < rhs.walkingMetres } + return lhs.id < rhs.id + } + + private static func priority(of option: ParkingOption) -> Int { + switch option.classification { + case .verifiedLive: return 0 + case .predicted: return 1 + case .staticOnly, .staleHistorical: + if option.clusterCount != nil { return 2 } + if option.provider.localizedCaseInsensitiveContains("OpenStreetMap") { return 4 } + return 3 + } + } + + private static func gridCell( + for coordinate: Coordinate, + viewport: ParkingViewport, + dimension: Int + ) -> GridCell { + let row = Int(((coordinate.latitude - viewport.south) / max(viewport.latitudeSpan, 0.000_001) * Double(dimension)).rounded(.down)) + let column = Int(((coordinate.longitude - viewport.west) / max(viewport.longitudeSpan, 0.000_001) * Double(dimension)).rounded(.down)) + return GridCell( + row: min(dimension - 1, max(0, row)), + column: min(dimension - 1, max(0, column)) + ) + } + + private struct GridCell: Hashable { + let row: Int + let column: Int + } +} diff --git a/ParkAlong/Services/LocationService.swift b/ParkAlong/Services/LocationService.swift index 5667171..c4ba360 100644 --- a/ParkAlong/Services/LocationService.swift +++ b/ParkAlong/Services/LocationService.swift @@ -1,18 +1,32 @@ @preconcurrency import CoreLocation import Foundation +enum LocationRequestResult: Equatable, Sendable { + case success(Coordinate) + case denied + case restricted + case timedOut + case unavailable + case cancelled +} + @MainActor protocol LocationProviding: AnyObject { var authorizationStatus: CLAuthorizationStatus { get } - func requestCoordinate() async -> Coordinate? + func requestCoordinate() async -> LocationRequestResult } @MainActor final class LocationService: NSObject, LocationProviding, @preconcurrency CLLocationManagerDelegate { private let manager = CLLocationManager() - private var continuation: CheckedContinuation? + private let requestTimeout: Duration + private var continuation: (id: Int, value: CheckedContinuation)? + private var timeoutTask: Task? + private var activeRequestID: Int? + private var nextRequestID = 0 - override init() { + init(requestTimeout: Duration = .seconds(8)) { + self.requestTimeout = requestTimeout super.init() manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters @@ -20,41 +34,126 @@ final class LocationService: NSObject, LocationProviding, @preconcurrency CLLoca var authorizationStatus: CLAuthorizationStatus { manager.authorizationStatus } - func requestCoordinate() async -> Coordinate? { - if manager.authorizationStatus == .denied || manager.authorizationStatus == .restricted { return nil } - return await withCheckedContinuation { continuation in - self.continuation?.resume(returning: nil) - self.continuation = continuation - if manager.authorizationStatus == .notDetermined { manager.requestWhenInUseAuthorization() } - else { manager.requestLocation() } + func requestCoordinate() async -> LocationRequestResult { + switch manager.authorizationStatus { + case .denied: return .denied + case .restricted: return .restricted + default: break + } + + // A second request supersedes the first; that is not evidence that + // location itself is unavailable. + if let activeRequestID { + finish(.cancelled, requestID: activeRequestID) + } + let requestID = nextRequestID + nextRequestID += 1 + activeRequestID = requestID + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + guard activeRequestID == requestID else { + continuation.resume(returning: .cancelled) + return + } + self.continuation = (requestID, continuation) + guard !Task.isCancelled else { + finish(.cancelled, requestID: requestID) + return + } + timeoutTask = Task { [weak self] in + guard let self else { return } + do { + try await Task.sleep(for: self.requestTimeout) + } catch { + return + } + self.finish(.timedOut, requestID: requestID) + } + switch manager.authorizationStatus { + case .notDetermined: + manager.requestWhenInUseAuthorization() + case .authorizedAlways, .authorizedWhenInUse: + manager.requestLocation() + case .denied: + finish(.denied, requestID: requestID) + case .restricted: + finish(.restricted, requestID: requestID) + @unknown default: + finish(.unavailable, requestID: requestID) + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.finish(.cancelled, requestID: requestID) + } } } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + guard continuation != nil else { return } switch manager.authorizationStatus { - case .authorizedAlways, .authorizedWhenInUse: manager.requestLocation() - case .denied, .restricted: - continuation?.resume(returning: nil) - continuation = nil - default: break + case .authorizedAlways, .authorizedWhenInUse: + manager.requestLocation() + case .denied: + finish(.denied) + case .restricted: + finish(.restricted) + default: + break } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - let coordinate = locations.last?.coordinate - continuation?.resume(returning: coordinate.map { Coordinate(latitude: $0.latitude, longitude: $0.longitude) }) - continuation = nil + let now = Date.now + guard let location = locations.last(where: { + $0.horizontalAccuracy >= 0 + && $0.horizontalAccuracy <= 5_000 + && abs($0.timestamp.timeIntervalSince(now)) <= 120 + && CLLocationCoordinate2DIsValid($0.coordinate) + }) else { + finish(.unavailable) + return + } + finish(.success(.init(latitude: location.coordinate.latitude, longitude: location.coordinate.longitude))) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { - continuation?.resume(returning: nil) - continuation = nil + switch manager.authorizationStatus { + case .denied: finish(.denied) + case .restricted: finish(.restricted) + default: finish(.unavailable) + } + } + + private func finish(_ result: LocationRequestResult, requestID: Int? = nil) { + guard let continuation else { return } + if let requestID, continuation.id != requestID { return } + self.continuation = nil + activeRequestID = nil + timeoutTask?.cancel() + timeoutTask = nil + continuation.value.resume(returning: result) } } @MainActor final class FixtureLocationService: LocationProviding { let authorizationStatus: CLAuthorizationStatus - init(denied: Bool) { authorizationStatus = denied ? .denied : .authorizedWhenInUse } - func requestCoordinate() async -> Coordinate? { authorizationStatus == .denied ? nil : .melbourneCBD } + private let result: LocationRequestResult + + init(denied: Bool) { + result = denied ? .denied : .success(.melbourneCBD) + authorizationStatus = denied ? .denied : .authorizedWhenInUse + } + + init(result: LocationRequestResult) { + self.result = result + switch result { + case .success, .timedOut, .unavailable, .cancelled: authorizationStatus = .authorizedWhenInUse + case .denied: authorizationStatus = .denied + case .restricted: authorizationStatus = .restricted + } + } + + func requestCoordinate() async -> LocationRequestResult { result } } diff --git a/ParkAlongTests/ParkingMapViewModelTests.swift b/ParkAlongTests/ParkingMapViewModelTests.swift index 039b656..ae05ac3 100644 --- a/ParkAlongTests/ParkingMapViewModelTests.swift +++ b/ParkAlongTests/ParkingMapViewModelTests.swift @@ -3,6 +3,429 @@ import XCTest @MainActor final class ParkingMapViewModelTests: XCTestCase { + func testStartCentersOnAuthorizedCurrentLocationBeforeFirstRefresh() async { + let current = Coordinate(latitude: -37.736, longitude: 145.001) + let repository = RefreshCountingParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: ImmediateLocationService(coordinate: current), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + + await viewModel.start() + + XCTAssertEqual(viewModel.destination.id, "current") + XCTAssertEqual(viewModel.viewport.center.latitude, current.latitude, accuracy: 0.000_001) + XCTAssertEqual(viewModel.viewport.center.longitude, current.longitude, accuracy: 0.000_001) + let lastViewport = await repository.lastViewport + XCTAssertEqual(lastViewport?.center, viewModel.viewport.center) + } + + func testStartingTwiceDoesNotSnapBackToLocationOrRequestItAgain() async { + let current = Coordinate(latitude: -37.736, longitude: 145.001) + let location = ImmediateLocationService(coordinate: current) + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + await viewModel.start() + let userViewport = ParkingViewport( + south: -36.82, west: 144.20, + north: -36.70, east: 144.36, + zoomLevel: 12 + ) + viewModel.viewport = userViewport + + await viewModel.start() + + XCTAssertEqual(viewModel.viewport, userViewport) + XCTAssertEqual(location.requestCount, 1) + } + + func testUserMapInteractionWinsOverDelayedStartupLocation() async { + let location = ControllableLocationService() + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + let start = Task { await viewModel.start() } + await location.waitUntilRequested() + let userViewport = ParkingViewport( + south: -36.82, west: 144.20, + north: -36.70, east: 144.36, + zoomLevel: 12 + ) + + viewModel.updateViewport(userViewport, interactionEnded: false, userInitiated: true) + await start.value + + XCTAssertEqual(viewModel.destination.id, "map-area") + XCTAssertEqual(viewModel.destination.coordinate, userViewport.center) + XCTAssertEqual(viewModel.viewport, userViewport) + XCTAssertNil(viewModel.mapFocusRequest) + XCTAssertEqual(location.cancellationCount, 1) + } + + func testAutomaticCameraSettleDoesNotRefreshWhileStartupLocationIsPending() async { + let location = ControllableLocationService() + let repository = RefreshCountingParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []), + viewportDebounce: .zero + ) + let originalViewport = viewModel.viewport + let start = Task { await viewModel.start() } + await location.waitUntilRequested() + let automaticViewport = ParkingViewport( + south: -44, west: 141, + north: -34, east: 151, + zoomLevel: 5 + ) + + viewModel.updateViewport(automaticViewport, interactionEnded: true, userInitiated: false) + await Task.yield() + + let refreshCount = await repository.refreshCount + XCTAssertEqual(viewModel.viewport, originalViewport) + XCTAssertEqual(refreshCount, 0) + start.cancel() + await start.value + } + + func testSceneActivationDoesNotRefreshWhileStartupLocationIsPending() async { + let location = ControllableLocationService() + let repository = RefreshCountingParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + let start = Task { await viewModel.start() } + await location.waitUntilRequested() + + await viewModel.refreshAfterActivation() + + let refreshCount = await repository.refreshCount + XCTAssertEqual(refreshCount, 0) + start.cancel() + await start.value + } + + func testCancelledStartupCanRestartWhenMapReappears() async { + let location = ControllableLocationService() + let current = Coordinate(latitude: -37.736, longitude: 145.001) + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + let firstStart = Task { await viewModel.start() } + await location.waitUntilRequested() + + viewModel.cancelOutstandingWork() + await firstStart.value + let secondStart = Task { await viewModel.start() } + await location.waitUntilRequested() + location.complete(.success(current)) + await secondStart.value + + XCTAssertEqual(location.requestCount, 2) + XCTAssertEqual(viewModel.destination.id, "current") + XCTAssertEqual(viewModel.viewport.center, current) + } + + func testDeniedStartupUsesClearMelbourneFallback() async { + let repository = RefreshCountingParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: ResultLocationService(result: .denied), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + + await viewModel.start() + + XCTAssertEqual(viewModel.destination.id, "cbd") + XCTAssertEqual(viewModel.destination.subtitle, "Location permission denied") + XCTAssertTrue(viewModel.notice.localizedCaseInsensitiveContains("Melbourne CBD")) + let requestedViewport = await repository.lastViewport + XCTAssertEqual(requestedViewport?.center, Coordinate.melbourneCBD) + } + + func testRestrictedStartupUsesClearMelbourneFallback() async { + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: ResultLocationService(result: .restricted), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + + await viewModel.start() + + XCTAssertEqual(viewModel.destination.subtitle, "Location access restricted") + XCTAssertEqual(viewModel.viewport.center, Coordinate.melbourneCBD) + } + + func testLocationTimeoutFallsBackWithoutHanging() async { + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: ResultLocationService(result: .timedOut), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + + await viewModel.start() + + XCTAssertEqual(viewModel.destination.subtitle, "Current location timed out") + XCTAssertEqual(viewModel.state, .loaded) + } + + func testUnavailableSimulatorLocationFallsBackWithoutRepeatedSnapping() async { + let location = ResultLocationService(result: .unavailable) + let viewModel = ParkingMapViewModel( + repository: FixtureParkingRepository(mode: .live), + locationService: location, + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + + await viewModel.start() + let userViewport = ParkingViewport(south: -38.2, west: 144.2, north: -38.0, east: 144.4, zoomLevel: 12) + viewModel.viewport = userViewport + await viewModel.start() + + XCTAssertEqual(viewModel.destination.subtitle, "Current location unavailable") + XCTAssertEqual(viewModel.viewport, userViewport) + XCTAssertEqual(location.requestCount, 1) + } + + func testNewRefreshCancelsInFlightRefreshAndOnlyAppliesNewestResult() async { + let repository = ControllableParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + let firstViewport = ParkingViewport(south: -37.84, west: 144.92, north: -37.78, east: 145.00, zoomLevel: 14) + let secondViewport = ParkingViewport(south: -36.82, west: 144.20, north: -36.70, east: 144.36, zoomLevel: 12) + + viewModel.viewport = firstViewport + let firstRefresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(1) + viewModel.viewport = secondViewport + let secondRefresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(2) + await repository.completeLast(with: result(notice: "newest")) + await secondRefresh.value + + let cancellationCount = await repository.cancelledRequestCount + XCTAssertEqual(cancellationCount, 1) + XCTAssertEqual(viewModel.notice, "newest") + firstRefresh.cancel() + await firstRefresh.value + } + + func testCancellingRefreshCallerCancelsOwnedRepositoryWork() async { + let repository = ControllableParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []) + ) + let refresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(1) + + refresh.cancel() + await repository.waitForCancellationCount(1) + await refresh.value + + let cancellationCount = await repository.cancelledRequestCount + XCTAssertEqual(cancellationCount, 1) + } + + func testRefreshKeepsPreviouslyVisibleMarkersWhileNewDataIsLoading() async { + let repository = ControllableParkingRepository() + let staticService = ControllableStaticParkingService() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: staticService + ) + let existing = makeStaticOption(id: "existing") + viewModel.staticOptions = [existing] + + let refresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(1) + + XCTAssertEqual(viewModel.staticOptions.map(\.id), ["existing"]) + XCTAssertEqual(viewModel.state, .loading) + + await repository.completeLast(with: result(notice: "loaded")) + await staticService.completeLast(with: []) + await refresh.value + } + + func testRefreshRebindsSelectedStaticDetailsToFreshOption() async { + let repository = ControllableParkingRepository() + let staticService = ControllableStaticParkingService() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: staticService + ) + let old = makeStaticOption(id: "selected") + var updated = makeStaticOption(id: "selected") + updated = ParkingOption( + id: updated.id, kind: updated.kind, title: "Fresh details", locationLabel: updated.locationLabel, + coordinate: updated.coordinate, availabilityState: updated.availabilityState, + available: updated.available, total: updated.total, restrictionLabel: updated.restrictionLabel, + restrictionWindow: updated.restrictionWindow, activeNow: updated.activeNow, price: updated.price, + provider: updated.provider, sourceTimestamp: updated.sourceTimestamp, walkingMetres: updated.walkingMetres, + prediction: updated.prediction, isBestBet: updated.isBestBet, zoneNumber: updated.zoneNumber, + classification: updated.classification, warningText: updated.warningText, + sourceDatasetAt: updated.sourceDatasetAt, sourceCheckedAt: updated.sourceCheckedAt, + schedule: updated.schedule, clusterCount: updated.clusterCount, clusterViewport: updated.clusterViewport + ) + viewModel.selectedOffStreetOption = old + let refresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(1) + + await repository.completeLast(with: result(notice: "fresh")) + await staticService.completeLast(with: [updated]) + await refresh.value + + XCTAssertEqual(viewModel.selectedOffStreetOption?.title, "Fresh details") + } + + func testPartialFailureStillRebindsSelectedStaticDetails() async { + let repository = ControllableParkingRepository() + let staticService = ControllableStaticParkingService() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: staticService + ) + let old = makeStaticOption(id: "selected") + let updated = ParkingOption( + id: old.id, kind: old.kind, title: "Fresh fallback details", locationLabel: old.locationLabel, + coordinate: old.coordinate, availabilityState: old.availabilityState, available: old.available, + total: old.total, restrictionLabel: old.restrictionLabel, restrictionWindow: old.restrictionWindow, + activeNow: old.activeNow, price: old.price, provider: old.provider, + sourceTimestamp: old.sourceTimestamp, walkingMetres: old.walkingMetres, prediction: old.prediction, + isBestBet: old.isBestBet, zoneNumber: old.zoneNumber, classification: old.classification, + warningText: old.warningText, sourceDatasetAt: old.sourceDatasetAt, + sourceCheckedAt: old.sourceCheckedAt, schedule: old.schedule, + clusterCount: old.clusterCount, clusterViewport: old.clusterViewport + ) + viewModel.selectedOffStreetOption = old + let refresh = Task { await viewModel.refresh(force: false) } + await repository.waitForStartedRequestCount(1) + + await repository.failLast() + await staticService.completeLast(with: [updated]) + await refresh.value + + XCTAssertEqual(viewModel.selectedOffStreetOption?.title, "Fresh fallback details") + XCTAssertEqual(viewModel.state, .loaded) + } + + func testRapidViewportUpdatesDebounceToOnlyTheNewestViewport() async { + let repository = ControllableParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []), + viewportDebounce: .zero + ) + let first = ParkingViewport(south: -37.9, west: 144.8, north: -37.7, east: 145.0, zoomLevel: 12) + let newest = ParkingViewport(south: -36.82, west: 144.20, north: -36.70, east: 144.36, zoomLevel: 13) + + viewModel.updateViewport(first, interactionEnded: true) + viewModel.updateViewport(newest, interactionEnded: true) + await repository.waitForStartedRequestCount(1) + + let lastStartedViewport = await repository.lastStartedViewport + let totalStartedCount = await repository.totalStartedCount + XCTAssertEqual(lastStartedViewport, newest) + XCTAssertEqual(totalStartedCount, 1) + await repository.completeLast(with: result(notice: "newest")) + } + + func testImmaterialCameraJitterDoesNotStartAViewportRefresh() async { + let repository = ControllableParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []), + viewportDebounce: .zero + ) + let current = viewModel.viewport + let jitter = ParkingViewport( + south: current.south + current.latitudeSpan * 0.01, + west: current.west + current.longitudeSpan * 0.01, + north: current.north + current.latitudeSpan * 0.01, + east: current.east + current.longitudeSpan * 0.01, + zoomLevel: current.zoomLevel + ) + + viewModel.updateViewport(jitter, interactionEnded: true) + await Task.yield() + + let totalStartedCount = await repository.totalStartedCount + XCTAssertEqual(totalStartedCount, 0) + } + func testChoosingDestinationRefreshesOneMapSizedViewport() async { let repository = RefreshCountingParkingRepository() let viewModel = ParkingMapViewModel( @@ -121,6 +544,192 @@ final class ParkingMapViewModelTests: XCTestCase { XCTAssertEqual(viewModel.mapFocusRequest, cluster.clusterViewport) XCTAssertEqual(viewModel.viewport, cluster.clusterViewport) } + + private func result(notice: String) -> ParkingRepositoryResult { + .init(zones: [], mode: .live, checkedAt: Date(timeIntervalSince1970: 1_777_000_000), notice: notice) + } + + private func makeStaticOption(id: String) -> ParkingOption { + .init( + id: id, kind: .offStreet, title: id, locationLabel: "Fixture", coordinate: .melbourneCBD, + availabilityState: .unknown, available: nil, total: nil, + restrictionLabel: "Fixture", restrictionWindow: "Fixture", activeNow: true, + price: .init(primaryText: "Fixture", detail: "Fixture", provider: "Fixture", actionLabel: nil, actionURL: nil), + provider: "Fixture", sourceTimestamp: nil, walkingMetres: 0, prediction: nil, + isBestBet: false, zoneNumber: nil, classification: .staticOnly, + warningText: "Not live", sourceDatasetAt: nil, sourceCheckedAt: nil, + schedule: [], clusterCount: nil, clusterViewport: nil + ) + } +} + +@MainActor +private final class ImmediateLocationService: LocationProviding { + let authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse + let coordinate: Coordinate? + private(set) var requestCount = 0 + + init(coordinate: Coordinate?) { + self.coordinate = coordinate + } + + func requestCoordinate() async -> LocationRequestResult { + requestCount += 1 + return coordinate.map(LocationRequestResult.success) ?? .unavailable + } +} + +@MainActor +private final class ResultLocationService: LocationProviding { + let authorizationStatus: CLAuthorizationStatus + let result: LocationRequestResult + private(set) var requestCount = 0 + + init(result: LocationRequestResult) { + self.result = result + switch result { + case .success: authorizationStatus = .authorizedWhenInUse + case .denied: authorizationStatus = .denied + case .restricted: authorizationStatus = .restricted + case .timedOut, .unavailable, .cancelled: authorizationStatus = .authorizedWhenInUse + } + } + + func requestCoordinate() async -> LocationRequestResult { + requestCount += 1 + return result + } +} + +@MainActor +private final class ControllableLocationService: LocationProviding { + let authorizationStatus: CLAuthorizationStatus = .authorizedWhenInUse + private var resultContinuation: CheckedContinuation? + private var requestWaiters: [CheckedContinuation] = [] + private(set) var requestCount = 0 + private(set) var cancellationCount = 0 + + func requestCoordinate() async -> LocationRequestResult { + requestCount += 1 + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + resultContinuation = continuation + let waiters = requestWaiters + requestWaiters.removeAll() + waiters.forEach { $0.resume() } + if Task.isCancelled { + finish(.cancelled) + } + } + } onCancel: { + Task { @MainActor [weak self] in + guard let self else { return } + cancellationCount += 1 + finish(.cancelled) + } + } + } + + func waitUntilRequested() async { + guard resultContinuation == nil else { return } + await withCheckedContinuation { requestWaiters.append($0) } + } + + func complete(_ result: LocationRequestResult) { + finish(result) + } + + private func finish(_ result: LocationRequestResult) { + let continuation = resultContinuation + resultContinuation = nil + continuation?.resume(returning: result) + } +} + +private actor ControllableParkingRepository: ParkingRepositoryProviding { + private struct Pending { + let id: Int + let continuation: CheckedContinuation + } + + private var nextID = 0 + private var totalStartedRequestCount = 0 + private var pending: [Pending] = [] + private var startedWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private var cancellationWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + private(set) var cancelledRequestCount = 0 + private(set) var lastStartedViewport: ParkingViewport? + + var totalStartedCount: Int { totalStartedRequestCount } + + func refresh(viewport: ParkingViewport, plan: ParkingPlan, now: Date, force: Bool) async throws -> ParkingRepositoryResult { + let id = nextID + nextID += 1 + totalStartedRequestCount += 1 + lastStartedViewport = viewport + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + pending.append(Pending(id: id, continuation: continuation)) + resumeStartedWaiters() + } + } onCancel: { + Task { await self.cancel(id: id) } + } + } + + func vacantBays(zoneNumber: Int, now: Date) async throws -> [Coordinate] { [] } + + func waitForStartedRequestCount(_ count: Int) async { + guard totalStartedRequestCount < count else { return } + await withCheckedContinuation { continuation in + startedWaiters.append((count, continuation)) + } + } + + func waitForCancellationCount(_ count: Int) async { + guard cancelledRequestCount < count else { return } + await withCheckedContinuation { continuation in + cancellationWaiters.append((count, continuation)) + } + } + + func completeLast(with result: ParkingRepositoryResult) { + guard let request = pending.popLast() else { return } + request.continuation.resume(returning: result) + } + + func failLast() { + guard let request = pending.popLast() else { return } + request.continuation.resume(throwing: URLError(.cannotConnectToHost)) + } + + private func cancel(id: Int) { + guard let index = pending.firstIndex(where: { $0.id == id }) else { return } + let request = pending.remove(at: index) + cancelledRequestCount += 1 + request.continuation.resume(throwing: CancellationError()) + let ready = cancellationWaiters.filter { cancelledRequestCount >= $0.count } + cancellationWaiters.removeAll { cancelledRequestCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } + + private func resumeStartedWaiters() { + let ready = startedWaiters.filter { totalStartedRequestCount >= $0.count } + startedWaiters.removeAll { totalStartedRequestCount >= $0.count } + ready.forEach { $0.continuation.resume() } + } +} + +private actor ControllableStaticParkingService: StaticParkingProviding { + private var pending: [CheckedContinuation<[ParkingOption], Never>] = [] + + func options(in viewport: ParkingViewport, plan: ParkingPlan) async -> [ParkingOption] { + await withCheckedContinuation { pending.append($0) } + } + + func completeLast(with options: [ParkingOption]) { + pending.popLast()?.resume(returning: options) + } } private actor RefreshCountingParkingRepository: ParkingRepositoryProviding { diff --git a/ParkAlongTests/ParkingMarkerSelectorTests.swift b/ParkAlongTests/ParkingMarkerSelectorTests.swift new file mode 100644 index 0000000..dc7411c --- /dev/null +++ b/ParkAlongTests/ParkingMarkerSelectorTests.swift @@ -0,0 +1,116 @@ +import XCTest +@testable import ParkAlong + +final class ParkingMarkerSelectorTests: XCTestCase { + private let viewport = ParkingViewport( + south: -37.84, west: 144.92, + north: -37.78, east: 145.00, + zoomLevel: 14 + ) + + func testDenseCBDKeepsEveryUsefulMarkerBeforeLocationOnlyWarnings() { + let live = (0..<12).map { index in + option(id: "live-\(index)", classification: .verifiedLive, available: index + 1, row: index / 4, column: index % 4) + } + let predicted = (0..<4).map { index in + option(id: "predicted-\(index)", classification: .predicted, available: index + 1, row: index, column: 5) + } + let warnings = (0..<120).map { index in + option(id: "warning-\(index)", classification: .staticOnly, available: nil, row: index / 12, column: index % 12) + } + + let selected = ParkingMarkerSelector.select( + options: warnings + predicted + live, + selectedID: nil, + viewport: viewport + ) + + XCTAssertTrue(Set(live.map(\.id)).isSubset(of: Set(selected.map(\.id)))) + XCTAssertTrue(Set(predicted.map(\.id)).isSubset(of: Set(selected.map(\.id)))) + XCTAssertLessThanOrEqual( + selected.filter { $0.classification == .staticOnly || $0.classification == .staleHistorical }.count, + 4 + ) + XCTAssertLessThanOrEqual(selected.count, ParkingMarkerSelector.markerBudget(for: viewport.zoomLevel)) + } + + func testSelectedWarningRemainsVisibleWhenItWouldOtherwiseLoseDensitySelection() { + let warnings = (0..<100).map { index in + option(id: "warning-\(index)", classification: .staticOnly, available: nil, row: 2, column: 2) + } + + let selected = ParkingMarkerSelector.select( + options: warnings, + selectedID: "warning-99", + viewport: viewport + ) + + XCTAssertTrue(selected.contains(where: { $0.id == "warning-99" })) + } + + func testDensitySelectionIsDeterministicAcrossInputOrdering() { + let options = (0..<80).map { index in + option(id: "warning-\(index)", classification: .staticOnly, available: nil, row: index / 10, column: index % 10) + } + + let forward = ParkingMarkerSelector.select(options: options, selectedID: nil, viewport: viewport) + let reverse = ParkingMarkerSelector.select(options: options.reversed(), selectedID: nil, viewport: viewport) + + XCTAssertEqual(forward.map(\.id), reverse.map(\.id)) + } + + func testSuburbanViewShowsUsefulLocationCoverageWithoutFillingTheMapWithWarnings() { + let warnings = (0..<90).map { index in + option(id: "suburban-\(index)", classification: .staticOnly, available: nil, row: index / 10, column: index % 10) + } + let suburban = ParkingViewport( + south: -37.95, west: 144.75, + north: -37.75, east: 145.05, + zoomLevel: 12 + ) + + let selected = ParkingMarkerSelector.select(options: warnings, selectedID: nil, viewport: suburban) + + XCTAssertGreaterThan(selected.count, 8) + XCTAssertLessThanOrEqual(selected.count, 12) + XCTAssertEqual(Set(selected.map(\.id)).count, selected.count) + } + + private func option( + id: String, + classification: ParkingDataClassification, + available: Int?, + row: Int, + column: Int + ) -> ParkingOption { + let latitude = viewport.south + (Double(row) + 0.5) * viewport.latitudeSpan / 12 + let longitude = viewport.west + (Double(column) + 0.5) * viewport.longitudeSpan / 12 + return ParkingOption( + id: id, + kind: .offStreet, + title: id, + locationLabel: "Fixture", + coordinate: .init(latitude: latitude, longitude: longitude), + availabilityState: available.map { $0 > 0 ? .available : .occupied } ?? .unknown, + available: available, + total: available.map { $0 + 5 }, + restrictionLabel: "Fixture", + restrictionWindow: "Fixture", + activeNow: true, + price: .init(primaryText: "Fixture", detail: "Fixture", provider: "Fixture", actionLabel: nil, actionURL: nil), + provider: "Fixture", + sourceTimestamp: classification == .verifiedLive ? Date(timeIntervalSince1970: 1_777_000_000) : nil, + walkingMetres: Double(row * 100 + column), + prediction: nil, + isBestBet: id == "live-0", + zoneNumber: nil, + classification: classification, + warningText: classification.needsWarning ? "Not live" : nil, + sourceDatasetAt: nil, + sourceCheckedAt: nil, + schedule: [], + clusterCount: nil, + clusterViewport: nil + ) + } +} diff --git a/ParkAlongTests/RemoteParkingRepositoryTests.swift b/ParkAlongTests/RemoteParkingRepositoryTests.swift index e48b21d..4986aac 100644 --- a/ParkAlongTests/RemoteParkingRepositoryTests.swift +++ b/ParkAlongTests/RemoteParkingRepositoryTests.swift @@ -115,14 +115,32 @@ final class RemoteParkingRepositoryTests: XCTestCase { XCTAssertEqual(options.map(\.id), ["static-bundled"]) } + func testStaticRepositoryDoesNotHideRemoteRefreshBehindItsViewportCache() async { + let first = location(id: "changing", checkedAt: arrival, capacity: 10) + let second = location(id: "changing", checkedAt: arrival.addingTimeInterval(60), capacity: 20) + let remote = SequenceRemoteParkingProvider(responses: [[first], [second]]) + let repository = StaticParkingRepository(locations: [], remote: remote) + let viewport = ParkingViewport(south: -38, west: 144, north: -37, east: 145, zoomLevel: 14) + let plan = ParkingPlan(arrival: arrival, durationMinutes: 60) + + let firstOptions = await repository.options(in: viewport, plan: plan) + let secondOptions = await repository.options(in: viewport, plan: plan) + let requestCount = await remote.requestCount + + XCTAssertEqual(firstOptions.first?.total, 10) + XCTAssertEqual(secondOptions.first?.total, 20) + XCTAssertEqual(requestCount, 2) + } + private func location( id: String, coordinate: Coordinate = .melbourneCBD, - checkedAt: Date + checkedAt: Date, + capacity: Int = 10 ) -> StaticParkingLocation { .init( id: id, name: id, municipality: "Fixture", coordinate: coordinate, - kind: .offStreet, archetype: .general, capacity: 10, accessibleSpaces: nil, + kind: .offStreet, archetype: .general, capacity: capacity, accessibleSpaces: nil, schedules: [], tariffs: [], source: .init( id: "fixture", name: "Fixture", sourceURL: URL(string: "https://example.com")!, @@ -149,6 +167,20 @@ private actor FailingRemoteParkingProvider: RemoteParkingProviding { } } +private actor SequenceRemoteParkingProvider: RemoteParkingProviding { + private var responses: [[StaticParkingLocation]] + private(set) var requestCount = 0 + + init(responses: [[StaticParkingLocation]]) { + self.responses = responses + } + + func locations(for query: RemoteParkingQuery) async throws -> [StaticParkingLocation] { + requestCount += 1 + return responses.isEmpty ? [] : responses.removeFirst() + } +} + private final class LockedTestClock: @unchecked Sendable { private let lock = NSLock() private var storedValue: Date diff --git a/ParkAlongTests/StaticParkingRepositoryTests.swift b/ParkAlongTests/StaticParkingRepositoryTests.swift index 1b342f7..a5e62f7 100644 --- a/ParkAlongTests/StaticParkingRepositoryTests.swift +++ b/ParkAlongTests/StaticParkingRepositoryTests.swift @@ -56,6 +56,42 @@ final class StaticParkingRepositoryTests: XCTestCase { XCTAssertTrue(locations.contains(where: { $0.id == "bendigo-hargreaves-multistorey" && $0.tariffs.first?.hourlyCents == 240 })) } + func testBundledCatalogDecodeCompletesWithinInteractiveStartupBudget() throws { + let clock = ContinuousClock() + + let elapsed = try clock.measure { + _ = try BundleDataLoader.load([StaticParkingLocation].self, named: "victoria_static_parking") + } + + print("PARKALONG_PERF bundled_catalog_decode_seconds=\(elapsed.components.seconds).\(elapsed.components.attoseconds)") + XCTAssertLessThan(elapsed, .seconds(5)) + } + + func testRealCatalogStreetViewportQueryPerformance() async throws { + let locations = try BundleDataLoader.load([StaticParkingLocation].self, named: "victoria_static_parking") + let repository = StaticParkingRepository(locations: locations, resultLimit: 48) + let viewports = (0..<8).map { step in + ParkingViewport( + south: -37.84 + Double(step) * 0.0004, + west: 144.92 + Double(step) * 0.0004, + north: -37.78 + Double(step) * 0.0004, + east: 145.00 + Double(step) * 0.0004, + zoomLevel: 14 + ) + } + let clock = ContinuousClock() + var visibleCounts: [Int] = [] + + let elapsed = await clock.measure { + for viewport in viewports { + visibleCounts.append(await repository.options(in: viewport, plan: plan(.twoHours)).count) + } + } + + print("PARKALONG_PERF eight_street_viewport_queries_seconds=\(elapsed.components.seconds).\(elapsed.components.attoseconds) visible_counts=\(visibleCounts)") + XCTAssertLessThan(elapsed, .seconds(2)) + } + func testBundledForecastValidationDecodesWithMeasuredReleaseEvidence() throws { let records = try BundleDataLoader.load([ForecastValidationRecord].self, named: "historical_validation") @@ -78,6 +114,24 @@ final class StaticParkingRepositoryTests: XCTestCase { XCTAssertEqual(options.map(\.title), ["Lazy catalog"]) } + func testExactViewportAndPlanQueryUsesCacheAndPlanChangeInvalidatesIt() async { + let location = fixture(id: "cache", name: "Cached location", coordinate: .melbourneCBD) + let repository = StaticParkingRepository(locations: [location]) + let queryViewport = viewport(center: .melbourneCBD) + + let first = await repository.options(in: queryViewport, plan: plan(.oneHour)) + let second = await repository.options(in: queryViewport, plan: plan(.oneHour)) + let cachedMetrics = await repository.cacheMetrics() + _ = await repository.options(in: queryViewport, plan: plan(.twoHours)) + let invalidatedMetrics = await repository.cacheMetrics() + + XCTAssertEqual(first, second) + XCTAssertEqual(cachedMetrics, .init(hits: 1, misses: 1, entries: 1)) + XCTAssertEqual(invalidatedMetrics.hits, 1) + XCTAssertEqual(invalidatedMetrics.misses, 2) + XCTAssertEqual(invalidatedMetrics.entries, 1) + } + func testReturnsNearbyStaticLocationWithResolvedRulePriceAndProvenance() async { let location = fixture( id: "ballarat-zone", name: "Sturt Street", coordinate: .init(latitude: -37.562, longitude: 143.858), diff --git a/ParkAlongTests/StaticParkingSpatialIndexTests.swift b/ParkAlongTests/StaticParkingSpatialIndexTests.swift new file mode 100644 index 0000000..15f1305 --- /dev/null +++ b/ParkAlongTests/StaticParkingSpatialIndexTests.swift @@ -0,0 +1,92 @@ +import XCTest +@testable import ParkAlong + +final class StaticParkingSpatialIndexTests: XCTestCase { + func testQueryReturnsExactlyLocationsIntersectingViewportAcrossCellEdges() { + let locations = [ + fixture(id: "south-west", latitude: -37.8201, longitude: 144.9499), + fixture(id: "inside", latitude: -37.8100, longitude: 144.9600), + fixture(id: "north-east", latitude: -37.7999, longitude: 144.9701), + fixture(id: "outside-north", latitude: -37.7900, longitude: 144.9600), + fixture(id: "outside-east", latitude: -37.8100, longitude: 145.0000), + ] + let index = StaticParkingSpatialIndex(locations: locations, cellSizeDegrees: 0.01) + let viewport = ParkingViewport( + south: -37.825, west: 144.945, + north: -37.795, east: 144.975, + zoomLevel: 14 + ) + + let matches = index.locations(in: viewport) + + XCTAssertEqual(Set(matches.map(\.id)), ["south-west", "inside", "north-east"]) + } + + func testQueryDoesNotDuplicateLocationsWhenViewportCrossesManyCells() { + let locations = (0..<50).map { index in + fixture( + id: "location-\(index)", + latitude: -37.90 + Double(index % 10) * 0.02, + longitude: 144.85 + Double(index / 10) * 0.03 + ) + } + let index = StaticParkingSpatialIndex(locations: locations, cellSizeDegrees: 0.01) + let viewport = ParkingViewport( + south: -38.0, west: 144.7, + north: -37.6, east: 145.1, + zoomLevel: 10 + ) + + let matches = index.locations(in: viewport) + + XCTAssertEqual(matches.count, locations.count) + XCTAssertEqual(Set(matches.map(\.id)).count, locations.count) + } + + func testQueryChecksOnlyIntersectingCellsForAStreetScaleViewport() { + let locations = (0..<1_000).map { index in + fixture( + id: "statewide-\(index)", + latitude: -39.0 + Double(index / 40) * 0.2, + longitude: 141.0 + Double(index % 40) * 0.2 + ) + } + [fixture(id: "target", latitude: -37.8136, longitude: 144.9631)] + let index = StaticParkingSpatialIndex(locations: locations, cellSizeDegrees: 0.02) + let viewport = ParkingViewport( + south: -37.82, west: 144.95, + north: -37.80, east: 144.98, + zoomLevel: 14 + ) + + let result = index.query(in: viewport) + + XCTAssertEqual(result.locations.map(\.id), ["target"]) + XCTAssertLessThan(result.inspectedLocationCount, locations.count / 20) + } + + private func fixture(id: String, latitude: Double, longitude: Double) -> StaticParkingLocation { + StaticParkingLocation( + id: id, + name: id, + municipality: "Fixture", + coordinate: .init(latitude: latitude, longitude: longitude), + kind: .offStreet, + archetype: .general, + capacity: nil, + accessibleSpaces: nil, + schedules: [], + tariffs: [], + source: .init( + id: "fixture", + name: "Fixture", + sourceURL: URL(string: "https://example.com")!, + licenseName: "Fixture", + licenseURL: nil, + datasetUpdatedAt: nil, + checkedAt: Date(timeIntervalSince1970: 1_777_000_000) + ), + classification: .staticOnly, + predictionEvidence: nil + ) + } +} diff --git a/ParkAlongUITests/ParkAlongUITests.swift b/ParkAlongUITests/ParkAlongUITests.swift index 768c77a..f8ee917 100644 --- a/ParkAlongUITests/ParkAlongUITests.swift +++ b/ParkAlongUITests/ParkAlongUITests.swift @@ -4,7 +4,16 @@ import XCTest final class ParkAlongUITests: XCTestCase { private func launch(_ arguments: [String] = ["-fixture-live"]) -> XCUIApplication { let app = XCUIApplication() - app.launchArguments = ["-ui-testing", "-intercept-navigation"] + arguments + var launchArguments = ["-ui-testing", "-intercept-navigation"] + arguments + if !arguments.contains("-UIPreferredContentSizeCategoryName") { + // The simulator retains this preference between processes. Reset it + // so the dedicated accessibility test cannot leak into later cases. + launchArguments += [ + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryL" + ] + } + app.launchArguments = launchArguments app.launch() return app } @@ -28,6 +37,76 @@ final class ParkAlongUITests: XCTestCase { XCTAssertTrue(app.buttons["best-bet-button"].exists) } + func testAuthorizedStartupCentersOnCurrentLocation() { + let app = launch() + XCTAssertTrue(app.staticTexts["destination-title"].waitForExistence(timeout: 3)) + XCTAssertEqual(app.staticTexts["destination-title"].label, "Current location") + waitForValue("selected", on: app.buttons["current-location-button"]) + } + + func testRestrictedLocationFallsBackWithoutHanging() { + let app = launch(["-fixture-live", "-location-restricted"]) + XCTAssertTrue(app.staticTexts["destination-title"].waitForExistence(timeout: 3)) + XCTAssertEqual(app.staticTexts["destination-title"].label, "Melbourne CBD") + XCTAssertTrue(app.staticTexts["Location access restricted"].exists) + } + + func testLocationTimeoutFallsBackWithoutHanging() { + let app = launch(["-fixture-live", "-location-timeout"]) + XCTAssertTrue(app.staticTexts["destination-title"].waitForExistence(timeout: 3)) + XCTAssertEqual(app.staticTexts["destination-title"].label, "Melbourne CBD") + XCTAssertTrue(app.staticTexts["Current location timed out"].exists) + } + + func testUnavailableLocationFallsBackWithoutHanging() { + let app = launch(["-fixture-live", "-location-unavailable"]) + XCTAssertTrue(app.staticTexts["destination-title"].waitForExistence(timeout: 3)) + XCTAssertEqual(app.staticTexts["destination-title"].label, "Melbourne CBD") + XCTAssertTrue(app.staticTexts["Current location unavailable"].exists) + } + + func testDenseMarkerMapAcceptsPinchAndRemainsInteractive() { + let app = launch(["-fixture-live", "-fixture-dense"]) + let map = app.maps.firstMatch + XCTAssertTrue(map.waitForExistence(timeout: 3)) + + map.pinch(withScale: 0.55, velocity: -2) + + XCTAssertTrue(map.exists) + XCTAssertTrue(map.isHittable) + let staticMarkers = app.descendants(matching: .any).matching( + NSPredicate(format: "identifier BEGINSWITH %@", "static-pin-") + ) + XCTAssertTrue(staticMarkers.firstMatch.waitForExistence(timeout: 3)) + XCTAssertGreaterThan(staticMarkers.count, 0) + XCTAssertLessThanOrEqual(staticMarkers.count, 48) + XCTAssertTrue(staticMarkers.firstMatch.isHittable) + staticMarkers.firstMatch.tap() + let detailSheet = app.otherElements["zone-detail-sheet"] + XCTAssertTrue(detailSheet.waitForExistence(timeout: 2)) + detailSheet.swipeDown() + XCTAssertFalse(detailSheet.waitForExistence(timeout: 1)) + let twoHours = app.buttons["duration-2h"] + XCTAssertTrue(twoHours.isHittable) + twoHours.tap() + waitForValue("selected", on: twoHours) + } + + func testStayTrackRemainsUsableAtAccessibilityTextSize() { + let app = launch([ + "-fixture-live", + "-UIPreferredContentSizeCategoryName", + "UICTContentSizeCategoryAccessibilityExtraExtraLarge" + ]) + let track = element("stay-duration-track", in: app) + XCTAssertTrue(track.waitForExistence(timeout: 3)) + let fifteenMinutes = app.buttons["duration-15m"] + XCTAssertTrue(fifteenMinutes.waitForExistence(timeout: 2)) + XCTAssertTrue(fifteenMinutes.isHittable) + fifteenMinutes.tap() + waitForValue("selected", on: fifteenMinutes) + } + func testPrimaryMapActionsRemainDiscoverableInAdaptiveChrome() { let app = launch() @@ -170,7 +249,7 @@ final class ParkAlongUITests: XCTestCase { func testLiveFailureKeepsMappedParkingWithWarning() { let app = launch(["-fixture-live-error"]) - let pin = app.buttons["static-pin-static-fixture-ballarat"] + let pin = element("static-pin-static-fixture-ballarat", in: app) XCTAssertTrue(pin.waitForExistence(timeout: 3)) XCTAssertTrue(pin.label.localizedCaseInsensitiveContains("not live")) @@ -185,7 +264,7 @@ final class ParkAlongUITests: XCTestCase { func testStaticLocationPinShowsWarningRuleAndVerifiedPrice() { let app = launch() - let pin = app.buttons["static-pin-static-fixture-ballarat"] + let pin = element("static-pin-static-fixture-ballarat", in: app) XCTAssertTrue(pin.waitForExistence(timeout: 3)) XCTAssertTrue(pin.label.localizedCaseInsensitiveContains("location only")) pin.tap() diff --git a/docs/performance/2026-08-24-map-performance.md b/docs/performance/2026-08-24-map-performance.md new file mode 100644 index 0000000..2be471f --- /dev/null +++ b/docs/performance/2026-08-24-map-performance.md @@ -0,0 +1,34 @@ +# Map performance evidence — 24 August 2026 + +All parking-rule tests and measurements were run with `TZ=Australia/Melbourne` on an iPhone 17 Pro iOS 26.5 simulator using Xcode 27 beta (`27A5237l`). The bundled statewide catalog contains 34,023 records and is 22,546,415 bytes; the historical availability file is 48,303,231 bytes. + +## Baseline + +The baseline full simulator suite passed, but exposed a severe startup cost: + +- 94 unit tests plus 16 UI tests: 173.362 seconds total. +- `testGeneratedVictorianCatalogDecodesFromAppBundle`: 23.659 seconds. +- A focused real-catalog measurement reported `bundled_catalog_decode_seconds=23.25246475`. +- Eight moving Melbourne street-view queries over the decoded catalog took 0.545419042 seconds and repeatedly walked the full catalog. + +The decoder was constructing multiple `ISO8601DateFormatter` objects for every decoded date. With 34,023 records, that formatter setup—not the 22.5 MB file read—was the dominant startup bottleneck. The viewport path then repeatedly filtered the statewide array, while each rendered SwiftUI marker added a 44 by 44 point button hit target over MapKit. + +## Implemented path + +- Reused a value-type ISO-8601 parse strategy instead of constructing formatters per date. +- Built one immutable spatial grid after the lazy catalog load. +- Cached exact viewport/plan results and per-plan parking-rule resolutions with bounded invalidation. +- Debounced settled camera changes by 250 ms, cancelled superseded work, and rejected stale generations. +- Preserved current marker arrays while a replacement refresh is in flight. +- Limited rendered annotations by zoom and truthfulness, retaining live/predicted and selected results before location-only warnings. +- Replaced marker `Button` hit targets with MapKit selection tags so annotations no longer tile the gesture surface. + +## After + +- The identical bundled-catalog measurement completed in 0.523–0.540 seconds (about 43 times faster). +- Eight moving real-catalog viewport queries completed in 0.392–0.414 seconds (about 24–28% faster), with 48 results per street view. +- A spatial-index correctness test confirms a street-scale query inspects only intersecting cells rather than all 34,023 records. +- Deterministic tests cover cache hits/invalidation, rapid viewport coalescing, cancellation, stale-result protection, and preservation of visible markers during refresh. +- A dense 160-location UI fixture renders no more than the zoom budget and performs a real XCUITest pinch followed immediately by a duration selection. + +The decoder number is the largest practical improvement because it removes roughly 22.7 seconds from the first static-catalog load. Network latency from the City of Melbourne and MapKit remains external and variable; cancellation prevents an old response from replacing a newer viewport but cannot make those services respond faster. diff --git a/docs/superpowers/plans/2026-08-24-map-performance-location-ux.md b/docs/superpowers/plans/2026-08-24-map-performance-location-ux.md new file mode 100644 index 0000000..3122757 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-map-performance-location-ux.md @@ -0,0 +1,145 @@ +# ParkAlong Map Performance, Location, and UX Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make statewide parking discovery fast, cancellable, location-first, visually truthful, and thoroughly testable without weakening the distinction between live, predicted, static, and unknown data. + +**Architecture:** Decode and index the bundled catalog once outside the main actor, query only spatial cells intersecting the padded viewport, and cache bounded query results. Let the main-actor view model own debounce/cancellation/generation state while pure marker and startup-location policies remain independently testable. Keep SwiftUI presentation adaptive: native iOS 26/27 Liquid Glass where available and material/opaque iOS 17 fallbacks. + +**Tech Stack:** Swift 6, SwiftUI, MapKit, Core Location, Observation, OSLog signposts, XCTest/XCUITest, XcodeGen, Python unittest. + +**Spec:** User delegation dated 2026-08-24 in Codex task `01a02e04-207d-7c21-b357-45bf4dcb37d0`. + +## Global Constraints + +- Work directly in `/Users/sid/Documents/Coding/ParkAlong` on the focused `codex/map-performance-location-ux` branch. +- Preserve ParkAlong branding and iOS 17 deployment support. +- Use `project.yml` as the XcodeGen source of truth. +- Use `Australia/Melbourne` for parking-rule and arrival-time behavior. +- Never present static, stale, or uncertain data as live or green availability. +- Delegate a focused SwiftUI duration-control pass to Cursor CLI model `cursor-grok-4.6-high`; Codex reviews and owns integration. +- Do not merge or push `main`. + +--- + +### Task 1: Capture baseline evidence and executable regressions + +**Files:** +- Modify: `ParkAlongTests/StaticParkingRepositoryTests.swift` +- Modify: `ParkAlongTests/ParkingMapViewModelTests.swift` +- Modify: `ParkAlongUITests/ParkAlongUITests.swift` + +**Interfaces:** +- Consumes: current `StaticParkingProviding`, `ParkingRepositoryProviding`, and `LocationProviding` contracts. +- Produces: deterministic failures for repeated full scans, stale viewport replacement, startup location, denied fallback, and duration selection. + +- [ ] Add a real-catalog XCTest that reports initial decode/index/query timing and repeated Melbourne/suburban/statewide query timings. +- [ ] Add controllable actor fakes whose continuations expose when a viewport request starts, cancels, and finishes; assert the newest request alone becomes visible. +- [ ] Add startup UI seams for authorized, denied, timeout, and unavailable location outcomes without sleeps. +- [ ] Run each new test against the baseline and record the expected failure or timing. + +### Task 2: Build the immutable statewide spatial catalog + +**Files:** +- Create: `ParkAlong/Data/StaticParkingSpatialIndex.swift` +- Modify: `ParkAlong/Data/StaticParkingRepository.swift` +- Modify: `ParkAlong/Data/BundleDataLoader.swift` +- Create: `ParkAlong/Support/ParkingPerformance.swift` +- Modify: `ParkAlongTests/StaticParkingRepositoryTests.swift` + +**Interfaces:** +- Produces: `StaticParkingSpatialIndex.init(locations:)`, `locations(in:)`, catalog metrics, pre-normalized search text, and bounded query-cache keys. +- Consumes: `StaticParkingLocation`, `ParkingViewport`, and the existing Melbourne-time rule resolver. + +- [ ] Write failing index-correctness tests comparing indexed results with a hand-derived fixture set, including cell edges and viewport padding. +- [ ] Write a failing cache test proving an equivalent viewport/plan query does not rerun catalog selection. +- [ ] Implement one-time detached decode/index construction and OSLog signposts for decode, index build, spatial lookup, rule resolution, and total static query. +- [ ] Replace per-viewport full-catalog scanning and repeated search normalization with indexed candidates and normalized catalog entries. +- [ ] Add bounded result caching and cancellation checks between expensive stages. +- [ ] Verify exact result correctness and capture real-catalog before/after timings. + +### Task 3: Make viewport loading cancellable and state-preserving + +**Files:** +- Modify: `ParkAlong/Features/Map/ParkingMapViewModel.swift` +- Modify: `ParkAlong/Features/Map/ParkingMapView.swift` +- Modify: `ParkAlongTests/ParkingMapViewModelTests.swift` + +**Interfaces:** +- Produces: one owned debounced viewport task, one owned active refresh task, generation-safe application, and stable visible arrays while loading. +- Consumes: repository/off-street/static async services and `ParkingViewport.materiallyDiffers(from:)`. + +- [ ] Write failing tests for debounce coalescing, in-flight cancellation, stale completion rejection, preserved pins during refresh, and no refresh for immaterial camera jitter. +- [ ] Refactor refresh scheduling so a newer viewport cancels debounce and in-flight work before starting a 250 ms settled query. +- [ ] Keep the last successful visible markers during loading and apply all three result families atomically for the current generation. +- [ ] Add OSLog signposts containing generation, viewport, candidate count, visible count, cache hit, and elapsed time. +- [ ] Verify rapid pan/zoom tests without arbitrary sleeps. + +### Task 4: Implement location-first startup as a finite state machine + +**Files:** +- Modify: `ParkAlong/Services/LocationService.swift` +- Modify: `ParkAlong/App/AppEnvironment.swift` +- Modify: `ParkAlong/Features/Map/ParkingMapViewModel.swift` +- Modify: `ParkAlong/Features/Map/ParkingMapView.swift` +- Modify: `ParkAlongTests/ParkingMapViewModelTests.swift` +- Modify: `ParkAlongUITests/ParkAlongUITests.swift` + +**Interfaces:** +- Produces: `LocationRequestResult` with success, denied, restricted, timed-out, and unavailable cases; one-shot startup centering and explicit fallback notices. +- Consumes: Core Location authorization callbacks and deterministic fixture launch arguments. + +- [ ] Write failing unit tests proving authorized startup centers once before querying, first-run completion centers once, denial falls back, timeout finishes, and later map gestures are never snapped back. +- [ ] Replace the optional-coordinate continuation with a single-resume state machine and bounded timeout/cancellation cleanup. +- [ ] Start location acquisition on launch, refresh around the usable fix, and use Melbourne CBD only after an explicit non-success outcome. +- [ ] Add fixture seams and XCUITests for authorized current location, denied fallback, timeout, and unavailable simulation. + +### Task 5: Establish truthful density-aware marker hierarchy + +**Files:** +- Create: `ParkAlong/Features/Map/ParkingMarkerSelector.swift` +- Modify: `ParkAlong/Features/Map/ParkingMapViewModel.swift` +- Modify: `ParkAlong/Features/Map/ParkingMapView.swift` +- Modify: `ParkAlong/Features/Map/AvailabilityPin.swift` +- Modify: `ParkAlong/Features/Map/MapControls.swift` +- Create: `ParkAlongTests/ParkingMarkerSelectorTests.swift` +- Modify: `ParkAlongTests/ParkingOptionTests.swift` + +**Interfaces:** +- Produces: pure density budgets and stable selection that ranks selected, verified-live, predicted, official static, and generic location-only data in that order while preserving truthful palettes; MapKit-native tagged selection instead of a screen-covering layer of SwiftUI marker buttons. +- Consumes: live zones, static/off-street options, selected option, viewport zoom, and cell occupancy. + +- [ ] Write failing suburban/CBD fixture tests for marker budgets, useful-result priority, stable selection, selected-marker retention, and deterministic density filtering. +- [ ] Implement a grid-aware selector that limits low-value warnings more aggressively when useful live/predicted markers are present. +- [ ] Replace overlapping 44-point annotation `Button` hit targets with MapKit selection tags so pinch and pan gestures retain native multi-touch ownership even in dense areas. +- [ ] Keep live green/orange/red and predicted plum semantics; make location-only markers smaller and visually muted without making them green. +- [ ] Update the legend and accessibility labels to match the truthful hierarchy. + +### Task 6: Replace the stay control with a native adaptive segmented switcher + +**Files:** +- Modify: `ParkAlong/Features/Map/MapControls.swift` +- Modify: `ParkAlong/DesignSystem/AdaptiveChrome.swift` +- Modify: `ParkAlongTests/AdaptiveChromeTests.swift` +- Modify: `ParkAlongUITests/ParkAlongUITests.swift` + +**Interfaces:** +- Produces: horizontally usable native-style selections for `15m`, `1h`, `2h`, `3h`, `4h`, `6h`, and `8h+`, retaining the custom planner. +- Consumes: `ParkingPlan`, `StayDuration`, Dynamic Type, Reduce Motion, Reduce Transparency, and iOS availability. + +- [ ] Run Cursor CLI in the saved checkout with `--model cursor-grok-4.6-high --yolo` and restrict its ownership to the visual duration-control presentation. +- [ ] Review Cursor's diff for native semantics, 44-point targets, horizontal scrolling, Dynamic Type, and graceful iOS 17 material fallback. +- [ ] Integrate/fix presentation while retaining Codex-owned selection wiring and tests. +- [ ] Run duration XCUITests at normal and accessibility text sizes. + +### Task 7: Regenerate, verify, visually inspect, and commit + +**Files:** +- Modify: `project.yml` only if new paths require explicit project configuration. +- Regenerate: `ParkAlong.xcodeproj/project.pbxproj` when project structure changes. + +- [ ] Run `/opt/homebrew/bin/xcodegen generate --spec project.yml` after structural changes. +- [ ] Run Python generator/source tests and compileall if the data pipeline changes. +- [ ] Run all relevant Swift unit tests, full XCUITests, and a clean simulator build on available iOS 26.x runtimes. +- [ ] Interactively run core flows in light/dark appearance and at one accessibility text size; capture screenshots and performance/signpost evidence. +- [ ] Run `git diff --check`, inspect the complete diff, confirm the branch is not `main`, and commit the verified implementation. From 63a19958c87f1e35d0324aa80bf1f365305377f5 Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Mon, 24 Aug 2026 16:59:12 +1000 Subject: [PATCH 2/4] Use native glass duration picker --- ParkAlong/Features/Map/MapControls.swift | 140 +++++------------------ ParkAlongUITests/ParkAlongUITests.swift | 24 +++- 2 files changed, 46 insertions(+), 118 deletions(-) diff --git a/ParkAlong/Features/Map/MapControls.swift b/ParkAlong/Features/Map/MapControls.swift index cdf28d4..9802334 100644 --- a/ParkAlong/Features/Map/MapControls.swift +++ b/ParkAlong/Features/Map/MapControls.swift @@ -144,6 +144,7 @@ struct MapBottomChrome: View { .padding(.vertical, 8) .adaptiveGlassSurface(cornerRadius: 16) .accessibilityLabel(statusText) + .accessibilityIdentifier("availability-status") } } } @@ -228,17 +229,11 @@ struct BestBetButton: View { struct StayDurationBar: View { @Bindable var viewModel: ParkingMapViewModel @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - @Namespace private var staySelection @State private var showingPlanner = false @State private var plannerPrefersEightHours = false - private var trackShape: RoundedRectangle { - RoundedRectangle(cornerRadius: 18, style: .continuous) - } - - private var thumbShape: RoundedRectangle { - RoundedRectangle(cornerRadius: 13, style: .continuous) + private var trackShape: Capsule { + Capsule(style: .continuous) } var body: some View { @@ -252,10 +247,14 @@ struct StayDurationBar: View { } AdaptiveGlassContainer(spacing: 8) { - HStack(alignment: .center, spacing: 8) { + VStack(spacing: 8) { stayTrack - plannerButton - refreshButton + + HStack(spacing: 8) { + Spacer(minLength: 0) + plannerButton + refreshButton + } } .adaptiveControlDock(cornerRadius: 22) } @@ -271,103 +270,23 @@ struct StayDurationBar: View { } private var stayTrack: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 0) { - ForEach(Array(StayTrackItem.all.enumerated()), id: \.element.id) { index, item in - sizedSegment(item, index: index) - } - } - .scrollTargetLayout() - } - .scrollTargetBehavior(.viewAligned) - .scrollBounceBehavior(.basedOnSize) - .onAppear { - if dynamicTypeSize.isAccessibilitySize { - proxy.scrollTo(StayTrackItem.all[0].id, anchor: .leading) - } else { - proxy.scrollTo(selectedItem.id, anchor: .center) - } - } - .onChange(of: selectedItem) { _, item in - if reduceMotion { - proxy.scrollTo(item.id, anchor: .center) - } else { - withAnimation(.snappy(duration: 0.28)) { - proxy.scrollTo(item.id, anchor: .center) - } - } + Picker("Stay duration", selection: durationSelection) { + ForEach(StayTrackItem.all) { item in + Text(chipLabel(for: item, selected: selectedItem == item)) + .tag(item) + .accessibilityLabel(item.accessibilityLabel) + .accessibilityIdentifier(item.identifier) } } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: .infinity, minHeight: 36) + .accessibilityIdentifier("stay-duration-picker") .padding(.horizontal, 5) .padding(.vertical, 4) .adaptiveGlassSurface(shape: trackShape, isInteractive: true) - .frame(maxWidth: .infinity) .accessibilityElement(children: .contain) .accessibilityIdentifier("stay-duration-track") - .accessibilityAdjustableAction { direction in - adjustStay(direction) - } - } - - @ViewBuilder - private func sizedSegment(_ item: StayTrackItem, index: Int) -> some View { - if dynamicTypeSize.isAccessibilitySize { - staySegment(item, index: index) - .fixedSize(horizontal: true, vertical: false) - } else { - staySegment(item, index: index) - .containerRelativeFrame(.horizontal) { length, _ in - max(44, length / CGFloat(StayTrackItem.all.count)) - } - } - } - - private func staySegment(_ item: StayTrackItem, index: Int) -> some View { - let selected = selectedItem == item - return Button { - select(item) - } label: { - Text(chipLabel(for: item, selected: selected)) - .font(.footnote.weight(selected ? .semibold : .medium)) - .monospacedDigit() - .foregroundStyle(selected ? Color.white : Color.primary) - .lineLimit(1) - .minimumScaleFactor(0.7) - .padding(.horizontal, dynamicTypeSize.isAccessibilitySize ? 12 : 4) - .frame(minWidth: 44, maxWidth: .infinity, minHeight: 44) - .background { - if selected { - if reduceMotion { - thumbShape.fill(Color.accentColor) - } else { - thumbShape - .fill(Color.accentColor) - .matchedGeometryEffect(id: "stay-selection", in: staySelection) - } - } - } - .overlay(alignment: .leading) { - if showsDivider(before: index) { - Capsule() - .fill(.primary.opacity(0.16)) - .frame(width: 1, height: 16) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .id(item.id) - .accessibilityLabel(item.accessibilityLabel) - .accessibilityValue(selected ? "selected" : "not selected") - .accessibilityIdentifier(item.identifier) - .accessibilityAddTraits(selected ? [.isSelected] : []) - } - - private func showsDivider(before index: Int) -> Bool { - guard index > 0 else { return false } - let items = StayTrackItem.all - return selectedItem != items[index] && selectedItem != items[index - 1] } private var plannerButton: some View { @@ -413,6 +332,13 @@ struct StayDurationBar: View { StayTrackItem.matching(durationMinutes: viewModel.plan.durationMinutes) } + private var durationSelection: Binding { + Binding( + get: { selectedItem }, + set: { select($0) } + ) + } + private func chipLabel(for item: StayTrackItem, selected: Bool) -> String { if item == .extended, selected, !StayTrackItem.presetMinutes.contains(viewModel.plan.durationMinutes) { return StayPlanFormatting.compactDuration(viewModel.plan.durationMinutes) @@ -436,18 +362,6 @@ struct StayDurationBar: View { } } - private func adjustStay(_ direction: AccessibilityAdjustmentDirection) { - let items = StayTrackItem.presets + [.extended] - guard let current = items.firstIndex(of: selectedItem) else { return } - let next: Int - switch direction { - case .increment: next = min(items.count - 1, current + 1) - case .decrement: next = max(0, current - 1) - @unknown default: return - } - guard next != current else { return } - select(items[next]) - } } private enum StayTrackItem: Hashable, Identifiable { diff --git a/ParkAlongUITests/ParkAlongUITests.swift b/ParkAlongUITests/ParkAlongUITests.swift index f8ee917..93a249e 100644 --- a/ParkAlongUITests/ParkAlongUITests.swift +++ b/ParkAlongUITests/ParkAlongUITests.swift @@ -19,10 +19,10 @@ final class ParkAlongUITests: XCTestCase { } private func waitForValue(_ value: String, on element: XCUIElement, timeout: TimeInterval = 2) { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "value == %@", value), - object: element - ) + let predicate = NSPredicate { _, _ in + element.value as? String == value || (value == "selected" && element.isSelected) + } + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: timeout), .completed) } @@ -107,6 +107,14 @@ final class ParkAlongUITests: XCTestCase { waitForValue("selected", on: fifteenMinutes) } + func testStayDurationUsesNativeSegmentedControl() { + let app = launch() + let picker = app.segmentedControls["stay-duration-picker"] + + XCTAssertTrue(picker.waitForExistence(timeout: 3)) + XCTAssertEqual(picker.buttons.count, 7) + } + func testPrimaryMapActionsRemainDiscoverableInAdaptiveChrome() { let app = launch() @@ -204,9 +212,15 @@ final class ParkAlongUITests: XCTestCase { let app = launch() let fourHours = app.buttons["duration-4h"] XCTAssertTrue(fourHours.waitForExistence(timeout: 2)) + XCTAssertTrue(fourHours.isHittable) fourHours.tap() - waitForValue("selected", on: fourHours) + let fourHourStatus = app.staticTexts["availability-status"] + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS[c] %@", "4-hour stay"), + object: fourHourStatus + ) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) XCTAssertFalse(app.buttons["duration-more"].exists) } From 27ab3b49e565d98e4af4c48d997df7f4cc58004f Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:25:23 +1000 Subject: [PATCH 3/4] Improve duration taps and map prefetching --- ParkAlong/Features/Map/MapControls.swift | 21 ++++---- .../Features/Map/ParkingMapViewModel.swift | 3 +- ParkAlongTests/ParkingMapViewModelTests.swift | 52 +++++++++++++++++-- ParkAlongUITests/ParkAlongUITests.swift | 43 ++++++++++++--- 4 files changed, 96 insertions(+), 23 deletions(-) diff --git a/ParkAlong/Features/Map/MapControls.swift b/ParkAlong/Features/Map/MapControls.swift index 9802334..d33244c 100644 --- a/ParkAlong/Features/Map/MapControls.swift +++ b/ParkAlong/Features/Map/MapControls.swift @@ -230,7 +230,6 @@ struct StayDurationBar: View { @Bindable var viewModel: ParkingMapViewModel @Environment(\.accessibilityReduceMotion) private var reduceMotion @State private var showingPlanner = false - @State private var plannerPrefersEightHours = false private var trackShape: Capsule { Capsule(style: .continuous) @@ -262,7 +261,7 @@ struct StayDurationBar: View { .animation(reduceMotion ? nil : .snappy(duration: 0.22), value: viewModel.plan) .sensoryFeedback(.selection, trigger: viewModel.plan.durationMinutes) .sheet(isPresented: $showingPlanner) { - ArrivalStayPlannerView(viewModel: viewModel, preferEightHourDefault: plannerPrefersEightHours) + ArrivalStayPlannerView(viewModel: viewModel, preferEightHourDefault: false) .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .presentationContentInteraction(.resizes) @@ -279,11 +278,11 @@ struct StayDurationBar: View { } } .pickerStyle(.segmented) + .controlSize(.extraLarge) .labelsHidden() - .frame(maxWidth: .infinity, minHeight: 36) + .frame(maxWidth: .infinity, minHeight: 44) .accessibilityIdentifier("stay-duration-picker") - .padding(.horizontal, 5) - .padding(.vertical, 4) + .padding(2) .adaptiveGlassSurface(shape: trackShape, isInteractive: true) .accessibilityElement(children: .contain) .accessibilityIdentifier("stay-duration-track") @@ -291,7 +290,6 @@ struct StayDurationBar: View { private var plannerButton: some View { Button { - plannerPrefersEightHours = false showingPlanner = true } label: { Image(systemName: "calendar.badge.clock") @@ -357,8 +355,13 @@ struct StayDurationBar: View { ) ) case .extended: - plannerPrefersEightHours = selectedItem != .extended - showingPlanner = true + viewModel.applyPlan( + ParkingPlan( + arrival: viewModel.plan.arrival, + duration: .eightHours, + isPublicHoliday: viewModel.plan.isPublicHoliday + ) + ) } } @@ -402,7 +405,7 @@ private enum StayTrackItem: Hashable, Identifiable { case .minutes(180): "3 hours" case .minutes(240): "4 hours" case .minutes(360): "6 hours" - case .extended: "8 hours or custom stay, opens arrival and stay planner" + case .extended: "8 hours" default: "Stay duration" } } diff --git a/ParkAlong/Features/Map/ParkingMapViewModel.swift b/ParkAlong/Features/Map/ParkingMapViewModel.swift index fac4e33..9ff1480 100644 --- a/ParkAlong/Features/Map/ParkingMapViewModel.swift +++ b/ParkAlong/Features/Map/ParkingMapViewModel.swift @@ -19,6 +19,7 @@ enum ParkingSearchState: Equatable { @Observable @MainActor final class ParkingMapViewModel { + private static let viewportQueryPaddingFraction = 0.5 private let repository: any ParkingRepositoryProviding private let locationService: any LocationProviding private let destinationSearch: any DestinationSearching @@ -116,7 +117,7 @@ final class ParkingMapViewModel { let generation = refreshGeneration let plan = self.plan let now = Date.now - let viewport = self.viewport + let viewport = self.viewport.padded(by: Self.viewportQueryPaddingFraction) state = .loading let task = Task { [weak self] in guard let self else { return } diff --git a/ParkAlongTests/ParkingMapViewModelTests.swift b/ParkAlongTests/ParkingMapViewModelTests.swift index ae05ac3..e81b6ea 100644 --- a/ParkAlongTests/ParkingMapViewModelTests.swift +++ b/ParkAlongTests/ParkingMapViewModelTests.swift @@ -394,11 +394,45 @@ final class ParkingMapViewModelTests: XCTestCase { let lastStartedViewport = await repository.lastStartedViewport let totalStartedCount = await repository.totalStartedCount - XCTAssertEqual(lastStartedViewport, newest) + XCTAssertEqual(lastStartedViewport!.south, -36.88, accuracy: 0.000_001) + XCTAssertEqual(lastStartedViewport!.west, 144.12, accuracy: 0.000_001) + XCTAssertEqual(lastStartedViewport!.north, -36.64, accuracy: 0.000_001) + XCTAssertEqual(lastStartedViewport!.east, 144.44, accuracy: 0.000_001) + XCTAssertEqual(lastStartedViewport!.zoomLevel, 13) XCTAssertEqual(totalStartedCount, 1) await repository.completeLast(with: result(notice: "newest")) } + func testViewportRefreshQueriesBeyondEveryVisibleEdge() async { + let repository = ControllableParkingRepository() + let viewModel = ParkingMapViewModel( + repository: repository, + locationService: FixtureLocationService(denied: true), + destinationSearch: FixtureDestinationSearchService(), + navigator: AppleMapsNavigator(intercept: true), + offStreetService: FixtureOffStreetParkingService(includeResult: false), + staticParkingService: StaticParkingRepository(locations: []), + viewportDebounce: .zero + ) + let visible = ParkingViewport( + south: -37.84, west: 144.92, + north: -37.78, east: 145.00, + zoomLevel: 14 + ) + + viewModel.updateViewport(visible, interactionEnded: true) + await repository.waitForStartedRequestCount(1) + + let queried = await repository.lastStartedViewport + XCTAssertNotNil(queried) + XCTAssertLessThan(queried!.south, visible.south) + XCTAssertLessThan(queried!.west, visible.west) + XCTAssertGreaterThan(queried!.north, visible.north) + XCTAssertGreaterThan(queried!.east, visible.east) + XCTAssertEqual(queried!.zoomLevel, visible.zoomLevel) + await repository.completeLast(with: result(notice: "prefetched")) + } + func testImmaterialCameraJitterDoesNotStartAViewportRefresh() async { let repository = ControllableParkingRepository() let viewModel = ParkingMapViewModel( @@ -426,7 +460,7 @@ final class ParkingMapViewModelTests: XCTestCase { XCTAssertEqual(totalStartedCount, 0) } - func testChoosingDestinationRefreshesOneMapSizedViewport() async { + func testChoosingDestinationRefreshesOneBufferedMapViewport() async { let repository = RefreshCountingParkingRepository() let viewModel = ParkingMapViewModel( repository: repository, @@ -446,7 +480,11 @@ final class ParkingMapViewModelTests: XCTestCase { let refreshCount = await repository.refreshCount let lastViewport = await repository.lastViewport XCTAssertEqual(refreshCount, 1) - XCTAssertEqual(lastViewport, viewModel.viewport) + XCTAssertLessThan(lastViewport!.south, viewModel.viewport.south) + XCTAssertLessThan(lastViewport!.west, viewModel.viewport.west) + XCTAssertGreaterThan(lastViewport!.north, viewModel.viewport.north) + XCTAssertGreaterThan(lastViewport!.east, viewModel.viewport.east) + XCTAssertEqual(lastViewport!.zoomLevel, viewModel.viewport.zoomLevel) XCTAssertLessThan(viewModel.viewport.latitudeSpan, 0.01) XCTAssertLessThan(viewModel.viewport.longitudeSpan, 0.015) XCTAssertEqual(viewModel.mapFocusRequest, viewModel.viewport) @@ -494,7 +532,7 @@ final class ParkingMapViewModelTests: XCTestCase { XCTAssertEqual(viewModel.staticOptions.first?.classification, .staticOnly) } - func testRefreshPassesTheExactVisibleViewportToOffStreetDiscovery() async { + func testRefreshPassesTheBufferedViewportToOffStreetDiscovery() async { let offStreet = ViewportCapturingOffStreetService() let viewModel = ParkingMapViewModel( repository: FixtureParkingRepository(mode: .live), @@ -512,7 +550,11 @@ final class ParkingMapViewModelTests: XCTestCase { await viewModel.refresh(force: true) let requestedViewport = await offStreet.lastViewport - XCTAssertEqual(requestedViewport, bendigoViewport) + XCTAssertEqual(requestedViewport!.south, -36.85, accuracy: 0.000_001) + XCTAssertEqual(requestedViewport!.west, 144.12, accuracy: 0.000_001) + XCTAssertEqual(requestedViewport!.north, -36.65, accuracy: 0.000_001) + XCTAssertEqual(requestedViewport!.east, 144.44, accuracy: 0.000_001) + XCTAssertEqual(requestedViewport!.zoomLevel, 12) } func testSelectingClusterRequestsZoomInsteadOfOpeningParkingDetails() async { diff --git a/ParkAlongUITests/ParkAlongUITests.swift b/ParkAlongUITests/ParkAlongUITests.swift index 93a249e..b30d290 100644 --- a/ParkAlongUITests/ParkAlongUITests.swift +++ b/ParkAlongUITests/ParkAlongUITests.swift @@ -115,6 +115,30 @@ final class ParkAlongUITests: XCTestCase { XCTAssertEqual(picker.buttons.count, 7) } + func testStayDurationSegmentsHaveFullHeightTargetsAndAcceptAdjacentSelections() { + let app = launch() + XCTAssertTrue(app.segmentedControls["stay-duration-picker"].waitForExistence(timeout: 3)) + + for identifier in ["duration-3h", "duration-4h", "duration-6h"] { + XCTAssertGreaterThanOrEqual(app.buttons[identifier].frame.height, 44, identifier) + } + + for (identifier, statusText) in [ + ("duration-3h", "3-hour stay"), + ("duration-4h", "4-hour stay"), + ("duration-6h", "6-hour stay") + ] { + let segment = app.buttons[identifier] + XCTAssertTrue(segment.isHittable, identifier) + segment.tap() + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS[c] %@", statusText), + object: app.staticTexts["availability-status"] + ) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed, identifier) + } + } + func testPrimaryMapActionsRemainDiscoverableInAdaptiveChrome() { let app = launch() @@ -126,7 +150,7 @@ final class ParkAlongUITests: XCTestCase { XCTAssertTrue(app.buttons["refresh-availability-button"].exists) } - func testStayTrackExposesEveryPresetAndOpensPlanner() { + func testStayTrackExposesEveryPresetAndEightHoursDoesNotOpenPlanner() { let app = launch() XCTAssertTrue(element("stay-duration-track", in: app).waitForExistence(timeout: 3)) XCTAssertFalse(app.buttons["duration-more"].exists) @@ -136,18 +160,21 @@ final class ParkAlongUITests: XCTestCase { } app.buttons["duration-8h+"].tap() - XCTAssertTrue(element("arrival-stay-planner", in: app).waitForExistence(timeout: 2)) - XCTAssertTrue(app.buttons["planner-arrival-tomorrow"].exists) - XCTAssertTrue(app.buttons["planner-apply"].exists) - XCTAssertTrue(element("planner-arrival-context", in: app).exists) + waitForValue("selected", on: app.buttons["duration-8h+"]) + XCTAssertFalse(element("arrival-stay-planner", in: app).exists) + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "label CONTAINS[c] %@", "8-hour stay"), + object: app.staticTexts["availability-status"] + ) + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) } func testPlannerAppliesFutureCustomStay() { let app = launch() XCTAssertTrue(element("stay-duration-track", in: app).waitForExistence(timeout: 3)) - let eightHoursPlus = app.buttons["duration-8h+"] - XCTAssertTrue(eightHoursPlus.waitForExistence(timeout: 2)) - eightHoursPlus.tap() + let plannerButton = app.buttons["arrival-planner-button"] + XCTAssertTrue(plannerButton.waitForExistence(timeout: 2)) + plannerButton.tap() XCTAssertTrue(element("arrival-stay-planner", in: app).waitForExistence(timeout: 2)) app.buttons["planner-arrival-tomorrow"].tap() From 62430032221ad981ad4363fa0e08e9175788101b Mon Sep 17 00:00:00 2001 From: Sidhaarth Krishnan <161455529+RealSid08@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:36:17 +1000 Subject: [PATCH 4/4] Fix CoreLocation import for iOS 26 CI --- ParkAlongTests/ParkingMapViewModelTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/ParkAlongTests/ParkingMapViewModelTests.swift b/ParkAlongTests/ParkingMapViewModelTests.swift index e81b6ea..2471f2a 100644 --- a/ParkAlongTests/ParkingMapViewModelTests.swift +++ b/ParkAlongTests/ParkingMapViewModelTests.swift @@ -1,3 +1,4 @@ +import CoreLocation import XCTest @testable import ParkAlong