From 29d4a0472b0daf3fffb0966b046749d27dc1321e Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Fri, 14 Aug 2026 12:32:31 +0100 Subject: [PATCH] Siri: cheapest [fuel] near me intent + App Shortcuts (cached-first, freshness label) --- FuelBoard/SiriShortcuts.swift | 169 ++++++++++++++++++ .../FuelBoardShared/SiriCheapestLookup.swift | 1 + .../FuelBoardSharedTests/FuelBoardTests.swift | 70 ++++++++ Shared/SiriCheapestLookup.swift | 54 ++++++ 4 files changed, 294 insertions(+) create mode 100644 FuelBoard/SiriShortcuts.swift create mode 120000 FuelBoardTests/Sources/FuelBoardShared/SiriCheapestLookup.swift create mode 100644 Shared/SiriCheapestLookup.swift diff --git a/FuelBoard/SiriShortcuts.swift b/FuelBoard/SiriShortcuts.swift new file mode 100644 index 0000000..6ce536d --- /dev/null +++ b/FuelBoard/SiriShortcuts.swift @@ -0,0 +1,169 @@ +import AppIntents +import SwiftUI + +/// Siri / Shortcuts surface for "cheapest [fuel] near me". +/// +/// Design (see skill reference `siri-app-intents-scope.md`): +/// - App Intents (iOS 16+), no entitlements/capabilities/Info.plist keys. +/// - Cached-data-first: answers from the full-UK dump saved in the app-group +/// defaults by the normal app fetch. The dialog ALWAYS labels data age +/// ("as of 6:30 AM") — silent stale answers are a trust killer for fuel +/// prices. The fresh-data path (relay baseURL is LAN-only) is a P0 +/// dependency; until it lands, off-LAN answers use whatever the phone +/// last fetched at home. +/// - Location: last-known location fallback (background CoreLocation from a +/// Siri-launched process is slow/unreliable). +/// - Phrases stay literal/boring for App Store review; `\.applicationName` +/// appears in one phrase per the review guidance. + +// MARK: - Fuel parameter + +extension FuelType: AppEnum { + static var typeDisplayRepresentation: TypeDisplayRepresentation = "Fuel" + + static var caseDisplayRepresentations: [FuelType: DisplayRepresentation] = [ + .e10: "Unleaded", + .e5: "Premium", + .diesel: "Diesel", + ] +} + +// MARK: - Intent + +struct CheapestFuelIntent: AppIntent { + static var title: LocalizedStringResource = "Cheapest Fuel Near Me" + static var description = IntentDescription( + "Finds the cheapest station selling a fuel near you, using the latest cached prices." + ) + + @Parameter(title: "Fuel") + var fuel: FuelType + + func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView & ReturnsValue { + let stations = FuelStore.loadStations() + + guard !stations.isEmpty else { + let dialog = "I don't have any price data yet. Open FuelBoard once to download the latest prices, then ask me again." + return .result( + value: "No price data yet", + dialog: IntentDialog(stringLiteral: dialog), + view: CheapestFuelMessage(text: "No price data yet — open FuelBoard to download prices.") + ) + } + + guard let coordinate = FuelStore.loadLocationWithDate()?.coordinate else { + let dialog = "I don't know your location yet. Open FuelBoard and allow location access, then ask me again." + return .result( + value: "Location unavailable", + dialog: IntentDialog(stringLiteral: dialog), + view: CheapestFuelMessage(text: "Location unavailable — open FuelBoard to share your location.") + ) + } + + guard let station = SiriCheapestLookup.cheapest( + in: stations, + fuel: fuel, + fromLat: coordinate.lat, + lng: coordinate.lng + ), let price = station.prices[fuel] else { + let fuelName = fuel.displayName.lowercased() + return .result( + value: "No \(fuelName) stations found", + dialog: IntentDialog(stringLiteral: "I couldn't find any station selling \(fuelName) near you."), + view: CheapestFuelMessage(text: "No \(fuelName) stations found.") + ) + } + + let distanceKM = station.distanceKM(to: coordinate.lat, lng2: coordinate.lng) + let distanceText = FuelStore.loadDistanceUnit().format(distanceKM) + let priceText = String(format: "£%.3f", price / 100) + let freshness = SiriCheapestLookup.freshnessLabel( + updated: FuelStore.loadDataUpdated(), + lastRefresh: FuelStore.loadLastRefresh() + ) + let summary = "\(station.name): \(priceText), \(distanceText)" + let freshnessClause = freshness.isEmpty ? "" : " — prices \(freshness)" + let dialog = "The cheapest \(fuel.displayName.lowercased()) near you is \(station.name) at \(priceText), \(distanceText) away\(freshnessClause)." + + return .result( + value: summary, + dialog: IntentDialog(stringLiteral: dialog), + view: CheapestFuelCard( + stationName: station.name, + fuelName: fuel.displayName, + price: priceText, + distance: distanceText, + freshness: freshness + ) + ) + } +} + +// MARK: - Snippets + +/// Price card shown in Shortcuts / Siri results. +struct CheapestFuelCard: View { + let stationName: String + let fuelName: String + let price: String + let distance: String + let freshness: String + + var body: some View { + HStack(spacing: 14) { + Image(systemName: "fuelpump.fill") + .font(.title2) + .foregroundStyle(.tint) + VStack(alignment: .leading, spacing: 2) { + Text(stationName) + .font(.headline) + .lineLimit(1) + Text("\(fuelName) · \(distance)") + .font(.subheadline) + .foregroundStyle(.secondary) + if !freshness.isEmpty { + Text(freshness) + .font(.caption) + .foregroundStyle(.tertiary) + } + } + Spacer(minLength: 8) + Text(price) + .font(.title2.bold()) + .monospacedDigit() + } + .padding(12) + } +} + +/// Fallback message card for the no-data / no-location / no-station cases. +struct CheapestFuelMessage: View { + let text: String + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + Text(text) + .font(.subheadline) + } + .padding(12) + } +} + +// MARK: - App Shortcuts + +struct FuelBoardShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: CheapestFuelIntent(), + phrases: [ + "Ask \(.applicationName) what's the cheapest \(\.$fuel) near me", + "Find the cheapest \(\.$fuel) near me \(.applicationName)", + "Cheapest \(\.$fuel) near me \(.applicationName)", + ], + shortTitle: "Cheapest Fuel", + systemImageName: "fuelpump" + ) + } +} diff --git a/FuelBoardTests/Sources/FuelBoardShared/SiriCheapestLookup.swift b/FuelBoardTests/Sources/FuelBoardShared/SiriCheapestLookup.swift new file mode 120000 index 0000000..c29fdf0 --- /dev/null +++ b/FuelBoardTests/Sources/FuelBoardShared/SiriCheapestLookup.swift @@ -0,0 +1 @@ +../../../Shared/SiriCheapestLookup.swift \ No newline at end of file diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift index c62ae14..135b661 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift @@ -406,3 +406,73 @@ final class DistanceLabelTests: XCTestCase { XCTAssertEqual(DistanceUnit.kilometers.displayMiles(15), 24) // 15 mi = 24.1 km } } + +final class SiriCheapestLookupTests: XCTestCase { + private func station(_ id: String, lat: Double, lng: Double, _ prices: [FuelType: Double]) -> FuelStation { + FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: prices, priceUpdated: nil) + } + + func testCheapestPicksLowestPriceNotNearest() { + // Near station is pricier; far station is cheaper — price must win. + let stations = [ + station("near", lat: 53.0, lng: -1.0, [.e10: 145.9]), + station("far", lat: 53.5, lng: -1.5, [.e10: 139.9]), + ] + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01) + XCTAssertEqual(result?.id, "far", "cheapest by price, not by distance") + } + + func testCheapestTieBreaksByDistance() { + let stations = [ + station("near", lat: 53.0, lng: -1.0, [.e10: 140.0]), + station("far", lat: 53.9, lng: -1.9, [.e10: 140.0]), + ] + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01) + XCTAssertEqual(result?.id, "near", "equal prices resolve to the nearest station") + } + + func testCheapestSkipsStationsWithoutThatFuel() { + let stations = [ + station("noDiesel", lat: 53.0, lng: -1.0, [.e10: 139.9]), + station("sellsDiesel", lat: 53.5, lng: -1.5, [.diesel: 149.9]), + ] + let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01) + XCTAssertEqual(result?.id, "sellsDiesel", "stations without the fuel are skipped") + } + + func testCheapestReturnsNilWhenNoStationSellsFuel() { + let stations = [station("a", lat: 53.0, lng: -1.0, [.e10: 139.9])] + XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.0, lng: -1.0)) + } + + func testCheapestReturnsNilForEmptyInput() { + XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.0, lng: -1.0)) + } + + func testFreshnessLabelUsesDataUpdatedStamp() { + let label = SiriCheapestLookup.freshnessLabel( + updated: "2026-08-14T10:31:36.000Z", + lastRefresh: nil + ) + XCTAssertTrue(label.hasPrefix("as of "), "stamp must be prefixed for the dialog") + XCTAssertFalse(label.contains("10:31"), "stamp is converted to the user's local time, not raw UTC") + } + + func testFreshnessLabelFallsBackToLastRefresh() { + let label = SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: Date(timeIntervalSince1970: 0)) + XCTAssertTrue(label.hasPrefix("as of "), "local refresh date is used when no GOV.UK stamp exists") + } + + func testFreshnessLabelEmptyWithoutAnyDate() { + XCTAssertEqual(SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: nil), "") + } + + func testFreshnessLabelToleratesPlainISODate() { + // Some relays emit no fractional seconds. + let label = SiriCheapestLookup.freshnessLabel( + updated: "2026-08-14T10:31:36Z", + lastRefresh: nil + ) + XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses") + } +} diff --git a/Shared/SiriCheapestLookup.swift b/Shared/SiriCheapestLookup.swift new file mode 100644 index 0000000..54bd3fa --- /dev/null +++ b/Shared/SiriCheapestLookup.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Pure, testable logic behind the Siri "cheapest fuel near me" intent. +/// Foundation-only on purpose: `Shared/` joins both the app and widget +/// targets, and FuelBoardTests exercises this directly. +enum SiriCheapestLookup { + + /// The cheapest station selling `fuel`, tie-broken by distance from the + /// given point. Stations without a price for `fuel` are skipped entirely. + /// Returns nil when no station sells the fuel. + static func cheapest( + in stations: [FuelStation], + fuel: FuelType, + fromLat lat: Double, + lng: Double + ) -> FuelStation? { + stations + .filter { $0.prices[fuel] != nil } + .min { lhs, rhs in + guard let lp = lhs.prices[fuel], let rp = rhs.prices[fuel] else { + return false + } + if lp != rp { return lp < rp } + return lhs.distanceKM(to: lat, lng2: lng) < rhs.distanceKM(to: lat, lng2: lng) + } + } + + /// A short freshness stamp for the dialog: "as of 6:30 AM". + /// Prefers the GOV.UK `data_updated` stamp carried by the relay (ISO 8601 + /// with or without fractional seconds); falls back to the local + /// last-refresh date; returns "" when there is no data at all. + static func freshnessLabel(updated: String?, lastRefresh: Date?, now: Date = Date()) -> String { + let date: Date? + if let updated { + date = Self.parseISO(updated) ?? lastRefresh + } else { + date = lastRefresh + } + guard let date else { return "" } + let formatter = DateFormatter() + formatter.dateFormat = "h:mm a" + formatter.locale = Locale(identifier: "en_GB") + return "as of \(formatter.string(from: date))" + } + + private static func parseISO(_ string: String) -> Date? { + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFraction.date(from: string) { return date } + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + return plain.date(from: string) + } +}