From b458d929727d5878e4b08014cef26612e29911b4 Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 14 Aug 2026 14:24:20 +0100 Subject: [PATCH] Siri: favourite price + favourite directions (top favourite per fuel); trim to 10 AppShortcuts (Apple cap) --- BACKLOG.md | 19 +- FuelBoard/SiriShortcuts.swift | 234 +++++++++++++++--- .../FuelBoardSharedTests/FuelBoardTests.swift | 49 ++++ Shared/SiriCheapestLookup.swift | 17 ++ 4 files changed, 280 insertions(+), 39 deletions(-) diff --git a/BACKLOG.md b/BACKLOG.md index 8f79d43..be1a2a5 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -11,15 +11,16 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED. ## P1 — Soon -- [ ] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent` +- [x] **Siri: "Cheapest [fuel] near me"** — `AppShortcutsProvider` + `CheapestFuelIntent` (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first with freshness label ("as of 6am"), last-known-location fallback. Constraint: fresh-data path needs a reachable source off-LAN — cached answers work anywhere the app has previously fetched (see P3 data mirror). Interim: the relay on Tailscale (100.120.217.98:8789) is reachable off-LAN for testing; relay will - move to a permanent box or the GitHub mirror long-term. *IN PROGRESS — MVP - intent shipped (dialog + snippet + freshness label), closest phrase still - pending.* + move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped + lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away), + per-fuel literal-word shortcuts `e55078c`, names = app words `a3b6f37`, plus + Directions-to-cheapest `caadf18` + spoken hand-off `aed99e3`.* - [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by distance instead of price. Free second phrase in the same `AppShortcutsProvider`. @@ -28,8 +29,14 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED. - [ ] **Shortcuts: station price lookup** — `StationEntity` + `EntityQuery` so Shortcuts/Siri pick a station by name; `ReturnsValue` enables user automations ("get cheapest diesel → notify"). -- [ ] **Siri: favourite station price** — "What's diesel at my favourite station?" - (cheap once `StationEntity` exists). +- [x] **Siri: favourite station price** — "How much is my favourite unleaded?" + `FavouriteFuelPriceIntent`: TOP favourite per fuel (first in the manual + Favourites order — `SiriCheapestLookup.topFavourite`, refresh-vs-dump with + offline cached-snapshot fallback); no location required (distance shown only + when the saved fix exists). Also `DirectionsToFavouriteFuelIntent` (Maps + hand-off, station announced first). 4+4 App Shortcuts (generic + per-fuel). + *DONE — favourites already carried station snapshots, so no `StationEntity` + dependency.* - [ ] **Siri: on-screen awareness (View Annotations + StationEntity)** — iOS 26/27 Siri prefers the visible screen over the App Shortcut when the app is frontmost; without view annotations it answers from the rendered screen diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift index 374d567..691ce84 100644 --- a/FuelBoard/SiriShortcuts.swift +++ b/FuelBoard/SiriShortcuts.swift @@ -67,7 +67,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "No price data yet", dialog: IntentDialog(stringLiteral: dialog), - view: CheapestFuelMessage(text: "No price data yet — open FuelBoard to download prices.") + view: FuelMessage(text: "No price data yet — open FuelBoard to download prices.") ) } @@ -76,7 +76,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "Location unavailable", dialog: IntentDialog(stringLiteral: dialog), - view: CheapestFuelMessage(text: "Location unavailable — open FuelBoard to share your location.") + view: FuelMessage(text: "Location unavailable — open FuelBoard to share your location.") ) } @@ -96,7 +96,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: "No \(fuelName) stations within \(radiusText)", dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), - view: CheapestFuelMessage(text: "No \(fuelName) stations within \(radiusText).") + view: FuelMessage(text: "No \(fuelName) stations within \(radiusText).") ) } @@ -114,7 +114,7 @@ struct CheapestFuelIntent: AppIntent { return .result( value: summary, dialog: IntentDialog(stringLiteral: dialog), - view: CheapestFuelCard( + view: FuelPriceCard( stationName: station.name, fuelName: fuel.displayName, price: priceText, @@ -195,10 +195,150 @@ private struct DirectionsError: LocalizedError { var errorDescription: String? { message } } +// MARK: - Favourite intents + +/// Price at the user's TOP favourite station for `fuel` — the first entry in +/// the manual favourites order for that fuel (the Favourites tab's +/// drag-and-drop order, the same order the single widget uses). Unlike the +/// cheapest intents this deliberately does NOT require a location: the +/// favourite is chosen by the user, not by distance, so it answers even when +/// a Siri-launched location fix is unavailable (distance is included only +/// when the saved last-known fix exists). +struct FavouriteFuelPriceIntent: AppIntent { + static var title: LocalizedStringResource = "Favourite Fuel Price" + static var description = IntentDescription( + "Tells you the price at your top favourite station for a fuel, using the latest cached prices." + ) + + @Parameter(title: "Fuel") + var fuel: FuelType + + init() { + self.fuel = .e10 + } + + init(fuel: FuelType) { + self.fuel = fuel + } + + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue { + let stations = FuelStore.loadStations() + let favourites = FuelStore.loadFavourites() + let fuelName = fuel.displayName.lowercased() + + guard let favourite = SiriCheapestLookup.topFavourite(in: favourites, from: stations, fuel: fuel) else { + let dialog = "You don't have a favourite \(fuelName) station yet. Open FuelBoard and star one, then ask me again." + return .result( + value: "No favourite \(fuelName) station", + dialog: IntentDialog(stringLiteral: dialog), + view: FuelMessage(text: "No favourite \(fuelName) station — star one in FuelBoard first.") + ) + } + + guard let price = favourite.station.prices[fuel] else { + let dialog = "I don't have a current price for \(favourite.station.name), your favourite \(fuelName) station." + return .result( + value: "No current price for \(favourite.station.name)", + dialog: IntentDialog(stringLiteral: dialog), + view: FuelMessage(text: "No current price for \(favourite.station.name).") + ) + } + + let priceText = String(format: "£%.3f", price / 100) + let freshness = SiriCheapestLookup.freshnessLabel( + updated: FuelStore.loadDataUpdated(), + lastRefresh: FuelStore.loadLastRefresh() + ) + let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" + + // Distance is a nicety, not a requirement: the favourite is chosen by + // the user, so a missing location fix still gets a full answer. + let distanceText: String + let distanceClause: String + let summary: String + if let coordinate = FuelStore.loadLocationWithDate()?.coordinate { + distanceText = FuelStore.loadDistanceUnit().format( + favourite.station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) + ) + distanceClause = ", \(distanceText) away" + summary = "\(favourite.station.name): \(priceText), \(distanceText)" + } else { + distanceText = "" + distanceClause = "" + summary = "\(favourite.station.name): \(priceText)" + } + + let dialog = "Your favourite \(fuelName) station, \(favourite.station.name), is at \(priceText)\(distanceClause)\(freshnessClause)." + + return .result( + value: summary, + dialog: IntentDialog(stringLiteral: dialog), + view: FuelPriceCard( + stationName: favourite.station.name, + fuelName: fuel.displayName, + price: priceText, + distance: distanceText, + freshness: freshness + ) + ) + } +} + +/// Opens Apple Maps with turn-by-turn directions to the user's TOP favourite +/// station for `fuel`. Same selection as `FavouriteFuelPriceIntent`; same +/// Maps hand-off pattern as `DirectionsToCheapestFuelIntent` — the station is +/// announced before Maps opens. +struct DirectionsToFavouriteFuelIntent: AppIntent { + static var title: LocalizedStringResource = "Directions to Favourite Fuel Station" + static var description = IntentDescription( + "Opens Apple Maps directions to your top favourite station for a fuel." + ) + + @Parameter(title: "Fuel") + var fuel: FuelType + + init() { + self.fuel = .e10 + } + + init(fuel: FuelType) { + self.fuel = fuel + } + + func perform() async throws -> some IntentResult & ProvidesDialog & OpensIntent { + let stations = FuelStore.loadStations() + let favourites = FuelStore.loadFavourites() + let fuelName = fuel.displayName.lowercased() + + guard let favourite = SiriCheapestLookup.topFavourite(in: favourites, from: stations, fuel: fuel) else { + throw DirectionsError("You don't have a favourite \(fuelName) station yet. Open FuelBoard and star one, then ask me again.") + } + + let url = SiriCheapestLookup.mapsURL(latitude: favourite.station.lat, longitude: favourite.station.lng) + + // Distance text when the saved last-known fix exists; otherwise just + // announce the station before the hand-off. + let dialog: String + if let coordinate = FuelStore.loadLocationWithDate()?.coordinate { + let distanceText = FuelStore.loadDistanceUnit().format( + favourite.station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) + ) + dialog = "Opening Maps to \(favourite.station.name), \(distanceText) away." + } else { + dialog = "Opening Maps to \(favourite.station.name)." + } + + return .result( + opensIntent: OpenURLIntent(url), + dialog: IntentDialog(stringLiteral: dialog) + ) + } +} + // MARK: - Snippets /// Price card shown in Shortcuts / Siri results. -struct CheapestFuelCard: View { +struct FuelPriceCard: View { let stationName: String let fuelName: String let price: String @@ -214,7 +354,7 @@ struct CheapestFuelCard: View { Text(stationName) .font(.headline) .lineLimit(1) - Text("\(fuelName) · \(distance)") + Text(distance.isEmpty ? fuelName : "\(fuelName) · \(distance)") .font(.subheadline) .foregroundStyle(.secondary) if !freshness.isEmpty { @@ -232,8 +372,9 @@ struct CheapestFuelCard: View { } } -/// Fallback message card for the no-data / no-location / no-station cases. -struct CheapestFuelMessage: View { +/// Fallback message card for the no-data / no-location / no-station / no- +/// favourite cases. +struct FuelMessage: View { let text: String var body: some View { @@ -298,46 +439,73 @@ struct FuelBoardShortcuts: AppShortcutsProvider { systemImageName: "fuelpump" ) - // Directions — separate intent so the price queries stay answer-only - // and only the explicit directions query hands off to Apple Maps. - // Phrases use direction verbs ("directions to", "get directions") to - // stay distinct from the price shortcut phrases. + // Favourite price — "How much is my favourite [fuel]?" The answer is + // the TOP favourite for that fuel (first in the manual Favourites + // order — the same order the single widget uses). AppShortcut( - intent: DirectionsToCheapestFuelIntent(), + intent: FavouriteFuelPriceIntent(fuel: .e10), phrases: [ - "Ask \(.applicationName) for directions to the cheapest \(\.$fuel) near me", + "Ask \(.applicationName) how much my favourite unleaded costs", + "Ask \(.applicationName) the price of my favourite petrol", ], - shortTitle: "Directions to Cheapest Fuel", + shortTitle: "Favourite Unleaded Price", + systemImageName: "star.fill" + ) + + AppShortcut( + intent: FavouriteFuelPriceIntent(fuel: .e5), + phrases: [ + "Ask \(.applicationName) how much my favourite premium costs", + "Ask \(.applicationName) the price of my favourite super unleaded", + ], + shortTitle: "Favourite Premium Price", + systemImageName: "star.fill" + ) + + AppShortcut( + intent: FavouriteFuelPriceIntent(fuel: .diesel), + phrases: [ + "Ask \(.applicationName) how much my favourite diesel costs", + ], + shortTitle: "Favourite Diesel Price", + systemImageName: "star.fill" + ) + + // Directions — only the FAVOURITE directions carry App Shortcut + // phrases: Apple caps app shortcuts at 10 (the metadata processor + // hard-fails past that), and the per-fuel cheapest-price + favourite + // price/directions entries cover the user's actual asks. The cheapest- + // directions and generic favourite intents below remain in code — + // still runnable from the Shortcuts action list, just without Siri + // phrases. Phrases use direction verbs ("directions to", "get + // directions") so they never collide with the price shortcut phrases. + AppShortcut( + intent: DirectionsToFavouriteFuelIntent(fuel: .e10), + phrases: [ + "Ask \(.applicationName) for directions to my favourite unleaded station", + "Get directions to my favourite petrol station \(.applicationName)", + ], + shortTitle: "Directions to Favourite Unleaded", systemImageName: "map" ) AppShortcut( - intent: DirectionsToCheapestFuelIntent(fuel: .e10), + intent: DirectionsToFavouriteFuelIntent(fuel: .e5), phrases: [ - "Ask \(.applicationName) for directions to the cheapest unleaded near me", - "Get directions to the cheapest petrol near me \(.applicationName)", + "Ask \(.applicationName) for directions to my favourite premium station", + "Get directions to my favourite super unleaded station \(.applicationName)", ], - shortTitle: "Directions to Cheapest Unleaded", + shortTitle: "Directions to Favourite Premium", systemImageName: "map" ) AppShortcut( - intent: DirectionsToCheapestFuelIntent(fuel: .e5), + intent: DirectionsToFavouriteFuelIntent(fuel: .diesel), phrases: [ - "Ask \(.applicationName) for directions to the cheapest premium near me", - "Get directions to the cheapest super unleaded near me \(.applicationName)", + "Ask \(.applicationName) for directions to my favourite diesel station", + "Get directions to my favourite diesel station \(.applicationName)", ], - shortTitle: "Directions to Cheapest Premium", - systemImageName: "map" - ) - - AppShortcut( - intent: DirectionsToCheapestFuelIntent(fuel: .diesel), - phrases: [ - "Ask \(.applicationName) for directions to the cheapest diesel near me", - "Get directions to the cheapest diesel near me \(.applicationName)", - ], - shortTitle: "Directions to Cheapest Diesel", + shortTitle: "Directions to Favourite Diesel", systemImageName: "map" ) } diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index 3007ddc..b88fd3b 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -485,6 +485,55 @@ final class SiriCheapestLookupTests: XCTestCase { XCTAssertTrue(url.absoluteString.hasPrefix("https://maps.apple.com/")) } + // MARK: Favourite selection + + private func favourite(_ id: String, _ fuel: FuelType, _ prices: [FuelType: Double]) -> FavouriteEntry { + FavouriteEntry(station: station(id, lat: 53.7, lng: -1.8, prices), fuel: fuel) + } + + // The top favourite for a fuel is the FIRST entry in the manual favourites + // order for that fuel (the drag-and-drop order from the Favourites tab; + // per-fuel blocks keep their position, so array order = per-fuel order). + func testTopFavouriteIsFirstInManualOrderPerFuel() { + let favs = [ + favourite("a", .e10, [.e10: 130]), + favourite("b", .diesel, [.diesel: 145]), + favourite("c", .e10, [.e10: 120]), + ] + XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10)?.station.id, "a") + XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .diesel)?.station.id, "b") + XCTAssertNil(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e5)) + } + + // The favourite's cached price snapshot is refreshed from the current + // station dump when the station is present. + func testTopFavouriteRefreshesPriceFromDump() { + let favs = [favourite("a", .e10, [.e10: 130])] + let dump = [station("a", lat: 53.7, lng: -1.8, [.e10: 122.9])] + let top = SiriCheapestLookup.topFavourite(in: favs, from: dump, fuel: .e10) + XCTAssertEqual(top?.station.prices[.e10], 122.9) + } + + // Stations missing from the dump keep their cached snapshot, so the + // favourite still answers offline or when the station isn't in the + // GOV.UK set. + func testTopFavouriteKeepsCachedSnapshotWhenMissingFromDump() { + let favs = [favourite("a", .e10, [.e10: 130])] + let top = SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10) + XCTAssertEqual(top?.station.prices[.e10], 130) + } + + // Manual reorder (the Favourites tab's drag-and-drop) changes which + // favourite is "top" — the Siri answer follows the widget order. + func testTopFavouriteFollowsManualReorder() { + let favs = [ + favourite("a", .e10, [.e10: 130]), + favourite("c", .e10, [.e10: 120]), + ] + let reordered = FuelStore.reorderedFavourites(favs, fuel: .e10, fromOffsets: IndexSet(integer: 0), toOffset: 2) + XCTAssertEqual(SiriCheapestLookup.topFavourite(in: reordered, from: [], fuel: .e10)?.station.id, "c") + } + func testFreshnessLabelUsesDataUpdatedStamp() { let label = SiriCheapestLookup.freshnessLabel( updated: "2026-08-14T10:31:36.000Z", diff --git a/Shared/SiriCheapestLookup.swift b/Shared/SiriCheapestLookup.swift index bc39365..229a3a2 100644 --- a/Shared/SiriCheapestLookup.swift +++ b/Shared/SiriCheapestLookup.swift @@ -60,6 +60,23 @@ enum SiriCheapestLookup { return plain.date(from: string) } + /// The user's TOP favourite for `fuel`: the first entry in the manual + /// favourites order (the Favourites tab's drag-and-drop order IS the + /// widget/priority order — the first station of a fuel is its "single + /// widget" favourite). Favourites are refreshed against the current + /// station dump first so prices are as fresh as the cache allows; + /// stations missing from the dump keep their cached snapshot, so the + /// answer still works fully offline. Returns nil when the user has no + /// favourite for that fuel. + static func topFavourite( + in favourites: [FavouriteEntry], + from stations: [FuelStation], + fuel: FuelType + ) -> FavouriteEntry? { + let refreshed = FuelStore.refreshedFavourites(favourites, from: stations) + return refreshed.first { $0.fuel == fuel } + } + /// Apple Maps URL for turn-by-turn directions to a point. MUST be the /// universal-link form (https://maps.apple.com) — OpenURLIntent rejects /// custom schemes like `maps://`, and the https form survives copy/paste