diff --git a/appleApp/FTW-Info.plist b/appleApp/FTW-Info.plist index 03bab63..0c887b4 100644 --- a/appleApp/FTW-Info.plist +++ b/appleApp/FTW-Info.plist @@ -4,6 +4,14 @@ FTWAppGroup $(FTW_APP_GROUP) + BGTaskSchedulerPermittedIdentifiers + + energy.ftw.app.prices + + UIBackgroundModes + + fetch + CFBundleLocalizations en diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index b7fc0de..e0591e1 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -1,5 +1,7 @@ +import BackgroundTasks import FTWDemo import FTWKit +import os import SwiftUI import WidgetKit @@ -50,9 +52,60 @@ struct FTWApp: App { // switcher: slow the stream, keep the session. Only the // background closes it. app.setVisible(phase == .active) - if phase == .background { app.suspend() } + PricesInBackground.inBackground = phase == .background + if phase == .background { + app.suspend() + Task { await PricesInBackground.schedule(app) } + } } } + .backgroundTask(.appRefresh(PricesInBackground.id)) { [app] in + await PricesInBackground.run(app) + } + } +} + +/// Tomorrow's prices for the widgets without opening the app. iOS wakes the +/// app shortly after 13:05 if it allows it at all; the app opens a session, +/// asks for prices, leaves them in the App Group and closes again. No silent +/// push and no server: when iOS does not wake the app, the widgets wait for +/// the next time it is opened. +@MainActor +enum PricesInBackground { + /// Listed under BGTaskSchedulerPermittedIdentifiers in FTW-Info.plist. + static let id = "energy.ftw.app.prices" + static let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + /// Set from the scene phase. A cold launch for this task has no scene, + /// so it starts in the background. + static var inBackground = true + + static func schedule(_ app: AppModel) async { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + let pricesEndMs = await site.sharedPricesEndMs() + let nowMs = Date.now.timeIntervalSince1970 * 1000 + let at = Date(timeIntervalSince1970: PriceRefresh.next(afterMs: nowMs, pricesEndMs: pricesEndMs) / 1000) + let request = BGAppRefreshTaskRequest(identifier: id) + request.earliestBeginDate = at + do { + try BGTaskScheduler.shared.submit(request) + log.info("background prices asked for after \(at.formatted(date: .abbreviated, time: .shortened), privacy: .public)") + } catch { + // The simulator, Background App Refresh turned off, or Low Power + // Mode. The widgets then wait for the app to be opened. + log.notice("background prices not scheduled: \(error.localizedDescription, privacy: .public)") + } + } + + static func run(_ app: AppModel) async { + guard let site = app.site, site.siteId != nil, !app.isDemo else { return } + log.info("background prices: woken") + // A new carrier if the last one was closed, or a fresh stream. + await app.refresh() + let prices = await site.refreshSharedPrices() + log.info("background prices: \(prices == nil ? "nothing written" : "\(prices!.slots.count) slots written", privacy: .public)") + // Someone may have opened the app meanwhile; their session stays. + if inBackground { app.suspend() } + await schedule(app) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 62eab3e..12a6db6 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1,6 +1,9 @@ import Foundation import FTWShared import Observation +import os + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") /// The bridge between the session and the screens. /// @@ -40,6 +43,12 @@ public final class SiteModel { /// What widgets read. Nil for the demo and in tests. @ObservationIgnored private let shared: AppGroupFiles? @ObservationIgnored private var lastCars: (entry: CarEntry, atMs: Double)? + /// The prices last left for the widgets, so the same prices again are + /// neither written nor reloaded. + @ObservationIgnored private var lastPrices: PriceEntry? + /// The latest write to `shared`. Each waits for the one before, so they + /// land in the order they were asked for. + @ObservationIgnored private var writing: Task? /// Called after every write to `shared`, so the app can wake its widgets. @ObservationIgnored public var sharedWritten: (@MainActor () -> Void)? @ObservationIgnored private var attemptStartedAtMs: Double @@ -367,17 +376,24 @@ public final class SiteModel { /// each of those. static let carRewriteMs: Double = 5 * 60_000 - /// Leave today's and tomorrow's prices for the widgets. - func sharePrices(_ prices: Prices, fromMs: Double) { - guard let siteId, let shared else { return } + /// Leave today's and tomorrow's prices for the widgets. The same prices + /// again are skipped: the widgets' timeline already holds them, and a + /// reload would only redraw it. Returns the write, for a caller that + /// must not end before it lands. + @discardableResult + func sharePrices(_ prices: Prices, fromMs: Double) -> Task? { + guard let siteId, let shared else { return nil } + let entry = PriceEntry(fromMs: fromMs, prices: prices) + guard entry != lastPrices else { return nil } + lastPrices = entry let record = CacheRecord( siteId: siteId, carrier: session.carrier, srcState: prices.stale ? .stale : .live, readAtMs: scheduler.nowMs, - value: PriceEntry(fromMs: fromMs, prices: prices) + value: entry ) - write(record, to: shared) + return write(record, to: shared) } /// Leave the chargers for the widgets, as the box just described them. @@ -402,14 +418,109 @@ public final class SiteModel { write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) } - private func write(_ record: CacheRecord, to shared: AppGroupFiles) { - guard !destroyed else { return } + /// Encoding and the file write run off the main actor. A sign-out + /// asked for meanwhile wins: the write is dropped rather than putting + /// the home back (`AppGroupFiles.clearCount`). + @discardableResult + private func write(_ record: CacheRecord, to shared: AppGroupFiles) -> Task? { + guard !destroyed else { return nil } + let asked = shared.clearCount + let previous = writing + let task = Task { [weak self] in + await previous?.value + do { + guard try await shared.write(record, asked: asked) else { return } + log.info("wrote \(Value.name, privacy: .public) for widgets, source \(record.srcState.rawValue, privacy: .public)") + guard let self, !self.destroyed else { return } + self.sharedWritten?() + } catch { + // A widget showing yesterday's record with its date is + // better than no app; the next read tries again. + log.error("could not write \(Value.name, privacy: .public) for widgets: \(error.localizedDescription, privacy: .public)") + if Value.self == PriceEntry.self { self?.lastPrices = nil } + } + } + writing = task + return task + } + + /// Where the prices the widgets hold run out. Nil when they hold none. + /// Read from what this process wrote, or off the main actor from disk + /// after a cold launch. + public func sharedPricesEndMs() async -> Double? { + if let lastPrices { return Self.end(lastPrices) } + guard let siteId, let shared else { return nil } + return await Self.readEnd(shared, siteId: siteId) + } + + @concurrent + private nonisolated static func readEnd(_ shared: AppGroupFiles, siteId: String) async -> Double? { + shared.read(PriceEntry.self, siteId: siteId).flatMap { end($0.value) } + } + + private nonisolated static func end(_ entry: PriceEntry) -> Double? { + entry.prices.slots.map { $0.startMs + $0.durationMs }.max() + } + + /// The background refresh: wait for the stream, ask for today's and + /// tomorrow's prices as the price card does, and leave them for the + /// widgets. Returns what was written, or nil when nothing was: no + /// stream within `waitMs`, a box without prices, or a failed ask. + /// + /// The caller builds the carrier and closes it after. + @discardableResult + public func refreshSharedPrices(calendar: Calendar = .current, waitMs: Double = 20_000) async -> Prices? { + guard siteId != nil, shared != nil, !destroyed else { return nil } + guard await streaming(within: waitMs) else { + log.notice("background prices: no stream within \(Int(waitMs / 1000), privacy: .public) s") + return nil + } + guard session.caps.contains(Contract.capPriceSpot) else { + log.notice("background prices: this box offers no prices") + return nil + } + let from = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 do { - try shared.write(record) - sharedWritten?() + let wire = try await prices(fromMs: from, toMs: from + PriceModel.horizonMs) + sharePrices(wire, fromMs: from) + // iOS may suspend the app once the task returns: land the write + // first, or the one already on its way with these prices. + await writing?.value + return wire } catch { - // A widget showing yesterday's record with its date is better - // than no app; the next read tries again. + log.error("background prices: \(error.localizedDescription, privacy: .public)") + return nil + } + } + + /// True once the session streams, false if it has not within `ms`. + private func streaming(within ms: Double) async -> Bool { + if session.phase == .streaming { return true } + let wait = StreamWait() + let ok = await withCheckedContinuation { cont in + wait.continuation = cont + wait.token = observe { [weak self, wait] in + if self?.session.phase == .streaming { wait.settle(true) } + } + wait.timer = scheduler.after(ms) { [wait] in wait.settle(false) } } + if let token = wait.token { unobserve(token) } + return ok + } +} + +/// One wait for the stream, settled once. The timer settles it even if the +/// model is gone, so the background task always ends. +@MainActor +private final class StreamWait { + var continuation: CheckedContinuation? + var token: Int? + var timer: Cancellable? + + func settle(_ ok: Bool) { + guard let cont = continuation else { return } + continuation = nil + timer?.cancel() + cont.resume(returning: ok) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Cache.swift b/appleApp/FTWKit/Sources/FTWShared/Cache.swift index 4966a37..fb6bf43 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Cache.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Cache.swift @@ -1,4 +1,5 @@ import Foundation +import Synchronization // What the app leaves on disk for its widgets, and later for a notification // extension: one home's prices and its car, each with where it came from @@ -64,6 +65,11 @@ public struct CacheRead: Sendable { public let record: CacheRecord public let origin: CacheOrigin + public init(record: CacheRecord, origin: CacheOrigin) { + self.record = record + self.origin = origin + } + public var value: Value { record.value } /// Always `.cache`. A file on this phone is not a connection to the box. public var carrier: CarrierKind { .cache } @@ -153,14 +159,40 @@ public struct AppGroupFiles: CacheSource { /// Only the app writes. Atomic, so a widget never reads half a file. public func write(_ record: CacheRecord) throws { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - let data = try JSONEncoder().encode(record) - try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) + try Self.clears.withLock { _ in try store(record) } + } + + /// Writes off the caller's thread. `asked` is `clearCount` when the write + /// was asked for: a clear since then means the home was signed out, so + /// the write is dropped rather than putting the home back. Returns + /// whether it wrote. + @concurrent + public nonisolated func write(_ record: CacheRecord, asked: Int) async throws -> Bool { + try Self.clears.withLock { counts in + guard counts[directory, default: 0] == asked else { return false } + try store(record) + return true + } } /// Everything, for signing out. A widget then has nothing to draw. public func removeAll() { - try? FileManager.default.removeItem(at: directory) + Self.clears.withLock { counts in + counts[directory, default: 0] += 1 + try? FileManager.default.removeItem(at: directory) + } + } + + /// How many times this process has cleared this cache. Writes and + /// clears take the same lock, so they land in the order they were asked + /// for. + public var clearCount: Int { Self.clears.withLock { $0[directory, default: 0] } } + private static let clears = Mutex<[URL: Int]>([:]) + + private func store(_ record: CacheRecord) throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(record) + try data.write(to: url(Value.name, record.siteId), options: [.atomic, .completeFileProtectionUntilFirstUserAuthentication]) } } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift new file mode 100644 index 0000000..950ac0c --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceGlance.swift @@ -0,0 +1,316 @@ +import Foundation + +/// What a price widget shows at one moment, read from the cached prices and +/// nothing else. +/// +/// A widget cannot ask the box anything, so its timeline is worked out in +/// advance: one moment now and one at every price boundary the box sent, +/// at whatever resolution the box sent it. Where the prices stop, the last +/// moment says so. It never stretches the last price over hours the market +/// has not priced, and never starts the day over with yesterday's. +public enum PriceGlance { + /// How far ahead the medium widget's curve reaches. + public static let curveMs: Double = 12 * 3_600_000 + + /// What the box's total holds besides the market price. The wire has + /// spot and total but not the settings between them, so this is read off + /// the two figures: Core charges (spot + grid fee) × (1 + VAT). + public enum Included: Equatable, Sendable { + case gridFeeAndVAT, vat, gridFee + /// The total is the market price and nothing else. + case spotOnly + /// Something is added, but the market barely moved, so a fee cannot + /// be told from a percentage. + case surcharges + } + + public enum State: Equatable, Sendable { + /// The app has left nothing: not paired, or signed out. + case nothing + /// A price covers this moment. + case price + /// No price covers this moment, and the next one starts at `nextMs`. + case gap(nextMs: Double) + /// No price covers this moment or anything after it. + case ended(lastMs: Double) + } + + public struct Moment: Equatable, Sendable { + public let atMs: Double + public let state: State + /// `.cache` whenever there is a record: a file on this phone is not a + /// connection to the box. `.none` when there is nothing. + public let carrier: CarrierKind + /// As it was when the app fetched the prices. Never better. + public let srcState: SourceState + /// Wall clock on the phone when the app fetched the prices. + public let readAtMs: Double? + public let currency: String + public let current: PriceSlot? + public let cheapest: PriceStrip.Block? + /// Slots from the current one to `curveMs` ahead. + public let curve: [PriceStrip.Bar] + public let meanMinor: Double? + public let included: Included + + /// "42.3", the price now. Nil when no price covers this moment. + public let priceText: String? + /// "öre/kWh" + public let unitText: String + /// "öre", for one short line. + public let unitLabel: String + /// "incl. grid fee and VAT" + public let includedText: String + /// "Cheapest 02–04" + public let cheapestText: String? + /// "cheapest 02:45": the start alone, for one short line. + public let cheapestStartText: String? + /// Why there is no price: "No prices after 24:00 · open FTW". + public let emptyText: String? + /// "Fetched 13:05" + public let fetchedText: String? + + /// Whether the widget should go grey: no price for this moment. + public var isEmpty: Bool { state != .price } + + /// One line for the lock screen's inline slot, which shares its + /// width with the date. Without a price it only sends the reader to + /// the app; the other families say when the prices ended. + public var inlineText: String { + guard let priceText else { return GlanceWord.noPricesShort.text } + let price = "\(priceText) \(unitLabel)" + return cheapestStartText.map { "\(price) · \($0)" } ?? price + } + } + + /// The timeline: one moment now and one at every boundary ahead, the + /// last where the prices end. + public static func moments(_ read: CacheRead?, nowMs: Double, calendar: Calendar = .current) -> [Moment] { + guard let read else { return [moment(nil, atMs: nowMs, calendar: calendar)] } + // Sorted and read once, not once per moment. + let slots = usable(read.value.prices) + let included = included(slots) + var edges = Set() + for s in slots { + edges.insert(s.startMs) + edges.insert(s.startMs + s.durationMs) + } + let ahead = edges.filter { $0 > nowMs }.sorted() + return ([nowMs] + ahead).map { moment(read, slots: slots, included: included, atMs: $0, calendar: calendar) } + } + + /// The widget at one moment. + public static func moment(_ read: CacheRead?, atMs t: Double, calendar: Calendar = .current) -> Moment { + guard let read else { + return Moment( + atMs: t, state: .nothing, carrier: .none, srcState: .never, readAtMs: nil, + currency: "", current: nil, cheapest: nil, curve: [], meanMinor: nil, included: .spotOnly, + priceText: nil, unitText: "", unitLabel: "", includedText: "", cheapestText: nil, cheapestStartText: nil, + emptyText: GlanceWord.openToSee.text, fetchedText: nil + ) + } + let slots = usable(read.value.prices) + return moment(read, slots: slots, included: included(slots), atMs: t, calendar: calendar) + } + + private static func moment(_ read: CacheRead, slots: [PriceSlot], included: Included, atMs t: Double, calendar: Calendar) -> Moment { + let prices = read.value.prices + let unit = PriceUnits.unit(prices.currency) + let current = slots.first { t >= $0.startMs && t < $0.startMs + $0.durationMs } + + let state: State + if current != nil { + state = .price + } else if let next = slots.first(where: { $0.startMs > t }) { + state = .gap(nextMs: next.startMs) + } else { + state = .ended(lastMs: slots.last.map { $0.startMs + $0.durationMs } ?? read.value.fromMs) + } + + var cheapest: PriceStrip.Block? + var curve: [PriceStrip.Bar] = [] + var mean: Double? + if current != nil { + let summary = PriceStrip.summary(Prices(zone: prices.zone, currency: prices.currency, slots: slots, stale: prices.stale), nowMs: t) + cheapest = summary.cheapest + curve = summary.bars.filter { $0.startMs < t + curveMs } + mean = summary.meanMinor + } + + let clock = Clock(calendar: calendar) + var emptyText: String? + switch state { + case .nothing, .price: emptyText = nil + case .gap(let next): emptyText = GlanceWord.noPriceUntil.format(clock.at(next, asEnd: false, from: t)) + case .ended(let last): + // A box that sent no slots at all has no last hour to name. + emptyText = slots.isEmpty ? GlanceWord.openToSee.text : GlanceWord.noPricesAfter.format(clock.at(last, asEnd: true, from: t)) + } + + return Moment( + atMs: t, + state: state, + carrier: read.carrier, + srcState: read.srcState, + readAtMs: read.record.readAtMs, + currency: prices.currency, + current: current, + cheapest: cheapest, + curve: curve, + meanMinor: mean, + included: included, + priceText: current.map { PriceStrip.text($0.totalMinor, prices.currency) }, + unitText: unit.perKwh, + unitLabel: unit.label, + includedText: text(included), + cheapestText: cheapest.map { clock.cheapest($0, from: t) }, + cheapestStartText: cheapest.map { clock.cheapestStart($0, from: t) }, + emptyText: emptyText, + fetchedText: GlanceWord.fetched.format(clock.at(read.record.readAtMs, asEnd: false, from: t)) + ) + } + + /// What the total holds, from a straight line through (spot, total). + /// Rounding to whole minor units blurs each point by half a unit, so the + /// line needs some spread in spot before its slope means anything. + public static func included(_ slots: [PriceSlot]) -> Included { + guard !slots.isEmpty, slots.contains(where: { abs($0.totalMinor - $0.spotMinor) >= 1 }) else { return .spotOnly } + let spots = slots.map(\.spotMinor) + guard let lo = spots.min(), let hi = spots.max(), hi - lo >= 4 else { return .surcharges } + let n = Double(slots.count) + let mx = spots.reduce(0, +) / n + let my = slots.map(\.totalMinor).reduce(0, +) / n + var sxx = 0.0, sxy = 0.0 + for s in slots { + sxx += (s.spotMinor - mx) * (s.spotMinor - mx) + sxy += (s.spotMinor - mx) * (s.totalMinor - my) + } + let slope = sxy / sxx + guard slope.isFinite, slope > 0 else { return .surcharges } + let fee = (my - slope * mx) / slope + switch (fee >= 1, slope > 1.03) { + case (true, true): return .gridFeeAndVAT + case (false, true): return .vat + case (true, false): return .gridFee + case (false, false): return .surcharges + } + } + + public static func text(_ included: Included) -> String { + switch included { + case .gridFeeAndVAT: return GlanceWord.inclGridFeeAndVAT.text + case .vat: return GlanceWord.inclVAT.text + case .gridFee: return GlanceWord.inclGridFee.text + case .spotOnly: return GlanceWord.spotOnly.text + case .surcharges: return GlanceWord.inclSurcharges.text + } + } + + /// The widget's name and line in the gallery. + public static var title: String { GlanceWord.title.text } + public static var summary: String { GlanceWord.summary.text } + public static var priceNow: String { GlanceWord.priceNow.text } + + static func usable(_ prices: Prices) -> [PriceSlot] { + prices.slots.filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + } +} + +/// Every sentence a price widget shows. The English is the key, and the +/// Swedish lives in `Resources/sv.lproj`; a test checks each one has it. +enum GlanceWord: String, CaseIterable { + case title = "Electricity price" + case summary = "The price now and when it is cheapest next, from what FTW last fetched." + case priceNow = "Price now" + case cheapest = "Cheapest %@" + case cheapestNow = "Cheapest now until %@" + case tomorrow = "tomorrow %@" + case cheapestStart = "cheapest %@" + case cheapestStartNow = "cheapest now" + case noPricesAfter = "No prices after %@ · open FTW" + case noPriceUntil = "No price until %@ · open FTW" + case openToSee = "Open FTW to see prices" + case noPricesShort = "No prices · open FTW" + case fetched = "Fetched %@" + case inclGridFeeAndVAT = "incl. grid fee and VAT" + case inclVAT = "incl. VAT" + case inclGridFee = "incl. grid fee" + case spotOnly = "spot price only" + case inclSurcharges = "incl. surcharges" + + var text: String { Words.text(rawValue) } + + func format(_ arg: String) -> String { + String(format: text, arg) + } +} + +/// Times on the household's clock, 24-hour, as the box's own page writes +/// them. An end at midnight reads 24:00 of the day it ends, not 00:00 of the +/// next. A time on another day than the moment names that day, because a +/// widget can sit unread for days. +struct Clock { + let calendar: Calendar + + struct Wall { + let day: Date + let hour: Int + let minute: Int + + var hm: String { String(format: "%02d:%02d", hour, minute) } + } + + func wall(_ ms: Double, asEnd: Bool) -> Wall { + let date = Date(timeIntervalSince1970: ms / 1000) + let c = calendar.dateComponents([.hour, .minute], from: date) + let day = calendar.startOfDay(for: date) + if asEnd, c.hour == 0, c.minute == 0, let before = calendar.date(byAdding: .day, value: -1, to: day) { + return Wall(day: before, hour: 24, minute: 0) + } + return Wall(day: day, hour: c.hour ?? 0, minute: c.minute ?? 0) + } + + func day(_ ms: Double) -> Date { + calendar.startOfDay(for: Date(timeIntervalSince1970: ms / 1000)) + } + + /// "13:05", or "Tue 24:00" when that is not the moment's day. + func at(_ ms: Double, asEnd: Bool, from t: Double) -> String { + let w = wall(ms, asEnd: asEnd) + guard w.day != day(t) else { return w.hm } + var named = calendar + named.locale = Words.locale + let weekday = named.component(.weekday, from: w.day) + return "\(named.shortWeekdaySymbols[weekday - 1]) \(w.hm)" + } + + /// "Cheapest 02–04", "Cheapest now until 04", "Cheapest tomorrow 15–17". + /// + /// Tomorrow is named only when the clock alone would read as today: at + /// 20:00, "02–04" can only be tonight, but at 14:00 "15–17" looks like + /// this afternoon. + func cheapest(_ block: PriceStrip.Block, from t: Double) -> String { + let a = wall(block.startMs, asEnd: false) + let b = wall(block.endMs, asEnd: true) + let whole = a.minute == 0 && b.minute == 0 + func short(_ w: Wall) -> String { whole ? String(format: "%02d", w.hour) : w.hm } + if block.startMs <= t { + return GlanceWord.cheapestNow.format(short(b)) + } + let range = "\(short(a))–\(short(b))" + return GlanceWord.cheapest.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(range) : range) + } + + /// "cheapest 02:45", "cheapest now", "cheapest tomorrow 15:00". + func cheapestStart(_ block: PriceStrip.Block, from t: Double) -> String { + if block.startMs <= t { return GlanceWord.cheapestStartNow.text } + let a = wall(block.startMs, asEnd: false) + return GlanceWord.cheapestStart.format(misleads(a, from: t) ? GlanceWord.tomorrow.format(a.hm) : a.hm) + } + + /// A time tomorrow that the clock alone would read as later today. + private func misleads(_ a: Wall, from t: Double) -> Bool { + let now = wall(t, asEnd: false) + return a.day > now.day && (a.hour, a.minute) > (now.hour, now.minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift new file mode 100644 index 0000000..12dd383 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceRefresh.swift @@ -0,0 +1,35 @@ +import Foundation + +/// When the app asks iOS to wake it for tomorrow's prices. +/// +/// The day-ahead market publishes tomorrow around 12:45, and the box asks +/// it once an hour, so the box holds tomorrow some time between then and +/// about 13:45. The app asks to run just after 13:05, and again every half +/// hour until the box has tomorrow. iOS decides when, or whether, it runs: +/// this is the earliest, never a promise. +public enum PriceRefresh { + public static let hour = 13 + public static let minute = 5 + /// How soon to ask again when the box did not have tomorrow yet. + public static let retryMs: Double = 30 * 60_000 + /// No retries after this hour; the next try is tomorrow's. + public static let lastRetryHour = 20 + + /// The earliest the next refresh should run, given when the prices the + /// widgets hold run out. + public static func next(afterMs nowMs: Double, pricesEndMs: Double?, calendar: Calendar = .current) -> Double { + let now = Date(timeIntervalSince1970: nowMs / 1000) + let today = calendar.startOfDay(for: now) + func at(_ day: Int, _ h: Int, _ m: Int) -> Double { + let d = calendar.date(byAdding: .day, value: day, to: today) ?? today + let t = calendar.date(bySettingHour: h, minute: m, second: 0, of: d) ?? d + return t.timeIntervalSince1970 * 1000 + } + let publish = at(0, hour, minute) + if nowMs < publish { return publish } + let endOfTomorrow = at(2, 0, 0) + if let end = pricesEndMs, end >= endOfTomorrow { return at(1, hour, minute) } + let retry = nowMs + retryMs + return retry <= at(0, lastRetryHour, 0) ? retry : at(1, hour, minute) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings index 8d2de29..2fe7e81 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Localizable.strings @@ -26,3 +26,23 @@ "%ld min ago" = "för %ld min sedan"; "%ld h ago" = "för %ld h sedan"; "%ld d ago" = "för %ld d sedan"; + +/* Price widgets */ +"Electricity price" = "Elpris"; +"The price now and when it is cheapest next, from what FTW last fetched." = "Priset nu och när det blir billigast härnäst, från det FTW senast hämtade."; +"Price now" = "Pris nu"; +"Cheapest %@" = "Billigast %@"; +"Cheapest now until %@" = "Billigast nu till %@"; +"tomorrow %@" = "i morgon %@"; +"cheapest %@" = "billigast %@"; +"cheapest now" = "billigast nu"; +"No prices after %@ · open FTW" = "Inga priser efter %@ · öppna FTW"; +"No price until %@ · open FTW" = "Inget pris förrän %@ · öppna FTW"; +"Open FTW to see prices" = "Öppna FTW för att se priser"; +"No prices · open FTW" = "Inga priser · öppna FTW"; +"Fetched %@" = "Hämtat %@"; +"incl. grid fee and VAT" = "inkl. nätavgift och moms"; +"incl. VAT" = "inkl. moms"; +"incl. grid fee" = "inkl. nätavgift"; +"spot price only" = "endast spotpris"; +"incl. surcharges" = "inkl. påslag"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Words.swift b/appleApp/FTWKit/Sources/FTWShared/Words.swift index 4e011c6..2cb7302 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Words.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Words.swift @@ -7,11 +7,19 @@ import Foundation /// the widget list both languages in `CFBundleLocalizations`; without that, /// iOS shows a package in the app's development language only. enum Words { + static var bundle: Bundle { .module } + static func text(_ key: String) -> String { - Bundle.module.localizedString(forKey: key, value: key, table: nil) + bundle.localizedString(forKey: key, value: key, table: nil) } static func format(_ key: String, _ args: CVarArg...) -> String { String(format: text(key), arguments: args) } + + /// The language the words resolve to, for the names the system spells, + /// such as weekdays, so a Swedish sentence never names an English day. + static var locale: Locale { + Locale(identifier: Bundle.module.preferredLocalizations.first ?? "en") + } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift new file mode 100644 index 0000000..b0c4667 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/WidgetPricesTests.swift @@ -0,0 +1,80 @@ +import Foundation +import FTWDemo +import FTWShared +import Testing +@testable import FTWKit + +/// The background refresh that leaves tomorrow's prices for the widgets. +@MainActor +@Suite struct WidgetPricesTests { + let scheduler = ManualScheduler() + let shared = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-shared-\(UUID().uuidString)")) + + func site(wakes: Counter) -> SiteModel { + let site = SiteModel(siteId: "home", build: "test", ua: "test", scheduler: scheduler, files: nil, shared: shared) + site.sharedWritten = { wakes.count += 1 } + site.start() + return site + } + + func run(_ ms: Double, step: Double = 20) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + scheduler.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + @Test func aBackgroundRefreshLeavesPricesAndWakesTheWidgets() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + site.connect(SimulatedBox.loopback(scheduler)) + let asked = scheduler.nowMs + let task = Task { await site.refreshSharedPrices() } + await run(2_000) + let written = try #require(await task.value) + #expect(!written.slots.isEmpty) + + let read = try #require(CacheReader(sources: [shared]).read(PriceEntry.self, siteId: "home")) + #expect(read.value.prices == written) + #expect(read.carrier == .cache) + #expect(read.record.readAtMs >= asked && read.record.readAtMs <= scheduler.nowMs) + #expect(wakes.count == 1) + #expect(await site.sharedPricesEndMs() == written.slots.map { $0.startMs + $0.durationMs }.max()) + + // The same prices again are neither written nor reloaded. + let again = Task { await site.refreshSharedPrices() } + await run(2_000) + #expect(await again.value == written) + #expect(wakes.count == 1) + } + + /// A sign-out that lands while a write is on its way wins: the home is + /// not put back for the widgets. + @Test func aSignOutBeatsAWriteOnItsWay() async throws { + let wakes = Counter() + let site = site(wakes: wakes) + let prices = Prices(zone: "SE3", currency: "SEK", slots: [PriceSlot(startMs: 0, durationMs: 3_600_000, spotMinor: 40, totalMinor: 60)], stale: false) + let write = try #require(site.sharePrices(prices, fromMs: 0)) + site.destroy() + shared.removeAll() + await write.value + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + } + + /// No stream, no write: the widgets keep what they had, and the task + /// ends on time rather than holding the wake. + @Test func withoutAStreamItGivesUpAndWritesNothing() async { + let wakes = Counter() + let site = site(wakes: wakes) + let task = Task { await site.refreshSharedPrices(waitMs: 5_000) } + await run(5_500, step: 250) + #expect(await task.value == nil) + #expect(shared.siteIDs(PriceEntry.self).isEmpty) + #expect(wakes.count == 0) + #expect(await site.sharedPricesEndMs() == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift new file mode 100644 index 0000000..df2cb40 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -0,0 +1,270 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price widgets' timeline, worked out from the cache alone. Times are +/// Stockholm's; 12 October 2026 is a Monday. These read English: the tests +/// run in the development language. +@Suite struct PriceGlanceTests { + static let hourMs: Double = 3_600_000 + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + /// Wall clock in Stockholm, as ms. + func at(_ day: Int, _ hour: Int, _ minute: Int = 0, month: Int = 10) -> Double { + calendar.date(from: DateComponents(year: 2026, month: month, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + /// Slots back to back from `fromMs`, each `dtMs` long, priced by index. + func slots(from fromMs: Double, count: Int, dtMs: Double = hourMs, total: (Int) -> Double = { _ in 50 }) -> [PriceSlot] { + (0.. CacheRead { + let prices = Prices(zone: "SE3", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "home", carrier: carrier, srcState: srcState, readAtMs: readAtMs, value: PriceEntry(fromMs: slots.first?.startMs ?? 0, prices: prices)) + return CacheRead(record: record, origin: .app) + } + + // MARK: The timeline + + @Test func oneMomentNowAndOneAtEveryHourTheBoxPriced() { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + #expect(moments.first?.atMs == at(12, 13, 30)) + #expect(moments.count == 1 + 35) + let steps = zip(moments.dropFirst(), moments.dropFirst(2)).map { $1.atMs - $0.atMs } + #expect(steps.allSatisfy { $0 == Self.hourMs }) + #expect(moments.last?.atMs == at(14, 0)) + } + + /// Quarter-hour prices give a moment every quarter hour, and a box that + /// mixes resolutions is followed slot by slot. + @Test func theTimelineFollowsTheResolutionTheBoxSent() { + let quarter = Self.hourMs / 4 + let today = slots(from: at(12, 0), count: 24) + let tomorrow = slots(from: at(13, 0), count: 96, dtMs: quarter) + let moments = PriceGlance.moments(read(today + tomorrow, readAtMs: at(12, 13, 5)), nowMs: at(12, 22, 30), calendar: calendar) + let times = moments.map(\.atMs) + #expect(times.prefix(3) == [at(12, 22, 30), at(12, 23), at(13, 0)]) + #expect(times.contains(at(13, 0, 15))) + let tail = zip(times.dropFirst(2), times.dropFirst(3)).map { $1 - $0 } + #expect(tail.allSatisfy { $0 == quarter }) + #expect(moments.last?.state == .ended(lastMs: at(14, 0))) + } + + @Test func eachMomentShowsTheSlotItFallsIn() { + let r = read(slots(from: at(12, 0), count: 24) { Double(10 + $0) }, readAtMs: at(12, 6)) + for m in PriceGlance.moments(r, nowMs: at(12, 9, 40), calendar: calendar) where m.state == .price { + let hour = calendar.component(.hour, from: Date(timeIntervalSince1970: m.atMs / 1000)) + #expect(m.current?.totalMinor == Double(10 + hour)) + #expect(m.priceText == PriceStrip.text(Double(10 + hour), "SEK")) + } + } + + // MARK: When the prices run out + + @Test func theLastMomentSaysWhenThePricesRanOut() throws { + let r = read(slots(from: at(12, 0), count: 18), readAtMs: at(12, 8)) + let last = try #require(PriceGlance.moments(r, nowMs: at(12, 9), calendar: calendar).last) + #expect(last.atMs == at(12, 18)) + #expect(last.state == .ended(lastMs: at(12, 18))) + #expect(last.isEmpty) + #expect(last.priceText == nil) + #expect(last.cheapestText == nil) + #expect(last.curve.isEmpty) + #expect(last.emptyText == "No prices after 18:00 · open FTW") + #expect(last.inlineText == "No prices · open FTW") + } + + /// Nothing after the end is priced: not the last hour stretched, not + /// yesterday's day started over. + @Test func noMomentAfterTheEndShowsAPrice() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + for h in 0..<72 { + let m = PriceGlance.moment(r, atMs: at(14, 0) + Double(h) * Self.hourMs + 1, calendar: calendar) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.current == nil && m.priceText == nil && m.curve.isEmpty) + } + } + + /// The proof case: fetched Monday just after the market published, and + /// not opened since. Thirty-six hours on, the widget is grey and says + /// which midnight the prices ended at. + @Test func thirtySixHoursLaterTheWidgetIsGrey() throws { + let fetched = at(12, 13, 5) + let r = read(slots(from: at(12, 0), count: 48), readAtMs: fetched) + let later = fetched + 36 * Self.hourMs + let moments = PriceGlance.moments(r, nowMs: later, calendar: calendar) + #expect(moments.count == 1) + let m = try #require(moments.first) + #expect(m.state == .ended(lastMs: at(14, 0))) + #expect(m.emptyText == "No prices after Tue 24:00 · open FTW") + #expect(m.fetchedText == "Fetched Mon 13:05") + #expect(m.carrier == .cache) + } + + /// An end at midnight is 24:00 of the day it closes, also on the day the + /// clocks go back and the day has 25 hours. + @Test func midnightReadsAsTheEndOfTheDayAcrossTheClockChange() throws { + let day = at(25, 0) + let next = at(26, 0) + #expect(next - day == 25 * Self.hourMs) + let r = read(slots(from: day, count: 25), readAtMs: day) + let last = try #require(PriceGlance.moments(r, nowMs: at(25, 20), calendar: calendar).last) + #expect(last.atMs == next) + #expect(last.emptyText == "No prices after Sun 24:00 · open FTW") + #expect(PriceGlance.moment(r, atMs: at(25, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + /// One failed fetch at midday leaves a hole. The widget says so for as + /// long as it lasts and picks the prices up again after it. + @Test func aHoleIsSaidNotFilled() { + let morning = slots(from: at(12, 0), count: 6) + let afternoon = slots(from: at(12, 12), count: 12) + let moments = PriceGlance.moments(read(morning + afternoon, readAtMs: at(12, 5)), nowMs: at(12, 5, 30), calendar: calendar) + let gap = moments.first { $0.atMs == at(12, 6) } + #expect(gap?.state == .gap(nextMs: at(12, 12))) + #expect(gap?.emptyText == "No price until 12:00 · open FTW") + #expect(gap?.priceText == nil) + #expect(!moments.contains { $0.atMs > at(12, 6) && $0.atMs < at(12, 12) }) + #expect(moments.first { $0.atMs == at(12, 12) }?.state == .price) + } + + @Test func beforeTheFirstPriceTheWidgetWaitsForIt() { + let r = read(slots(from: at(12, 6), count: 18), readAtMs: at(12, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 5), calendar: calendar) + #expect(m.state == .gap(nextMs: at(12, 6))) + #expect(m.emptyText == "No price until 06:00 · open FTW") + } + + // MARK: Cheapest next + + /// A day that is dear until midnight and cheapest from 02 to 04. + func night(dtMs: Double = hourMs, cheapFrom: Double, cheapTo: Double) -> [PriceSlot] { + let start = at(12, 0) + let count = Int((at(14, 0) - start) / dtMs) + return slots(from: start, count: count, dtMs: dtMs) { i in + let t = start + Double(i) * dtMs + return t >= cheapFrom && t < cheapTo ? 5 : 80 + Double(i % 7) + } + } + + @Test func cheapestNextNamesTheTwoHoursAhead() { + let r = read(night(cheapFrom: at(13, 2), cheapTo: at(13, 4)), readAtMs: at(12, 13, 5)) + let evening = PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar) + #expect(evening.cheapest?.startMs == at(13, 2)) + #expect(evening.cheapestText == "Cheapest 02–04") + // The inline line shares its width with the date: the start alone. + #expect(evening.inlineText == "\(evening.priceText!) öre · cheapest 02:00") + // Inside the block it is now, and says until when. + let inside = PriceGlance.moment(r, atMs: at(13, 2, 30), calendar: calendar) + #expect(inside.cheapestText == "Cheapest now until 04") + #expect(inside.cheapestStartText == "cheapest now") + } + + @Test func cheapestFollowsQuarterHours() { + let quarter = Self.hourMs / 4 + let r = read(night(dtMs: quarter, cheapFrom: at(13, 2, 15), cheapTo: at(13, 4, 15)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 20), calendar: calendar).cheapestText == "Cheapest 02:15–04:15") + } + + /// At 14:00, "15–17" would read as this afternoon. Tomorrow's is named. + @Test func tomorrowIsNamedOnlyWhenTheClockWouldMislead() { + let r = read(night(cheapFrom: at(13, 15), cheapTo: at(13, 17)), readAtMs: at(12, 13, 5)) + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestText == "Cheapest tomorrow 15–17") + #expect(PriceGlance.moment(r, atMs: at(12, 14), calendar: calendar).cheapestStartText == "cheapest tomorrow 15:00") + // The same block seen the next morning is today's. + #expect(PriceGlance.moment(r, atMs: at(13, 9), calendar: calendar).cheapestText == "Cheapest 15–17") + } + + @Test func noCheapestWhenLessThanTwoHoursArePriced() { + let r = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8)) + #expect(PriceGlance.moment(r, atMs: at(12, 23, 30), calendar: calendar).cheapestText == nil) + #expect(PriceGlance.moment(r, atMs: at(12, 22, 30), calendar: calendar).cheapestText == "Cheapest now until 24") + } + + @Test func theCurveReachesTwelveHoursAhead() throws { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5)) + let m = PriceGlance.moment(r, atMs: at(12, 13, 30), calendar: calendar) + #expect(m.curve.first?.startMs == at(12, 13)) + #expect(m.curve.first?.current == true) + #expect(m.curve.count == 13) + #expect(try #require(m.curve.last).startMs < at(12, 13, 30) + PriceGlance.curveMs) + } + + // MARK: What the total includes + + /// Core's formula, rounded once on the wire as Core rounds it. + func core(spots: [Double], fee: Double, vat: Double) -> [PriceSlot] { + spots.enumerated().map { i, spot in + PriceSlot(startMs: Double(i) * Self.hourMs, durationMs: Self.hourMs, spotMinor: spot.rounded(), totalMinor: ((spot + fee) * (1 + vat / 100)).rounded()) + } + } + + @Test func theTotalSaysWhatItIncludes() { + let day = (0..<24).map { 30 + 70 * sin(Double($0) / 4) + Double($0) * 0.37 } + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 25)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 25)) == .vat) + #expect(PriceGlance.included(core(spots: day, fee: 45, vat: 0)) == .gridFee) + #expect(PriceGlance.included(core(spots: day, fee: 0, vat: 0)) == .spotOnly) + // Cents move little over a day; the line still holds. + let cents = (0..<96).map { 6 + 4 * sin(Double($0) / 12) } + #expect(PriceGlance.included(core(spots: cents, fee: 3, vat: 24)) == .gridFeeAndVAT) + #expect(PriceGlance.included(core(spots: cents, fee: 0, vat: 24)) == .vat) + // A flat market cannot tell a fee from a percentage. + #expect(PriceGlance.included(core(spots: Array(repeating: 50, count: 24), fee: 45, vat: 25)) == .surcharges) + #expect(PriceGlance.text(.gridFeeAndVAT) == "incl. grid fee and VAT") + #expect(PriceGlance.text(.spotOnly) == "spot price only") + } + + // MARK: Freshness + + /// A record read back is the cache, whatever carried it and however + /// healthy the box was then. No moment claims live. + @Test func aCachedPriceNeverLooksLive() { + let r = read(slots(from: at(12, 0), count: 48), readAtMs: at(12, 13, 5), carrier: .relay, srcState: .live) + let moments = PriceGlance.moments(r, nowMs: at(12, 13, 30), calendar: calendar) + for m in moments { + #expect(m.carrier == .cache) + #expect(m.srcState == .live) + #expect(m.readAtMs == at(12, 13, 5)) + let words = [m.priceText, m.cheapestText, m.emptyText, m.fetchedText, m.includedText, m.inlineText].compactMap { $0 } + #expect(!words.contains { $0.lowercased().contains("live") }) + } + #expect(moments.first?.fetchedText == "Fetched 13:05") + // An answer the box itself called short is kept as such. + let short = read(slots(from: at(12, 0), count: 24), readAtMs: at(12, 8), srcState: .stale) + #expect(PriceGlance.moment(short, atMs: at(12, 9), calendar: calendar).srcState == .stale) + } + + @Test func withNothingCachedTheWidgetAsksForTheApp() { + let moments = PriceGlance.moments(nil, nowMs: at(12, 9), calendar: calendar) + #expect(moments.count == 1) + #expect(moments[0].state == .nothing) + #expect(moments[0].carrier == .none) + #expect(moments[0].srcState == .never) + #expect(moments[0].emptyText == "Open FTW to see prices") + } + + // MARK: Words + + /// Every sentence a widget shows has a Swedish line with the same + /// placeholders. + @Test func everyWidgetWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Localizable", withExtension: "strings", subdirectory: nil, localization: "sv")) + let table = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in GlanceWord.allCases { + let sv = try #require(table[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(sv.components(separatedBy: "%@").count == word.rawValue.components(separatedBy: "%@").count) + #expect(sv != word.rawValue) + } + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift new file mode 100644 index 0000000..f4d3f59 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceRefreshTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +@testable import FTWShared + +/// When the app asks iOS to wake it for tomorrow's prices. +@Suite struct PriceRefreshTests { + let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.timeZone = TimeZone(identifier: "Europe/Stockholm")! + return c + }() + + func at(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))!.timeIntervalSince1970 * 1000 + } + + @Test func beforeTheMarketPublishesItWaitsForFivePastOne() { + #expect(PriceRefresh.next(afterMs: at(12, 8), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 5)) + } + + @Test func withTomorrowInHandTheNextIsTomorrows() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 20), pricesEndMs: at(14, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// The box asks the market once an hour, so at 13:05 it may not have + /// tomorrow yet. Ask again in half an hour, until the evening. + @Test func withoutTomorrowItAsksAgainSoon() { + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: at(13, 0), calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 13, 10), pricesEndMs: nil, calendar: calendar) == at(12, 13, 40)) + #expect(PriceRefresh.next(afterMs: at(12, 19, 50), pricesEndMs: at(13, 0), calendar: calendar) == at(13, 13, 5)) + } + + /// 13:05 on the household's clock, also across the night the clocks go + /// back. + @Test func fivePastOneIsWallClockAcrossTheClockChange() { + let next = PriceRefresh.next(afterMs: at(24, 14), pricesEndMs: at(26, 0), calendar: calendar) + #expect(next == at(25, 13, 5)) + #expect(next - at(24, 13, 5) == 25 * 3_600_000) + } +} diff --git a/appleApp/FTWWidgets/PriceViews.swift b/appleApp/FTWWidgets/PriceViews.swift new file mode 100644 index 0000000..f2c8d91 --- /dev/null +++ b/appleApp/FTWWidgets/PriceViews.swift @@ -0,0 +1,299 @@ +import Charts +import FTWShared +import SwiftUI +import WidgetKit + +/// One moment of the price widget, in whichever family it sits. Every +/// sentence comes from `PriceGlance`; this file only lays them out. +struct PriceWidgetView: View { + let moment: PriceGlance.Moment + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { Rectangle().fill(background) } + } + + /// Grey when there is no price for this moment. The lock screen draws + /// its own, so the accessory families bring none. + private var background: AnyShapeStyle { + switch family { + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) + default: return AnyShapeStyle(moment.isEmpty ? Tone.empty : Tone.surface) + } + } + + @ViewBuilder private var content: some View { + switch family { + case .systemMedium: MediumPrice(moment: moment) + case .accessoryRectangular: RectangularPrice(moment: moment) + case .accessoryInline: Label(moment.inlineText, systemImage: Header.symbol) + default: SmallPrice(moment: moment) + } + } +} + +/// The home screen's small widget, and StandBy's. +struct SmallPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + if let price = moment.priceText { + Header(text: PriceGlance.priceNow) + PriceFigure(price: price, size: 38) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } else { + Empty(moment: moment) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } +} + +/// The figures on the left and the next twelve hours on the right. +struct MediumPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + if let price = moment.priceText { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Header(text: PriceGlance.priceNow) + PriceFigure(price: price, size: 34) + Text(moment.unitText).font(.caption2).foregroundStyle(.secondary) + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(2).minimumScaleFactor(0.8) + Spacer(minLength: 4) + Cheapest(moment: moment) + Fetched(moment: moment) + } + .frame(width: 128, alignment: .leading) + .accessibilityElement(children: .combine) + Curve(moment: moment) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + VStack(alignment: .leading, spacing: 2) { Empty(moment: moment) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } +} + +/// The lock screen's rectangle: price, cheapest, what the price holds. +struct RectangularPrice: View { + let moment: PriceGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let price = moment.priceText { + Text("\(Image(systemName: Header.symbol)) \(Text(price).font(.headline.monospacedDigit())) \(Text(moment.unitText).font(.caption))") + .contentTransition(.numericText()) + .widgetAccentable() + if let cheapest = moment.cheapestText { + Text(cheapest).font(.caption).lineLimit(1).minimumScaleFactor(0.75) + } + Text(moment.includedText).font(.caption2).foregroundStyle(.secondary).lineLimit(1).minimumScaleFactor(0.7) + } else { + Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(3) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + } +} + +// MARK: Parts + +/// "⚡ Pris nu": the symbol takes the accent colour, the words stay quiet. +struct Header: View { + static let symbol = "bolt.fill" + let text: String + + var body: some View { + HStack(spacing: 3) { + Image(systemName: Self.symbol) + .imageScale(.small) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(text).foregroundStyle(.secondary) + } + .font(.caption) + } +} + +struct PriceFigure: View { + let price: String + let size: CGFloat + + var body: some View { + Text(price) + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .contentTransition(.numericText()) + .minimumScaleFactor(0.5) + .lineLimit(1) + .widgetAccentable() + } +} + +struct Cheapest: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.cheapestText { + Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(Tone.cheap) + .lineLimit(1) + .minimumScaleFactor(0.7) + .widgetAccentable() + } + } +} + +/// When the app fetched these prices. A widget only ever shows the cache. +struct Fetched: View { + let moment: PriceGlance.Moment + + var body: some View { + if let text = moment.fetchedText { + Text(text).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + } + } +} + +/// No price for this moment: say why and send the reader to the app. +struct Empty: View { + let moment: PriceGlance.Moment + + var body: some View { + Header(text: PriceGlance.title) + Spacer(minLength: 4) + Text(moment.emptyText ?? "") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + Fetched(moment: moment) + } +} + +/// The next twelve hours as bars from zero, at the resolution the box sent: +/// green below the average ahead, red above, the current slot solid. +/// +/// Tinted and clear home screens and StandBy at night draw in one colour, so +/// there the current slot and the cheap ones stand out and the rest are faint. +struct Curve: View { + let moment: PriceGlance.Moment + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + Chart { + ForEach(moment.curve) { bar in + mark(bar) + } + if let mean = moment.meanMinor { + RuleMark(y: .value("Average", PriceUnits.display(mean, moment.currency))) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3])) + .foregroundStyle(Tone.flat) + } + } + .chartXScale(domain: date(start)...date(end)) + .chartXAxis { + AxisMarks(values: hours) { value in + AxisValueLabel { + if let d = value.as(Date.self) { Text(Self.hour(d)) } + } + } + } + .chartYAxis(.hidden) + .accessibilityHidden(true) + } + + /// A bar from zero, as wide as the slot. A rectangle, because a bar + /// mark with an x interval and one y lies on its side. + private func mark(_ bar: PriceStrip.Bar) -> some ChartContent { + let price: Double = PriceUnits.display(bar.minor, moment.currency) + return RectangleMark( + xStart: .value("From", date(bar.startMs)), + xEnd: .value("To", date(bar.endMs)), + yStart: .value("Zero", 0.0), + yEnd: .value("Price", price) + ) + .foregroundStyle(renderingMode == .fullColor ? AnyShapeStyle(Tone.of(bar.tone)) : AnyShapeStyle(.primary)) + .opacity(opacity(bar)) + } + + private func opacity(_ bar: PriceStrip.Bar) -> Double { + if bar.current { return 1 } + guard renderingMode != .fullColor else { return 0.55 } + return bar.tone == .cheap || bar.tone == .negative ? 0.8 : 0.3 + } + + private var start: Double { moment.curve.first?.startMs ?? moment.atMs } + private var end: Double { moment.curve.last?.endMs ?? moment.atMs } + + /// Every third whole hour, kept clear of the right edge so no label is + /// cut short. + private var hours: [Date] { + let hour: Double = 3_600_000 + var t = (start / hour).rounded(.up) * hour + var out: [Date] = [] + while t < end - hour { + if Calendar.current.component(.hour, from: date(t)) % 3 == 0 { out.append(date(t)) } + t += hour + } + return out + } + + /// "02", on the 24-hour clock the rest of the widget uses. + private static func hour(_ d: Date) -> String { + String(format: "%02d", Calendar.current.component(.hour, from: d)) + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} + +/// The app's colour roles (FTW/UI/Theme.swift, from the web app's tokens), +/// the few a price widget needs. +enum Tone { + static let surface = Pair(0xFAFAF8, 0x161616) + static let empty = Pair(0xDCDCD6, 0x2A2A2A) + static let cheap = Pair(0x009639, 0x5FD37F) + static let dear = Pair(0xCC243D, 0xFF6E74) + static let negative = Pair(0xD48500, 0xFFB000) + static let flat = Pair(0x686862, 0x858585) + static let accent = Pair(0xDA7F00, 0xFFAC41) + + static func of(_ tone: PriceStrip.Tone) -> Pair { + switch tone { + case .cheap: return cheap + case .dear: return dear + case .flat: return flat + case .negative: return negative + } + } +} + +/// One colour for light and one for dark, picked by SwiftUI where it draws. +struct Pair: ShapeStyle { + let light: Color + let dark: Color + + init(_ light: UInt32, _ dark: UInt32) { + self.light = Self.color(light) + self.dark = Self.color(dark) + } + + func resolve(in environment: EnvironmentValues) -> Color { + environment.colorScheme == .dark ? dark : light + } + + private static func color(_ rgb: UInt32) -> Color { + Color(red: Double((rgb >> 16) & 0xFF) / 255, green: Double((rgb >> 8) & 0xFF) / 255, blue: Double(rgb & 0xFF) / 255) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index 586246d..bb50c0f 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -1,7 +1,11 @@ import FTWShared +import os import SwiftUI import WidgetKit +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") +private let signposter = OSSignposter(subsystem: "energy.ftw.app", category: "widgets") + @main struct FTWWidgets: WidgetBundle { var body: some Widget { @@ -9,105 +13,68 @@ struct FTWWidgets: WidgetBundle { } } -/// The price now and the cheapest two hours ahead, from what the app left in -/// the App Group. A widget opens no connection and holds no key. +/// The price now, when it is cheapest next, and a short curve, from what the +/// app left in the App Group. A widget opens no connection and holds no key. +/// Prices are not secret, so the lock screen shows them as they are. struct PriceWidget: Widget { var body: some WidgetConfiguration { - StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { moment in - PriceWidgetView(moment: moment) - .containerBackground(.fill.tertiary, for: .widget) + StaticConfiguration(kind: "energy.ftw.prices", provider: PriceTimeline()) { entry in + PriceWidgetView(moment: entry.moment) } - .configurationDisplayName("Electricity price") - .description("The price now and the cheapest two hours ahead.") - .supportedFamilies([.systemSmall]) + .configurationDisplayName(PriceGlance.title) + .description(PriceGlance.summary) + // systemSmall is also the StandBy widget. + .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular, .accessoryInline]) } } -struct PriceMoment: TimelineEntry { +struct PriceWidgetEntry: TimelineEntry { let date: Date - /// Nil when the app has left nothing: not paired, or signed out. - let read: CacheRead? + let moment: PriceGlance.Moment } struct PriceTimeline: TimelineProvider { - func placeholder(in context: Context) -> PriceMoment { - PriceMoment(date: .now, read: nil) + func placeholder(in context: Context) -> PriceWidgetEntry { + // Drawn redacted while the real entry loads, so only the shape shows. + let now = Date.now.timeIntervalSince1970 * 1000 + return PriceWidgetEntry(date: .now, moment: PriceGlance.moment(Self.sample(nowMs: now), atMs: now)) } - func getSnapshot(in context: Context, completion: @escaping (PriceMoment) -> Void) { - completion(moments(now: .now)[0]) + func getSnapshot(in context: Context, completion: @escaping (PriceWidgetEntry) -> Void) { + completion(entries(now: .now)[0]) } /// The app reloads every timeline after it writes, so nothing here asks - /// to be woken. - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - completion(Timeline(entries: moments(now: .now), policy: .never)) - } - - /// One moment now and one at every price boundary ahead. The last falls - /// where the prices end, so the widget says they ran out rather than - /// showing the last hour again. - func moments(now: Date) -> [PriceMoment] { - guard let reader = CacheReader.shared(), - let site = reader.latestSiteID(PriceEntry.self), - let read = reader.read(PriceEntry.self, siteId: site) else { - return [PriceMoment(date: now, read: nil)] + /// to be woken. The last entry falls where the prices end and stays. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let interval = signposter.beginInterval("price timeline") + let started = ContinuousClock.now + let entries = entries(now: .now) + let ms = (ContinuousClock.now - started) / .milliseconds(1) + signposter.endInterval("price timeline", interval) + if let last = entries.last?.moment { + log.info("price timeline: \(entries.count, privacy: .public) entries in \(ms, format: .fixed(precision: 1), privacy: .public) ms, last \(String(describing: last.state), privacy: .public)") } - let nowMs = now.timeIntervalSince1970 * 1000 - let ahead = Set(read.value.prices.slots.map { $0.startMs + $0.durationMs }.filter { $0 > nowMs }).sorted() - return [PriceMoment(date: now, read: read)] + ahead.map { PriceMoment(date: Date(timeIntervalSince1970: $0 / 1000), read: read) } + completion(Timeline(entries: entries, policy: .never)) } -} - -struct PriceWidgetView: View { - let moment: PriceMoment - var body: some View { - if let read = moment.read { - prices(read) - } else { - VStack(alignment: .leading, spacing: 4) { - Text("FTW").font(.headline) - Text("Open FTW to see prices.").font(.caption).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } + private func entries(now: Date) -> [PriceWidgetEntry] { + let reader = CacheReader.shared() + let read = reader.flatMap { r in r.latestSiteID(PriceEntry.self).flatMap { r.read(PriceEntry.self, siteId: $0) } } + if reader == nil { log.error("price widget: no App Group, nothing to read") } + return PriceGlance.moments(read, nowMs: now.timeIntervalSince1970 * 1000) + .map { PriceWidgetEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), moment: $0) } } - private func prices(_ read: CacheRead) -> some View { - let prices = read.value.prices - let summary = PriceStrip.summary(prices, nowMs: moment.date.timeIntervalSince1970 * 1000) - return VStack(alignment: .leading, spacing: 2) { - Text("Price now").font(.caption).foregroundStyle(.secondary) - if let current = summary.current { - Text(PriceStrip.text(current.totalMinor, prices.currency)) - .font(.system(.largeTitle, design: .rounded).weight(.semibold).monospacedDigit()) - .minimumScaleFactor(0.6) - .lineLimit(1) - Text(PriceUnits.unit(prices.currency).perKwh).font(.caption2).foregroundStyle(.secondary) - Spacer(minLength: 2) - if let cheapest = summary.cheapest { - Text("Cheapest \(time(cheapest.startMs))–\(time(cheapest.endMs))") - .font(.caption2.weight(.semibold)) - .lineLimit(1) - .minimumScaleFactor(0.8) - } - } else { - Spacer(minLength: 2) - Text(endText(prices)).font(.caption).foregroundStyle(.secondary) - } - // Read back from the phone, never live: say when it was fetched. - Text("Updated \(time(read.record.readAtMs))").font(.caption2).foregroundStyle(.secondary) + /// A day's shape for the redacted placeholder. Never shown as a price. + static func sample(nowMs: Double) -> CacheRead { + let hour: Double = 3_600_000 + let start = (nowMs / hour).rounded(.down) * hour + let slots = (0..<12).map { i in + PriceSlot(startMs: start + Double(i) * hour, durationMs: hour, spotMinor: 40, totalMinor: 60 + Double((i * 37) % 50)) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - - private func endText(_ prices: Prices) -> String { - guard let last = prices.slots.map({ $0.startMs + $0.durationMs }).max() else { return "No prices · open FTW" } - return "No prices after \(time(last)) · open FTW" - } - - private func time(_ ms: Double) -> String { - Date(timeIntervalSince1970: ms / 1000).formatted(date: .omitted, time: .shortened) + let prices = Prices(zone: "", currency: "SEK", slots: slots, stale: false) + let record = CacheRecord(siteId: "", carrier: .none, srcState: .never, readAtMs: nowMs, value: PriceEntry(fromMs: start, prices: prices)) + return CacheRead(record: record, origin: .app) } }