Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49503bef99 | ||
|
|
e0e5b36503 | ||
|
|
a6dbe8c46e | ||
|
|
bfeb494c03 | ||
|
|
430006be94 | ||
|
|
9aea478317 | ||
|
|
3416ad563e | ||
|
|
87d66eae94 | ||
|
|
86385d53a2 | ||
|
|
9fdcc7370e |
+23
-13
@@ -13,20 +13,30 @@ Status: TODO / IN PROGRESS / DONE / BLOCKED.
|
|||||||
|
|
||||||
## P1 — Soon
|
## P1 — Soon
|
||||||
|
|
||||||
- [ ] **Move relay + mirror off the Mac mini (plan for 2026-08-16)** — dedicated
|
- [x] **Move relay + mirror off the Mac mini (DONE 2026-08-16)** — dedicated
|
||||||
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
|
Debian 13 LXC on Proxmox via community-scripts/ProxmoxVE `ct/debian.sh`
|
||||||
(CT ~110, hostname `fuelboard-relay`, 1 core/1 GB/8 GB, static IP
|
(CT **111**, hostname **`fuelboard-relay`**, 1 core/512 MB/2 GB, static IP
|
||||||
~192.168.1.150, plain LXC — NO Docker layer). Scope: relay AND mirror both
|
**192.168.1.113**, plain LXC — NO Docker layer). Scope: relay AND mirror
|
||||||
move (full Mac independence; relay binds loopback only, GitHub is the only
|
both moved (full Mac independence; relays bind **loopback only**, GitHub is
|
||||||
public surface). Steps: user creates CT on pve → install Python (trixie =
|
the only public surface). What landed: Python 3.13.5 (trixie) boots both
|
||||||
3.13 vs Mac 3.14 — verify relay boots) + relay source + `.env` (GOV.UK
|
relays (relay 8788 demo/CSV + relay-api 8789 `source: api`, 8,024 stations,
|
||||||
token) → copy deploy key `~/.ssh/fuelboard_deploy` + `github-fuelboard-data`
|
~2 min first sync, 0 failures); source at `/root/workspace/fuelboard-{relay,relay-api}`
|
||||||
ssh config → systemd `fuelboard-relay.service` (Restart=always,
|
(venvs + pip, `.env` chmod 600); deploy key `fuelboard_deploy` +
|
||||||
127.0.0.1:8789/8788) + `fuelboard-mirror.timer` (10:00 + 16:00) + service →
|
`github-fuelboard-data` ssh config + `fuelboard-data` clone at
|
||||||
relocate `mirror_push.py` (RELAY line → localhost on CT) + fuelboard-data
|
`/root/workspace/fuelboard-data` (origin = ssh alias, green + synced);
|
||||||
clone → verify one live push → remove Mac launchd
|
`mirror_push.py` → `/root/scripts/mirror_push.py` (RELAY line already
|
||||||
`com.apt.fuelboard-{relay,mirror}`. Open: confirm IP; logs move to
|
`127.0.0.1:8789`); systemd `fuelboard-relay.service` +
|
||||||
journalctl.
|
`fuelboard-relay-api.service` (Restart=always, `--host 127.0.0.1`) +
|
||||||
|
`fuelboard-mirror.service` (oneshot) + `.timer` (10:00 + 16:00, Persistent,
|
||||||
|
armed — next Mon 10:00); first live push VERIFIED from outside
|
||||||
|
(commit `735a292`, latest.json `data_updated 16:50:53Z`); Mac launchd
|
||||||
|
`com.apt.fuelboard-{relay,relay-api,mirror}` unloaded + plists parked in
|
||||||
|
`~/Library/LaunchAgents/fuelboard-migrated-to-ct/`; editor 8790 + IPA 8765
|
||||||
|
stay on the Mac; price watchdog `relay_price_watchdog.py` now fetches via
|
||||||
|
SSH (`~/.ssh/fuelboard_ct` → `/root/scripts/watchdog_fetch.py`). Logs via
|
||||||
|
`journalctl -u fuelboard-*` on the CT; SSH into CT: `ssh -i ~/.ssh/fuelboard_ct root@192.168.1.113`.
|
||||||
|
**Open: rootfs is 2 GB (1.1 GB free) — mirror retention ~940 MB will get
|
||||||
|
tight; run `pct resize 111 rootfs 8G` on pve when convenient.**
|
||||||
|
|
||||||
- [x] **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
|
||||||
|
|||||||
@@ -15,4 +15,14 @@ enum BundledDumpProvider {
|
|||||||
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
guard let asset = NSDataAsset(name: "FuelBoardDump") else { return nil }
|
||||||
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
return try? FuelPriceProvider.decodeStations(from: asset.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The GOV.UK `data_updated` stamp from the bundled envelope (ISO 8601).
|
||||||
|
/// Labels the offline-data banner honestly ("Offline data from 15 Aug")
|
||||||
|
/// instead of presenting the build's snapshot as live.
|
||||||
|
static var dataUpdatedStamp: String? {
|
||||||
|
guard let asset = NSDataAsset(name: "FuelBoardDump"),
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: asset.data)
|
||||||
|
as? [String: Any] else { return nil }
|
||||||
|
return obj["data_updated"] as? String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-5
@@ -32,6 +32,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var statusMessage = ""
|
@State private var statusMessage = ""
|
||||||
|
/// What data is on screen, driving which (if any) status banner shows
|
||||||
|
/// above the tabs:
|
||||||
|
/// - `.live`: fetched or cached data — nothing to say.
|
||||||
|
/// - `.offlineDump(date)`: serving the BUNDLED no-network snapshot —
|
||||||
|
/// the banner labels it honestly with the snapshot's own date.
|
||||||
|
/// - `.connectionProblem`: fetch failed but a saved cache is showing —
|
||||||
|
/// the banner says to check connectivity (tap = retry).
|
||||||
|
enum DataSourceStatus: Equatable {
|
||||||
|
case live
|
||||||
|
case offlineDump(date: String)
|
||||||
|
case connectionProblem
|
||||||
|
}
|
||||||
|
@State private var dataStatus: DataSourceStatus = .live
|
||||||
@State private var showOnboarding = false
|
@State private var showOnboarding = false
|
||||||
@State private var showWidgetMock = false
|
@State private var showWidgetMock = false
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
@@ -139,7 +152,31 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
VStack(spacing: 0) {
|
||||||
|
switch dataStatus {
|
||||||
|
case .offlineDump(let date):
|
||||||
|
let title = offlineTitle(date: date)
|
||||||
|
statusBanner(
|
||||||
|
icon: "wifi.slash",
|
||||||
|
tint: .orange,
|
||||||
|
title: title,
|
||||||
|
subtitle: NSLocalizedString("Pull to refresh on the Stations tab", comment: ""),
|
||||||
|
accessibilityLabel: date.isEmpty
|
||||||
|
? NSLocalizedString("Offline data. Pull to refresh on the Stations tab", comment: "")
|
||||||
|
: String(format: NSLocalizedString("Offline data from %@. Pull to refresh on the Stations tab", comment: ""), date)
|
||||||
|
)
|
||||||
|
case .connectionProblem:
|
||||||
|
statusBanner(
|
||||||
|
icon: "wifi.exclamationmark",
|
||||||
|
tint: .red,
|
||||||
|
title: NSLocalizedString("Check your internet connection", comment: ""),
|
||||||
|
subtitle: NSLocalizedString("Tap to try again", comment: ""),
|
||||||
|
accessibilityLabel: NSLocalizedString("Check your internet connection. Tap to try again", comment: "")
|
||||||
|
)
|
||||||
|
case .live:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
TabView(selection: $selectedTab) {
|
||||||
stationsTab
|
stationsTab
|
||||||
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
.tabItem { Label("Stations", systemImage: "fuelpump.fill") }
|
||||||
.tag(0)
|
.tag(0)
|
||||||
@@ -204,6 +241,19 @@ struct ContentView: View {
|
|||||||
default: selectedTab = 0
|
default: selectedTab = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// `-forceOfflineDump` / `-forceConnectionProblem` simulate the two
|
||||||
|
// failure legs for the screenshot harness. The auto-refresh below
|
||||||
|
// is skipped so the banner stays up (a live fetch would clear it).
|
||||||
|
if args.contains("-forceOfflineDump") {
|
||||||
|
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
}
|
||||||
|
if args.contains("-forceConnectionProblem") {
|
||||||
|
stations = FuelStore.loadStations().isEmpty
|
||||||
|
? (BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations)
|
||||||
|
: FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
|
}
|
||||||
// Onboarding runs first on a fresh install — it owns the initial
|
// Onboarding runs first on a fresh install — it owns the initial
|
||||||
// permission prompts (location, notifications, and the data/local
|
// permission prompts (location, notifications, and the data/local
|
||||||
// network probe on the Data page). Location tracking and the first
|
// network probe on the Data page). Location tracking and the first
|
||||||
@@ -233,7 +283,10 @@ struct ContentView: View {
|
|||||||
monitor.setEnabled(alertsEnabled)
|
monitor.setEnabled(alertsEnabled)
|
||||||
updateLiveActivity()
|
updateLiveActivity()
|
||||||
// Refresh only when the cache is stale (twice-a-day policy).
|
// Refresh only when the cache is stale (twice-a-day policy).
|
||||||
Task { await refresh() }
|
// Skipped under the force-* hooks so the banner stays up.
|
||||||
|
if !args.contains("-forceOfflineDump") && !args.contains("-forceConnectionProblem") && !args.contains("-forceHistoryFailure") {
|
||||||
|
Task { await refresh() }
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
showOnboarding = true
|
showOnboarding = true
|
||||||
}
|
}
|
||||||
@@ -367,6 +420,59 @@ struct ContentView: View {
|
|||||||
// so reading storage here could push the OLD style.
|
// so reading storage here could push the OLD style.
|
||||||
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
updateLiveActivity(priceDisplayStyleOverride: newValue)
|
||||||
}
|
}
|
||||||
|
} // VStack: status banner + TabView
|
||||||
|
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dataStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The banner title for the bundled-snapshot case: date when the stamp
|
||||||
|
/// parsed, plain "Offline data" otherwise.
|
||||||
|
private func offlineTitle(date: String) -> String {
|
||||||
|
date.isEmpty
|
||||||
|
? NSLocalizedString("Offline data", comment: "")
|
||||||
|
: String(format: NSLocalizedString("Offline data from %@", comment: ""), date)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared status-strip chrome: a tappable card pinned above the tabs.
|
||||||
|
/// Tapping retries the live fetch from ANY screen — no pull gesture
|
||||||
|
/// needed, so the offline banner isn't trapped on the Stations tab.
|
||||||
|
private func statusBanner(icon: String, tint: Color, title: String, subtitle: String, accessibilityLabel: String) -> some View {
|
||||||
|
Button {
|
||||||
|
Task { await refresh(force: true) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
.frame(width: 30)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(title)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(tint)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(tint.opacity(0.35), lineWidth: 1)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
.transition(.move(edge: .top).combined(with: .opacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes the current best-in-radius station into the Live Activity.
|
/// Pushes the current best-in-radius station into the Live Activity.
|
||||||
@@ -420,7 +526,9 @@ struct ContentView: View {
|
|||||||
distanceUnit: distanceUnit,
|
distanceUnit: distanceUnit,
|
||||||
priceDisplayStyle: priceDisplayStyle,
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
onToggleFavourite: toggleFavourite,
|
onToggleFavourite: toggleFavourite,
|
||||||
onReorder: reorderFavourites
|
onReorder: reorderFavourites,
|
||||||
|
onHistoryUnavailable: { if dataStatus == .live { dataStatus = .connectionProblem } },
|
||||||
|
onHistoryRecovered: { if dataStatus == .connectionProblem { dataStatus = .live } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -504,14 +612,26 @@ struct ContentView: View {
|
|||||||
FuelStore.saveFavourites(refreshedFavourites)
|
FuelStore.saveFavourites(refreshedFavourites)
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
WidgetCenter.shared.reloadAllTimelines()
|
||||||
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
statusMessage = "Loaded \(fetched.count) stations · \(Date().formatted(date: .omitted, time: .shortened))"
|
||||||
|
// Live data restored — any status banner no longer applies.
|
||||||
|
dataStatus = .live
|
||||||
} catch {
|
} catch {
|
||||||
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
statusMessage = "Live fetch failed: \(error.localizedDescription). Showing cached data."
|
||||||
if FuelStore.loadStations().isEmpty {
|
if FuelStore.loadStations().isEmpty {
|
||||||
// No cached prices — last resort is the bundled REAL dump
|
// No cached prices — last resort is the bundled REAL dump
|
||||||
// (stale but genuine), then the demo sample set.
|
// (stale but genuine), then the demo sample set. The banner
|
||||||
stations = BundledDumpProvider.stations ?? SampleFuelProvider.sampleStations
|
// labels the bundled snapshot honestly with its own date.
|
||||||
|
if let dump = BundledDumpProvider.stations {
|
||||||
|
stations = dump
|
||||||
|
dataStatus = .offlineDump(date: FuelStore.offlineDataLabel(from: BundledDumpProvider.dataUpdatedStamp) ?? "")
|
||||||
|
} else {
|
||||||
|
stations = SampleFuelProvider.sampleStations
|
||||||
|
dataStatus = .live
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Saved prices are still on screen — but the fetch failed, so
|
||||||
|
// say so: a stale cache must not look like a live app.
|
||||||
stations = FuelStore.loadStations()
|
stations = FuelStore.loadStations()
|
||||||
|
dataStatus = .connectionProblem
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Keep monitor geofences in sync with the freshest data.
|
// Keep monitor geofences in sync with the freshest data.
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ struct FavouritesView: View {
|
|||||||
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
var onToggleFavourite: (FuelStation, FuelType) -> Void = { _, _ in }
|
||||||
/// Persists a reordered favourites array (after drag-and-drop).
|
/// Persists a reordered favourites array (after drag-and-drop).
|
||||||
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
var onReorder: ([FavouriteEntry]) -> Void = { _ in }
|
||||||
|
/// Propagated from ContentView — Trends' history failure with no data
|
||||||
|
/// raises the global connection banner; recovery clears it.
|
||||||
|
var onHistoryUnavailable: (() -> Void)? = nil
|
||||||
|
var onHistoryRecovered: (() -> Void)? = nil
|
||||||
|
|
||||||
/// Fuel types that currently have at least one favourite — these are the
|
/// Fuel types that currently have at least one favourite — these are the
|
||||||
/// only tabs shown (a fuel with no favourites gets no tab).
|
/// only tabs shown (a fuel with no favourites gets no tab).
|
||||||
@@ -62,7 +66,9 @@ struct FavouritesView: View {
|
|||||||
distanceUnit: DistanceUnit,
|
distanceUnit: DistanceUnit,
|
||||||
priceDisplayStyle: PriceDisplayStyle,
|
priceDisplayStyle: PriceDisplayStyle,
|
||||||
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
onToggleFavourite: @escaping (FuelStation, FuelType) -> Void = { _, _ in },
|
||||||
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in }) {
|
onReorder: @escaping ([FavouriteEntry]) -> Void = { _ in },
|
||||||
|
onHistoryUnavailable: (() -> Void)? = nil,
|
||||||
|
onHistoryRecovered: (() -> Void)? = nil) {
|
||||||
self.favourites = favourites
|
self.favourites = favourites
|
||||||
self.selectedFuel = selectedFuel
|
self.selectedFuel = selectedFuel
|
||||||
self.location = location
|
self.location = location
|
||||||
@@ -70,6 +76,8 @@ struct FavouritesView: View {
|
|||||||
self.priceDisplayStyle = priceDisplayStyle
|
self.priceDisplayStyle = priceDisplayStyle
|
||||||
self.onToggleFavourite = onToggleFavourite
|
self.onToggleFavourite = onToggleFavourite
|
||||||
self.onReorder = onReorder
|
self.onReorder = onReorder
|
||||||
|
self.onHistoryUnavailable = onHistoryUnavailable
|
||||||
|
self.onHistoryRecovered = onHistoryRecovered
|
||||||
_fuel = State(initialValue: selectedFuel)
|
_fuel = State(initialValue: selectedFuel)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,9 +166,18 @@ struct FavouritesView: View {
|
|||||||
TrendsView(
|
TrendsView(
|
||||||
favourites: favourites,
|
favourites: favourites,
|
||||||
selectedFuel: activeFuel,
|
selectedFuel: activeFuel,
|
||||||
priceDisplayStyle: priceDisplayStyle
|
priceDisplayStyle: priceDisplayStyle,
|
||||||
|
onHistoryUnavailable: onHistoryUnavailable,
|
||||||
|
onHistoryRecovered: onHistoryRecovered
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
// QA hook: launch with `-showTrends` to open the sheet
|
||||||
|
// without a tap (same pattern as -showKeySheet).
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("-showTrends") {
|
||||||
|
showTrends = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -298,25 +298,47 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
ForEach(TipStore.tiers) { tier in
|
VStack(spacing: 10) {
|
||||||
Button {
|
ForEach(TipStore.tiers) { tier in
|
||||||
Task { await tipStore.purchase(tier) }
|
Button {
|
||||||
} label: {
|
Task { await tipStore.purchase(tier) }
|
||||||
HStack {
|
} label: {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
HStack(spacing: 12) {
|
||||||
Text(tier.name)
|
Image(systemName: "fuelpump.fill")
|
||||||
Text(tier.blurb)
|
.font(.title3)
|
||||||
.font(.caption)
|
.foregroundStyle(tier.accent)
|
||||||
.foregroundStyle(.secondary)
|
.frame(width: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(tier.name)
|
||||||
|
.font(.headline)
|
||||||
|
Text(tier.blurb)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Text(tipStore.displayPrice(for: tier))
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundStyle(tier.accent)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Spacer()
|
.padding(.horizontal, 14)
|
||||||
Text(tipStore.displayPrice(for: tier))
|
.padding(.vertical, 12)
|
||||||
.foregroundStyle(.secondary)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.monospacedDigit()
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(tier.accent.opacity(0.05))
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(tipStore.purchaseInProgress)
|
||||||
}
|
}
|
||||||
.disabled(tipStore.purchaseInProgress)
|
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
} header: {
|
} header: {
|
||||||
Text("Support FuelBoard")
|
Text("Support FuelBoard")
|
||||||
} footer: {
|
} footer: {
|
||||||
@@ -569,15 +591,19 @@ final class TipStore: ObservableObject {
|
|||||||
let name: String
|
let name: String
|
||||||
let blurb: String
|
let blurb: String
|
||||||
let fallbackPrice: String
|
let fallbackPrice: String
|
||||||
|
let accent: Color // card/price accent (matches the fuel palette)
|
||||||
}
|
}
|
||||||
|
|
||||||
static let tiers: [TipTier] = [
|
static let tiers: [TipTier] = [
|
||||||
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
TipTier(id: "com.apt.fuelboard.tip099", name: "Splash & Dash",
|
||||||
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99"),
|
blurb: "Just enough to keep things moving.", fallbackPrice: "£0.99",
|
||||||
|
accent: Color(red: 0.39, green: 0.82, blue: 1.0)), // #64D2FF
|
||||||
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
TipTier(id: "com.apt.fuelboard.tip299", name: "Half a Tank",
|
||||||
blurb: "A generous top-up for development.", fallbackPrice: "£2.99"),
|
blurb: "A generous top-up for development.", fallbackPrice: "£2.99",
|
||||||
|
accent: Color(red: 0.19, green: 0.82, blue: 0.35)), // #30D158
|
||||||
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
TipTier(id: "com.apt.fuelboard.tip499", name: "Fill 'Er Up",
|
||||||
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99"),
|
blurb: "Keeping the app on the road.", fallbackPrice: "£4.99",
|
||||||
|
accent: Color(red: 1.0, green: 0.84, blue: 0.04)), // #FFD60A
|
||||||
]
|
]
|
||||||
|
|
||||||
@Published private(set) var products: [String: Product] = [:]
|
@Published private(set) var products: [String: Product] = [:]
|
||||||
|
|||||||
@@ -193,39 +193,58 @@ struct StationsView: View {
|
|||||||
.sheet(isPresented: $showKey) {
|
.sheet(isPresented: $showKey) {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
Section("Key") {
|
Section {
|
||||||
HStack(spacing: 8) {
|
VStack(spacing: 10) {
|
||||||
Circle().fill(.green).frame(width: 12, height: 12)
|
keyCard(
|
||||||
Text("Best value — within 1.5p of the cheapest")
|
icon: "checkmark.circle.fill", color: .green,
|
||||||
.font(.caption)
|
title: "Best value", blurb: "Within 1.5p of the cheapest",
|
||||||
}
|
threshold: "≤1.5p")
|
||||||
HStack(spacing: 8) {
|
keyCard(
|
||||||
Circle().fill(.orange).frame(width: 12, height: 12)
|
icon: "equal.circle.fill", color: .orange,
|
||||||
Text("Okay — within 4p of the cheapest")
|
title: "Okay", blurb: "Within 4p of the cheapest",
|
||||||
.font(.caption)
|
threshold: "≤4p")
|
||||||
}
|
keyCard(
|
||||||
HStack(spacing: 8) {
|
icon: "exclamationmark.circle.fill", color: .red,
|
||||||
Circle().fill(.red).frame(width: 12, height: 12)
|
title: "Pricey", blurb: "More than 4p over the cheapest",
|
||||||
Text("Pricey — more than 4p over the cheapest")
|
threshold: ">4p")
|
||||||
.font(.caption)
|
Rectangle()
|
||||||
}
|
.fill(Color(.separator))
|
||||||
HStack(spacing: 8) {
|
.frame(height: 0.5)
|
||||||
Text("TOP")
|
.padding(.vertical, 2)
|
||||||
.font(.caption2.bold())
|
HStack(spacing: 12) {
|
||||||
.padding(.horizontal, 5)
|
Text("TOP")
|
||||||
.padding(.vertical, 1)
|
.font(.caption2.bold())
|
||||||
.background(Capsule().fill(.blue.opacity(0.15)))
|
.padding(.horizontal, 7)
|
||||||
.foregroundStyle(.blue)
|
.padding(.vertical, 3)
|
||||||
Text("Top result for the current sort")
|
.background(Capsule().fill(.blue.opacity(0.16)))
|
||||||
.font(.caption)
|
.foregroundStyle(.blue)
|
||||||
}
|
Text("Top result for the current sort")
|
||||||
HStack(spacing: 8) {
|
.font(.subheadline.weight(.semibold))
|
||||||
Image(systemName: "star.fill")
|
Spacer(minLength: 0)
|
||||||
.font(.caption2)
|
}
|
||||||
.foregroundStyle(.yellow)
|
.padding(.horizontal, 14)
|
||||||
Text("Star a station to add it to Favourites")
|
.padding(.vertical, 12)
|
||||||
.font(.caption)
|
.keyCardFill(color: .blue)
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: "star.fill")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.yellow)
|
||||||
|
.frame(width: 34)
|
||||||
|
Text("Star a station to add it to Favourites")
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.keyCardFill(color: .yellow)
|
||||||
|
Text("Colours match each station's rating on the list")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Key")
|
.navigationTitle("Key")
|
||||||
@@ -236,10 +255,52 @@ struct StationsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.presentationDetents([.medium])
|
.presentationDetents([.fraction(0.6)])
|
||||||
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One price-rating card for the Key sheet: accent icon, title + blurb,
|
||||||
|
/// and the rating threshold in the accent colour (mirrors the tip cards).
|
||||||
|
private func keyCard(icon: String, color: Color, title: String, blurb: String, threshold: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.frame(width: 34)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(LocalizedStringKey(title)).font(.headline)
|
||||||
|
Text(LocalizedStringKey(blurb))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle().fill(color).frame(width: 10, height: 10)
|
||||||
|
Text(threshold)
|
||||||
|
.font(.headline.weight(.bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.keyCardFill(color: color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Card background shared by the Key sheet cards — subtle accent tint over
|
||||||
|
/// the grouped background, matching the tip-section card style.
|
||||||
|
private extension View {
|
||||||
|
func keyCardFill(color: Color) -> some View {
|
||||||
|
background(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 12).fill(color.opacity(0.08)))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
// MARK: - Fuel-type iconography (app target only — FuelStore.swift is Foundation-only)
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ struct TrendsView: View {
|
|||||||
let selectedFuel: FuelType
|
let selectedFuel: FuelType
|
||||||
let priceDisplayStyle: PriceDisplayStyle
|
let priceDisplayStyle: PriceDisplayStyle
|
||||||
|
|
||||||
|
/// Propagated up to ContentView so a price-history fetch that fails with
|
||||||
|
/// NO data raises the global connection banner (same red banner as the
|
||||||
|
/// stations fetch). `onHistoryRecovered` fires once data loads again.
|
||||||
|
var onHistoryUnavailable: (() -> Void)? = nil
|
||||||
|
var onHistoryRecovered: (() -> Void)? = nil
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var fuel: FuelType = .e10
|
@State private var fuel: FuelType = .e10
|
||||||
@@ -80,6 +86,15 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func load() async {
|
private func load() async {
|
||||||
|
// QA hook: force the unreachable state for screenshots (same pattern
|
||||||
|
// as -showTrends / -forceConnectionProblem). Runs before the fetch so
|
||||||
|
// the retry state renders immediately with no spinner flash.
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("-forceHistoryFailure") {
|
||||||
|
series = []
|
||||||
|
loadFailed = true
|
||||||
|
onHistoryUnavailable?()
|
||||||
|
return
|
||||||
|
}
|
||||||
isLoading = true
|
isLoading = true
|
||||||
loadFailed = false
|
loadFailed = false
|
||||||
defer { isLoading = false }
|
defer { isLoading = false }
|
||||||
@@ -98,6 +113,14 @@ struct TrendsView: View {
|
|||||||
loadFailed = firstSnapshot == nil
|
loadFailed = firstSnapshot == nil
|
||||||
}
|
}
|
||||||
series = fetched
|
series = fetched
|
||||||
|
// A failure with no data IS a connection problem — raise the global
|
||||||
|
// banner so the user isn't stuck with a silent retry state. Success
|
||||||
|
// clears it (only if the banner is the connection banner).
|
||||||
|
if loadFailed {
|
||||||
|
onHistoryUnavailable?()
|
||||||
|
} else if hasAnyData {
|
||||||
|
onHistoryRecovered?()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func yLabel(_ pence: Double) -> String {
|
private func yLabel(_ pence: Double) -> String {
|
||||||
@@ -175,6 +198,7 @@ struct TrendsView: View {
|
|||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
chart
|
chart
|
||||||
legend
|
legend
|
||||||
|
legendFooter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,13 +229,21 @@ struct TrendsView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var chart: some View {
|
private var chart: some View {
|
||||||
Chart(displaySeries) { history in
|
// NOTE: each LineMark MUST carry an explicit `series:` — without it
|
||||||
ForEach(history.points) { point in
|
// Swift Charts merges every station's points into ONE polyline
|
||||||
LineMark(
|
// (points connect across stations, so only the first station's line
|
||||||
x: .value("Date", point.date),
|
// is recognisable). The outer ForEach keeps one chart with N series;
|
||||||
y: .value("Price", point.pence)
|
// per-mark foregroundStyle then colours each series from the palette.
|
||||||
)
|
Chart {
|
||||||
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
ForEach(displaySeries) { history in
|
||||||
|
ForEach(history.points) { point in
|
||||||
|
LineMark(
|
||||||
|
x: .value("Date", point.date),
|
||||||
|
y: .value("Price", point.pence),
|
||||||
|
series: .value("Station", history.name)
|
||||||
|
)
|
||||||
|
.foregroundStyle(seriesColor(index(of: history.stationID)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.chartXAxis {
|
.chartXAxis {
|
||||||
@@ -248,10 +280,41 @@ struct TrendsView: View {
|
|||||||
Text(history.name)
|
Text(history.name)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
if let avg = FuelHistoryStore.averagePence(history.points) {
|
||||||
|
Text(legendFigure(avg))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 4)
|
.padding(.horizontal, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The bracket figure in the chart key: absolute pence in Price mode,
|
||||||
|
/// signed pence above the day's cheapest in vs-cheapest mode — always
|
||||||
|
/// pence, matching the list rows (the y-axis follows the display toggle).
|
||||||
|
private func legendFigure(_ pence: Double) -> String {
|
||||||
|
switch mode {
|
||||||
|
case .price:
|
||||||
|
return String(format: "%.1fp", pence)
|
||||||
|
case .vsCheapest:
|
||||||
|
return pence > 0 ? String(format: "+%.1fp", pence) : String(format: "%.1fp", pence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One-line descriptor under the key so the brackets are self-explanatory.
|
||||||
|
private var legendFooter: some View {
|
||||||
|
Group {
|
||||||
|
if mode == .price {
|
||||||
|
Text("Average price over the days shown")
|
||||||
|
} else {
|
||||||
|
Text("Average pence above the day's cheapest favourite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,11 +23,15 @@
|
|||||||
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
|
||||||
"All %lld stations shown" = "All %lld stations shown";
|
"All %lld stations shown" = "All %lld stations shown";
|
||||||
"Key" = "Key";
|
"Key" = "Key";
|
||||||
"Best value — within 1.5p of the cheapest" = "Best value — within 1.5p of the cheapest";
|
"Best value" = "Best value";
|
||||||
"Okay — within 4p of the cheapest" = "Okay — within 4p of the cheapest";
|
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
|
||||||
"Pricey — more than 4p over the cheapest" = "Pricey — more than 4p over the cheapest";
|
"Okay" = "Okay";
|
||||||
|
"Within 4p of the cheapest" = "Within 4p of the cheapest";
|
||||||
|
"Pricey" = "Pricey";
|
||||||
|
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Top result for the current sort" = "Top result for the current sort";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
||||||
|
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
|
||||||
|
|
||||||
/* Favourites tab */
|
/* Favourites tab */
|
||||||
"No favourites yet" = "No favourites yet";
|
"No favourites yet" = "No favourites yet";
|
||||||
@@ -152,3 +156,15 @@
|
|||||||
"No price history yet" = "No price history yet";
|
"No price history yet" = "No price history yet";
|
||||||
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
|
"First snapshot %@ — a few days are needed to draw a trend." = "First snapshot %@ — a few days are needed to draw a trend.";
|
||||||
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
|
"Prices are recorded each day FuelBoard's relay runs — check back in a few days." = "Prices are recorded each day FuelBoard's relay runs — check back in a few days.";
|
||||||
|
"Average price over the days shown" = "Average price over the days shown";
|
||||||
|
"Average pence above the day's cheapest favourite" = "Average pence above the day's cheapest favourite";
|
||||||
|
|
||||||
|
/* Offline data banner */
|
||||||
|
"Offline data from %@" = "Offline data from %@";
|
||||||
|
"Offline data" = "Offline data";
|
||||||
|
"Pull to refresh on the Stations tab" = "Pull to refresh on the Stations tab";
|
||||||
|
"Offline data from %@. Pull to refresh on the Stations tab" = "Offline data from %@. Pull to refresh on the Stations tab";
|
||||||
|
"Offline data. Pull to refresh on the Stations tab" = "Offline data. Pull to refresh on the Stations tab";
|
||||||
|
"Check your internet connection" = "Check your internet connection";
|
||||||
|
"Tap to try again" = "Tap to try again";
|
||||||
|
"Check your internet connection. Tap to try again" = "Check your internet connection. Tap to try again";
|
||||||
|
|||||||
@@ -587,3 +587,22 @@ final class PriceDisplayTests: XCTestCase {
|
|||||||
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
|
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Offline data banner label
|
||||||
|
|
||||||
|
final class OfflineDataLabelTests: XCTestCase {
|
||||||
|
func testOfflineDataLabelFormatsStampWithFractionalSeconds() {
|
||||||
|
// The bundled dump's real envelope stamp.
|
||||||
|
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOfflineDataLabelToleratesPlainISODate() {
|
||||||
|
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testOfflineDataLabelNilWhenMissingOrUnparseable() {
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: nil))
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: ""))
|
||||||
|
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -164,6 +164,29 @@ final class FuelHistoryTests: XCTestCase {
|
|||||||
XCTAssertEqual(pruned.count, days.count)
|
XCTAssertEqual(pruned.count, days.count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Chart-key average
|
||||||
|
|
||||||
|
func testAveragePenceEmptyIsNil() {
|
||||||
|
XCTAssertNil(FuelHistoryStore.averagePence([]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAveragePenceSinglePoint() {
|
||||||
|
let d = FuelHistoryStore.date(fromDay: "2026-08-15")!
|
||||||
|
XCTAssertEqual(FuelHistoryStore.averagePence([PricePoint(date: d, pence: 156.7)]), 156.7)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testAveragePenceMultiplePoints() {
|
||||||
|
let d1 = FuelHistoryStore.date(fromDay: "2026-08-15")!
|
||||||
|
let d2 = FuelHistoryStore.date(fromDay: "2026-08-16")!
|
||||||
|
// (153.9 + 156.7) / 2 = 155.3 — exact in binary? 153.9+156.7=310.6, /2=155.3
|
||||||
|
let avg = FuelHistoryStore.averagePence([
|
||||||
|
PricePoint(date: d1, pence: 153.9),
|
||||||
|
PricePoint(date: d2, pence: 156.7),
|
||||||
|
])
|
||||||
|
XCTAssertNotNil(avg)
|
||||||
|
XCTAssertEqual(avg!, 155.3, accuracy: 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Mirror URLs
|
// MARK: Mirror URLs
|
||||||
|
|
||||||
func testHistoryFileURLKeepsBaseLastSegment() {
|
func testHistoryFileURLKeepsBaseLastSegment() {
|
||||||
|
|||||||
@@ -148,6 +148,13 @@ enum FuelHistoryStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Average pence over a series' points; nil when there are no points
|
||||||
|
/// (a station with no data shows no bracket figure in the chart key).
|
||||||
|
static func averagePence(_ points: [PricePoint]) -> Double? {
|
||||||
|
guard !points.isEmpty else { return nil }
|
||||||
|
return points.reduce(0) { $0 + $1.pence } / Double(points.count)
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebase every station's series so each day's CHEAPEST favourite sits at
|
/// Rebase every station's series so each day's CHEAPEST favourite sits at
|
||||||
/// 0 and the others show signed pence above it (mirrors the list's
|
/// 0 and the others show signed pence above it (mirrors the list's
|
||||||
/// baseline delta pattern). Days where a station has no point are gaps.
|
/// baseline delta pattern). Days where a station has no point are gaps.
|
||||||
|
|||||||
@@ -782,6 +782,27 @@ struct FuelStore {
|
|||||||
loadString(service: dataUpdatedKey)
|
loadString(service: dataUpdatedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A short label for the offline-data banner: "15 Aug" from a GOV.UK
|
||||||
|
/// `data_updated` ISO 8601 stamp (with or without fractional seconds).
|
||||||
|
/// Nil when the stamp is missing or unparseable — callers then hide the
|
||||||
|
/// banner rather than label data with a wrong date.
|
||||||
|
static func offlineDataLabel(from stamp: String?) -> String? {
|
||||||
|
guard let stamp, !stamp.isEmpty else { return nil }
|
||||||
|
let withFraction = ISO8601DateFormatter()
|
||||||
|
withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||||
|
var date = withFraction.date(from: stamp)
|
||||||
|
if date == nil {
|
||||||
|
let plain = ISO8601DateFormatter()
|
||||||
|
plain.formatOptions = [.withInternetDateTime]
|
||||||
|
date = plain.date(from: stamp)
|
||||||
|
}
|
||||||
|
guard let date else { return nil }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "d MMM"
|
||||||
|
formatter.locale = Locale(identifier: "en_GB")
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
|
||||||
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
/// True when the cached data is fresh enough that a scheduled auto-refresh
|
||||||
/// should be skipped (twice-a-day policy).
|
/// should be skipped (twice-a-day policy).
|
||||||
static var isCacheFresh: Bool {
|
static var isCacheFresh: Bool {
|
||||||
|
|||||||
Reference in New Issue
Block a user