- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared comparison confidence, so a saving is presented as verified only when both the candidate and the baseline pass the gate for the fuel on screen. - Rank against the closest station with an eligible selected-fuel price instead of the physically closest station, so a single stale neighbour can no longer invalidate every comparison in the list. Ordinary Closest sorting still includes every priced station. - Keep ineligible prices visible, fall back to pump-price order, and label Best Value unavailable when no comparison qualifies. - Widget: same baseline and confidence path as the app. - Tests: stale-closest baseline, alert baseline, eligibility policy, boundary and missing-timestamp regressions.
190 lines
9.7 KiB
Swift
190 lines
9.7 KiB
Swift
import Foundation
|
|
|
|
enum FuelPriceTimestamp {
|
|
static func parse(_ value: String?) -> Date? {
|
|
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.date(from: value) ?? {
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: value)
|
|
}()
|
|
}
|
|
}
|
|
|
|
enum FuelPriceFreshness {
|
|
static let warningAge: TimeInterval = 7 * 24 * 60 * 60
|
|
static let staleAge: TimeInterval = 30 * 24 * 60 * 60
|
|
static let futureTolerance: TimeInterval = 5 * 60
|
|
|
|
enum Source: Equatable { case gradeSpecific, stationWide, unknown }
|
|
enum Status: Equatable { case fresh, warning, stale, unknown, invalid }
|
|
enum Confidence: Equatable { case verified, provisional, unavailable }
|
|
enum RecommendationEligibility: Equatable { case eligible, ineligible }
|
|
|
|
struct Assessment: Equatable {
|
|
let source: Source
|
|
let status: Status
|
|
let age: TimeInterval?
|
|
let confidence: Confidence
|
|
let recommendationEligibility: RecommendationEligibility
|
|
let warning: Bool
|
|
|
|
var isEligibleForBestValue: Bool {
|
|
recommendationEligibility == .eligible
|
|
}
|
|
|
|
var stationReportAgeLabel: String? {
|
|
guard source == .stationWide, let age else { return nil }
|
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
|
Self.ageText(age))
|
|
}
|
|
|
|
var selectedFuelUpdateLabel: String {
|
|
guard source == .gradeSpecific, let age else {
|
|
return String(localized: "Not provided", comment: "Fuel-specific update time is missing")
|
|
}
|
|
return String(format: String(localized: "%@ ago", comment: "Price age, e.g. 28h ago"),
|
|
Self.ageText(age))
|
|
}
|
|
|
|
var displayLabel: String {
|
|
switch (source, status, age) {
|
|
case (_, .unknown, _): return "Price age unknown"
|
|
case (_, .invalid, _): return "Price timestamp invalid"
|
|
case (_, .stale, let age?): return "Price updated \(Self.ageText(age)) ago · may be outdated"
|
|
case (.stationWide, _, let age?): return "Price updated \(Self.ageText(age)) ago · station-wide · provisional"
|
|
case (_, .warning, let age?): return "Price updated \(Self.ageText(age)) ago"
|
|
case (.gradeSpecific, .fresh, let age?): return "Price updated \(Self.ageText(age)) ago"
|
|
default: return "Price age unknown"
|
|
}
|
|
}
|
|
|
|
private static func ageText(_ age: TimeInterval) -> String {
|
|
let minutes = max(0, Int(age / 60))
|
|
if minutes < 60 { return "\(minutes)m" }
|
|
let hours = minutes / 60
|
|
if hours < 48 { return "\(hours)h" }
|
|
return "\(hours / 24)d"
|
|
}
|
|
}
|
|
|
|
static func assessment(for station: FuelStation, fuel: FuelType, now: Date = Date()) -> Assessment {
|
|
if station.priceUpdatedByFuelRaw?[fuel] != nil && station.priceUpdatedByFuel?[fuel] == nil {
|
|
return invalid(source: .gradeSpecific)
|
|
}
|
|
if let timestamp = station.priceUpdatedByFuel?[fuel] {
|
|
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
|
|
}
|
|
guard let timestamp = station.priceUpdated else { return unknown() }
|
|
return assess(timestamp: timestamp, source: .stationWide, now: now)
|
|
}
|
|
|
|
/// Recommendation gate for Best Value. A station-wide timestamp under
|
|
/// seven days can support a provisional estimate. Older station-wide
|
|
/// reports cannot establish grade recency; grade-specific timestamps may
|
|
/// remain eligible up to thirty days, but carry an age warning.
|
|
static func isEligibleForBestValue(station: FuelStation, fuel: FuelType,
|
|
now: Date = Date()) -> Bool {
|
|
assessment(for: station, fuel: fuel, now: now).isEligibleForBestValue
|
|
}
|
|
|
|
static func assessment(priceUpdated: String?, priceUpdatedByGrade: [String: String]?,
|
|
fuel: FuelType, now: Date = Date()) -> Assessment {
|
|
if let raw = priceUpdatedByGrade?[fuel.rawValue] {
|
|
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid(source: .gradeSpecific) }
|
|
return assess(timestamp: date.timeIntervalSince1970, source: .gradeSpecific, now: now)
|
|
}
|
|
guard let raw = priceUpdated else { return unknown() }
|
|
guard let date = FuelPriceTimestamp.parse(raw) else { return invalid() }
|
|
return assess(timestamp: date.timeIntervalSince1970, source: .stationWide, now: now)
|
|
}
|
|
|
|
static func assessment(priceUpdated: TimeInterval?, priceUpdatedByFuel: [FuelType: TimeInterval]?,
|
|
fuel: FuelType, now: Date = Date()) -> Assessment {
|
|
if let timestamp = priceUpdatedByFuel?[fuel] {
|
|
return assess(timestamp: timestamp, source: .gradeSpecific, now: now)
|
|
}
|
|
guard let timestamp = priceUpdated else { return unknown() }
|
|
return assess(timestamp: timestamp, source: .stationWide, now: now)
|
|
}
|
|
|
|
/// Confidence for a monetary comparison requires both selected-grade prices.
|
|
/// Keep the eligibility and label decision together for app and widgets.
|
|
enum ComparisonConfidence: Equatable {
|
|
case verified, provisional, ineligible
|
|
|
|
static func assess(station: FuelStation, fuel: FuelType, baseline: FuelStation?, now: Date = Date()) -> Self {
|
|
FuelPriceFreshness.comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
|
}
|
|
|
|
var warningText: String? {
|
|
switch self {
|
|
case .verified: return nil
|
|
case .provisional: return String(localized: "Price not individually dated — saving is an estimate.")
|
|
case .ineligible: return String(localized: "Price age unavailable or too old — not a Best Value recommendation.")
|
|
}
|
|
}
|
|
}
|
|
|
|
static func comparisonWarning(station: FuelStation, fuel: FuelType,
|
|
baseline: FuelStation?, now: Date = Date()) -> String? {
|
|
let confidence = comparisonConfidence(station: station, fuel: fuel, baseline: baseline, now: now)
|
|
guard confidence == .provisional, let baseline else { return confidence.warningText }
|
|
let candidate = assessment(for: station, fuel: fuel, now: now)
|
|
let reference = assessment(for: baseline, fuel: fuel, now: now)
|
|
let candidateNeedsWarning = candidate.confidence != .verified || candidate.status != .fresh
|
|
let cause = candidateNeedsWarning ? candidate : reference
|
|
if cause.source == .stationWide {
|
|
let format = candidateNeedsWarning
|
|
? String(localized: "Estimated saving · %@ price not individually dated")
|
|
: String(localized: "Estimated saving · closest eligible station's %@ price not individually dated")
|
|
return String(format: format, fuel.displayName)
|
|
}
|
|
let format = candidateNeedsWarning
|
|
? String(localized: "Estimated saving · %@ price over 7 days old")
|
|
: String(localized: "Estimated saving · closest eligible station's %@ price over 7 days old")
|
|
return String(format: format, fuel.displayName)
|
|
}
|
|
|
|
static func comparisonConfidence(station: FuelStation, fuel: FuelType,
|
|
baseline: FuelStation?, now: Date = Date()) -> ComparisonConfidence {
|
|
guard let baseline,
|
|
let candidatePrice = station.prices[fuel], candidatePrice.isFinite, candidatePrice > 0,
|
|
let baselinePrice = baseline.prices[fuel], baselinePrice.isFinite, baselinePrice > 0
|
|
else { return .ineligible }
|
|
let candidate = assessment(for: station, fuel: fuel, now: now)
|
|
let reference = assessment(for: baseline, fuel: fuel, now: now)
|
|
guard candidate.isEligibleForBestValue, reference.isEligibleForBestValue else { return .ineligible }
|
|
if candidate.confidence == .verified, reference.confidence == .verified,
|
|
candidate.status == .fresh, reference.status == .fresh { return .verified }
|
|
return .provisional
|
|
}
|
|
|
|
private static func unknown() -> Assessment {
|
|
Assessment(source: .unknown, status: .unknown, age: nil,
|
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
|
}
|
|
|
|
private static func invalid(source: Source = .unknown) -> Assessment {
|
|
Assessment(source: source, status: .invalid, age: nil,
|
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
|
}
|
|
|
|
private static func assess(timestamp: TimeInterval, source: Source, now: Date) -> Assessment {
|
|
let age = now.timeIntervalSince1970 - timestamp
|
|
guard age >= -futureTolerance else {
|
|
return Assessment(source: source, status: .invalid, age: nil,
|
|
confidence: .unavailable, recommendationEligibility: .ineligible, warning: true)
|
|
}
|
|
let boundedAge = max(0, age)
|
|
let status: Status = boundedAge > staleAge ? .stale : boundedAge > warningAge ? .warning : .fresh
|
|
let confidence: Confidence = source == .gradeSpecific ? .verified : .provisional
|
|
let eligible = status == .fresh || (source == .gradeSpecific && status == .warning)
|
|
return Assessment(source: source, status: status, age: boundedAge,
|
|
confidence: confidence,
|
|
recommendationEligibility: eligible ? .eligible : .ineligible,
|
|
warning: source != .gradeSpecific || status != .fresh)
|
|
}
|
|
}
|