Siri: favourite price + favourite directions (top favourite per fuel); trim to 10 AppShortcuts (Apple cap)

This commit is contained in:
FuelBoard Contributor
2026-08-14 14:24:20 +01:00
parent aed99e3f32
commit b458d92972
4 changed files with 280 additions and 39 deletions
+13 -6
View File
@@ -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<Double>` 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
+201 -33
View File
@@ -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<String> {
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"
)
}
@@ -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",
+17
View File
@@ -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