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 ## 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 (App Intents, iOS 16+), dialog + price-card snippet, cached full-UK dump first
with freshness label ("as of 6am"), last-known-location fallback. Constraint: with freshness label ("as of 6am"), last-known-location fallback. Constraint:
fresh-data path needs a reachable source off-LAN — cached answers work anywhere 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 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 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 move to a permanent box or the GitHub mirror long-term. *DONE — radius-scoped
intent shipped (dialog + snippet + freshness label), closest phrase still lookup `037c241` (real-data regression: GULF HISTON 100.9p 132 mi away),
pending.* 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 - [ ] **Siri: "Closest [fuel] station"** — same plumbing as cheapest, sort by
distance instead of price. Free second phrase in the same `AppShortcutsProvider`. 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: station price lookup**`StationEntity` + `EntityQuery` so
Shortcuts/Siri pick a station by name; `ReturnsValue<Double>` enables user Shortcuts/Siri pick a station by name; `ReturnsValue<Double>` enables user
automations ("get cheapest diesel → notify"). automations ("get cheapest diesel → notify").
- [ ] **Siri: favourite station price** — "What's diesel at my favourite station?" - [x] **Siri: favourite station price** — "How much is my favourite unleaded?"
(cheap once `StationEntity` exists). `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 - [ ] **Siri: on-screen awareness (View Annotations + StationEntity)** — iOS
26/27 Siri prefers the visible screen over the App Shortcut when the app is 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 frontmost; without view annotations it answers from the rendered screen
+201 -33
View File
@@ -67,7 +67,7 @@ struct CheapestFuelIntent: AppIntent {
return .result( return .result(
value: "No price data yet", value: "No price data yet",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: "Location unavailable", value: "Location unavailable",
dialog: IntentDialog(stringLiteral: dialog), 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( return .result(
value: "No \(fuelName) stations within \(radiusText)", value: "No \(fuelName) stations within \(radiusText)",
dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) within \(radiusText) of you."), 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( return .result(
value: summary, value: summary,
dialog: IntentDialog(stringLiteral: dialog), dialog: IntentDialog(stringLiteral: dialog),
view: CheapestFuelCard( view: FuelPriceCard(
stationName: station.name, stationName: station.name,
fuelName: fuel.displayName, fuelName: fuel.displayName,
price: priceText, price: priceText,
@@ -195,10 +195,150 @@ private struct DirectionsError: LocalizedError {
var errorDescription: String? { message } 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 // MARK: - Snippets
/// Price card shown in Shortcuts / Siri results. /// Price card shown in Shortcuts / Siri results.
struct CheapestFuelCard: View { struct FuelPriceCard: View {
let stationName: String let stationName: String
let fuelName: String let fuelName: String
let price: String let price: String
@@ -214,7 +354,7 @@ struct CheapestFuelCard: View {
Text(stationName) Text(stationName)
.font(.headline) .font(.headline)
.lineLimit(1) .lineLimit(1)
Text("\(fuelName) · \(distance)") Text(distance.isEmpty ? fuelName : "\(fuelName) · \(distance)")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
if !freshness.isEmpty { if !freshness.isEmpty {
@@ -232,8 +372,9 @@ struct CheapestFuelCard: View {
} }
} }
/// Fallback message card for the no-data / no-location / no-station cases. /// Fallback message card for the no-data / no-location / no-station / no-
struct CheapestFuelMessage: View { /// favourite cases.
struct FuelMessage: View {
let text: String let text: String
var body: some View { var body: some View {
@@ -298,46 +439,73 @@ struct FuelBoardShortcuts: AppShortcutsProvider {
systemImageName: "fuelpump" systemImageName: "fuelpump"
) )
// Directions separate intent so the price queries stay answer-only // Favourite price "How much is my favourite [fuel]?" The answer is
// and only the explicit directions query hands off to Apple Maps. // the TOP favourite for that fuel (first in the manual Favourites
// Phrases use direction verbs ("directions to", "get directions") to // order the same order the single widget uses).
// stay distinct from the price shortcut phrases.
AppShortcut( AppShortcut(
intent: DirectionsToCheapestFuelIntent(), intent: FavouriteFuelPriceIntent(fuel: .e10),
phrases: [ 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" systemImageName: "map"
) )
AppShortcut( AppShortcut(
intent: DirectionsToCheapestFuelIntent(fuel: .e10), intent: DirectionsToFavouriteFuelIntent(fuel: .e5),
phrases: [ phrases: [
"Ask \(.applicationName) for directions to the cheapest unleaded near me", "Ask \(.applicationName) for directions to my favourite premium station",
"Get directions to the cheapest petrol near me \(.applicationName)", "Get directions to my favourite super unleaded station \(.applicationName)",
], ],
shortTitle: "Directions to Cheapest Unleaded", shortTitle: "Directions to Favourite Premium",
systemImageName: "map" systemImageName: "map"
) )
AppShortcut( AppShortcut(
intent: DirectionsToCheapestFuelIntent(fuel: .e5), intent: DirectionsToFavouriteFuelIntent(fuel: .diesel),
phrases: [ phrases: [
"Ask \(.applicationName) for directions to the cheapest premium near me", "Ask \(.applicationName) for directions to my favourite diesel station",
"Get directions to the cheapest super unleaded near me \(.applicationName)", "Get directions to my favourite diesel station \(.applicationName)",
], ],
shortTitle: "Directions to Cheapest Premium", shortTitle: "Directions to Favourite Diesel",
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",
systemImageName: "map" systemImageName: "map"
) )
} }
@@ -485,6 +485,55 @@ final class SiriCheapestLookupTests: XCTestCase {
XCTAssertTrue(url.absoluteString.hasPrefix("https://maps.apple.com/")) 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() { func testFreshnessLabelUsesDataUpdatedStamp() {
let label = SiriCheapestLookup.freshnessLabel( let label = SiriCheapestLookup.freshnessLabel(
updated: "2026-08-14T10:31:36.000Z", updated: "2026-08-14T10:31:36.000Z",
+17
View File
@@ -60,6 +60,23 @@ enum SiriCheapestLookup {
return plain.date(from: string) 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 /// Apple Maps URL for turn-by-turn directions to a point. MUST be the
/// universal-link form (https://maps.apple.com) OpenURLIntent rejects /// universal-link form (https://maps.apple.com) OpenURLIntent rejects
/// custom schemes like `maps://`, and the https form survives copy/paste /// custom schemes like `maps://`, and the https form survives copy/paste