diff --git a/appleApp/FTW/FTWApp.swift b/appleApp/FTW/FTWApp.swift index 9652d84..e309585 100644 --- a/appleApp/FTW/FTWApp.swift +++ b/appleApp/FTW/FTWApp.swift @@ -36,7 +36,9 @@ struct FTWApp: App { // A paired phone paints its cached home before anything else runs. model.launch() #if DEBUG - // For screenshots: `-FTWDemo YES` opens the simulated home. + // For screenshots: `-FTWDemo YES` opens the simulated home, and + // `-FTWDemoWidgets YES` lets it leave its records for the widgets. + model.demoSharesWidgets = UserDefaults.standard.bool(forKey: "FTWDemoWidgets") if UserDefaults.standard.bool(forKey: "FTWDemo") { model.startDemo() } // For end-to-end tests: `-FTWPairLink ` offers a pairing link // as a scanned QR would, where the simulator has no camera. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 4383055..a095873 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -35,6 +35,10 @@ public final class SimulatedBox { /// estimate meanwhile and the data grows into it. 0 sends all at once. public var holdTileMs: Double = 0 private var heldTile = false + /// The share of today the box has prices for, as + /// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost + /// partial. + public var pricedShare: Double = 1 /// A Core with srcfl/ftw#1586 reads and sends the ready time as /// `time_of_day_min` on the household clock. Off, it is a Core that /// knows only `time_of_day_min_utc`. @@ -480,10 +484,8 @@ public final class SimulatedBox { return (200, ["days": .array(rows)]) case ("GET", "/api/savings/daily"): let days = max(1, min(90, Int(query["days"] ?? "7") ?? 7)) - let rows: [JSON] = (0.. Double { + ms - hourOfDay(ms) * 3_600_000 + } + + /// Today so far, bucket by bucket. A bucket still filling holds its + /// energy so far, as the box's ledger does. + func gridDay(nowMs: Double) -> GridDay { + let from = midnight(nowMs) + var day = GridDay(expectedMs: nowMs - from) + var t = from + while t < nowMs { + if let b = bucket(t, nowMs: nowMs) { + let kwh = b.grid * Self.bucketMs / 3_600_000 / 1000 + let quarter = (t / 900_000).rounded(.down) * 900_000 + let price = priceAt(quarter + 450_000) + if kwh > 0 { + day.importWh += kwh * 1000 + day.importCostOre += kwh * price + } else { + day.exportWh -= kwh * 1000 + day.exportRevenueOre -= kwh * price * Self.spotShare + } + } + t += Self.bucketMs + } + return day + } +} + +extension SimulatedBox { + /// One day of `GET /api/savings/daily`, in Core's fields. Today comes + /// from the buckets; earlier days from the made-up ledger, priced at a + /// flat day's average. + func savingsRow(dayOffset: Int, nowMs: Double) -> JSON { + let day = house.localDay(nowMs - Double(dayOffset) * 86_400_000) + let saved = (1_400 + 900 * house.wobble(Double(dayOffset) * 1e6, 5)).rounded() + var g: SimulatedHouse.GridDay + if dayOffset == 0 { + g = house.gridDay(nowMs: nowMs) + } else { + let l = house.ledger(dayOffset: dayOffset, nowMs: nowMs) + g = SimulatedHouse.GridDay(importWh: l.imp, exportWh: l.exp, importCostOre: l.imp / 1000 * 95, exportRevenueOre: l.exp / 1000 * 95 * SimulatedHouse.spotShare, expectedMs: 86_400_000) + } + let expected = g.expectedMs.rounded() + return [ + "day": .string(day), + "import_wh": .number(g.importWh.rounded()), + "export_wh": .number(g.exportWh.rounded()), + "import_cost_ore": .number(g.importCostOre.rounded()), + "export_revenue_ore": .number(g.exportRevenueOre.rounded()), + "actual_cost_ore": .number((g.importCostOre - g.exportRevenueOre).rounded()), + "saved_ore": .number(saved), + "expected_ms": .number(expected), + "history_covered_ms": .number(expected), + "priced_covered_ms": .number((expected * pricedShare).rounded()), + "history_coverage_pct": 1, + "priced_coverage_pct": .number(pricedShare), + "resolution": "slot", + ] + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift index 5a43751..ddf198a 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHouse.swift @@ -13,6 +13,9 @@ public final class SimulatedHouse { public let batteryMaxW: Double = 5_000 public let fuseAmps: Double = 25 public let timeZone: TimeZone + /// A clear midsummer's day whatever the date, for showing a day that + /// sells more than it buys. + public var sunny = false public private(set) var socFraction: Double = 0.62 public private(set) var batteryW: Double = 0 @@ -153,6 +156,7 @@ public final class SimulatedHouse { /// How far the year is toward midsummer: 1 in June, 0 in December. func season(_ ms: Double) -> Double { + if sunny { return 1 } var cal = Calendar(identifier: .gregorian) cal.timeZone = timeZone let day = Double(cal.ordinality(of: .day, in: .year, for: Date(timeIntervalSince1970: ms / 1000)) ?? 172) @@ -167,7 +171,7 @@ public final class SimulatedHouse { let half = 3.5 + 4.5 * s guard h > 12.5 - half, h < 12.5 + half else { return 0 } let sun = sin(Double.pi * (h - 12.5 + half) / (2 * half)) - let cloud = 0.85 + 0.15 * wobble(ms / 600_000, 3) + let cloud = sunny ? 1 : 0.85 + 0.15 * wobble(ms / 600_000, 3) return -(pvPeakW * (0.2 + 0.8 * s) * max(0, sun) * max(0, sun) * cloud).rounded() } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift index 3c45a5a..f77882c 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/AppModel.swift @@ -14,6 +14,8 @@ public final class HomeModels { public let loadpoints: LoadpointsModel /// Live, history, plan and mode for Now's nodes. public let nodes: NodeModels + /// Today over the grid, for the grid widget. + public let gridToday: GridTodayShare init(site: SiteModel) { self.site = site @@ -23,6 +25,7 @@ public final class HomeModels { savings = SavingsModel(site: site) loadpoints = LoadpointsModel(site: site, charging: charging) nodes = NodeModels(site: site, loadpoints: loadpoints, prices: prices) + gridToday = GridTodayShare(site: site, history: nodes.history, prices: prices) } } @@ -54,6 +57,10 @@ public final class AppModel { /// A pairing link that arrived from outside, shown before it is trusted. public var offeredLink: String? public private(set) var isDemo = false + /// The demo leaves its records for the widgets under the home "demo". + /// Only a Debug build sets it, to photograph the widgets; the demo + /// otherwise writes nothing. + public var demoSharesWidgets = false @ObservationIgnored public let vault: Vault @ObservationIgnored public let sites: SiteList @@ -258,7 +265,9 @@ public final class AppModel { public func startDemo() { guard let demo else { return } home?.site.destroy() - let site = SiteModel(siteId: nil, build: build, ua: ua, scheduler: scheduler, files: nil, isDemo: true) + let shares = demoSharesWidgets && shared != nil + let site = SiteModel(siteId: shares ? "demo" : nil, build: build, ua: ua, scheduler: scheduler, files: nil, shared: shares ? shared : nil, isDemo: true, demoShares: shares) + if shares { site.sharedWritten = sharedWritten } site.ceilingW = 11_000 home = HomeModels(site: site) isDemo = true diff --git a/appleApp/FTWKit/Sources/FTWKit/State/GridTodayShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/GridTodayShare.swift new file mode 100644 index 0000000..80d9775 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWKit/State/GridTodayShare.swift @@ -0,0 +1,96 @@ +import Foundation +import FTWShared + +/// Leaves today over the grid for the grid widget: the box's totals and cost +/// from `GET /api/savings/daily?days=1`, and the grid's power since midnight +/// from the history the grid's node view draws. +/// +/// It reads while the app is open and on screen, once a quarter hour, the +/// step of the widget's chart, and again when the household's day turns. +/// Each read is one record, dated when the totals arrived, and the app wakes +/// the widgets after it lands. No timer runs while the app is hidden: the +/// widget says how old its figures are, and after midnight that they are +/// yesterday's. +@MainActor +public final class GridTodayShare { + private unowned let site: SiteModel + private unowned let history: HistoryModel + private unowned let prices: PriceModel + private var ask: LiveAsk? + + public init(site: SiteModel, history: HistoryModel, prices: PriceModel) { + self.site = site + self.history = history + self.prices = prices + // A home that leaves nothing for widgets has nothing to read for. + guard site.sharesWidgets else { return } + ask = LiveAsk(site: site, want: { [weak self] in self?.wanted }, ask: { [weak self] in try await self?.load() }) + } + + /// What would make the record out of date: a new quarter hour, or a new + /// day on the household's clock. + var wanted: String? { + guard site.documentVisible, site.hasPassthrough else { return nil } + let day = GridTodayEntry.dayName(site.nowMs, calendar: calendar) + return "grid today \(day) \(Int(site.nowMs / GridTodayEntry.quarterMs))" + } + + private var calendar: Calendar { .household(site.session.box?.tz) } + + func load() async throws { + let readAt = site.nowMs + let wire = try await site.callBox(.get, "/api/savings/daily", query: ["days": "1"]) + // A box with no price zone answers no days at all: nothing to leave. + guard let row = wire?["days"]?.array?.last, let totals = Self.totals(row) else { return } + let calendar = self.calendar + guard let day = GridTodayEntry.bounds(day: totals.day, calendar: calendar) else { return } + + // The history the grid's node view reads. A failure leaves the + // chart as last read; the totals are written all the same, and the + // ask comes again. + var failure: Error? + if history.available { + do { try await history.load() } catch { failure = error } + } + let steps = history.frame.map { frame in + HistoryModel.samples(frame, node: .grid, fromMs: day.startMs).map { (startMs: $0.startMs, stepMs: $0.stepMs, watts: $0.watts) } + } ?? [] + let entry = GridTodayEntry( + day: totals.day, + dayStartMs: day.startMs, + dayEndMs: day.endMs, + importWh: totals.importWh, + exportWh: totals.exportWh, + costMinor: totals.costMinor, + pricedCoverage: totals.pricedCoverage, + currency: prices.currency, + timeZone: site.session.box?.tz, + quarters: GridTodayEntry.quarters(steps, dayStartMs: day.startMs, untilMs: min(day.endMs, site.nowMs)) + ) + site.shareGridToday(entry, readAtMs: readAt, srcState: site.freshness(.grid).srcState) + if let failure { throw failure } + } + + struct Totals: Equatable { + let day: String + let importWh: Double + let exportWh: Double + let costMinor: Double? + let pricedCoverage: Double + } + + /// One row of the box's answer, as it gave it. The cost is the box's own + /// `actual_cost_ore`, import cost less export revenue; a day the box had + /// no prices for has none. + static func totals(_ row: JSON) -> Totals? { + guard let day = row["day"]?.string, day.count == 10 else { return nil } + let priced = row["resolution"]?.string != "no_prices" + return Totals( + day: day, + importWh: EnergyFormat.wholeWh(row["import_wh"]?.number), + exportWh: EnergyFormat.wholeWh(row["export_wh"]?.number), + costMinor: priced ? row["actual_cost_ore"]?.number : nil, + pricedCoverage: min(1, max(0, row["priced_coverage_pct"]?.number ?? 0)) + ) + } +} diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index cd6f7b2..8279e12 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -69,6 +69,9 @@ public final class SiteModel { /// The prices last left for the widgets, so the same prices again are /// neither written nor reloaded. @ObservationIgnored private var lastPrices: PriceEntry? + /// Today over the grid as last left, so the same figures again are not + /// written. + @ObservationIgnored private var lastGridToday: GridTodayEntry? /// 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? @@ -149,11 +152,14 @@ public final class SiteModel { /// One display frame: what arrives within it is drawn together. static let coalesceMs: Double = 16 - public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false) { + /// `demoShares` lets a demo leave its records for the widgets. Only a + /// Debug build asks for it, to photograph them; the demo otherwise + /// writes nothing. + public init(siteId: String?, build: String, ua: String, scheduler: Scheduler, files: SealedFiles?, shared: AppGroupFiles? = nil, isDemo: Bool = false, demoShares: Bool = false) { self.siteId = siteId self.scheduler = scheduler self.files = files - self.shared = isDemo ? nil : shared + self.shared = isDemo && !demoShares ? nil : shared self.isDemo = isDemo nowMs = scheduler.nowMs minuteMs = (scheduler.nowMs / 60_000).rounded(.down) * 60_000 @@ -739,6 +745,17 @@ public final class SiteModel { write(CacheRecord(siteId: siteId, carrier: via ?? session.carrier, srcState: answering ? .live : .stale, readAtMs: now, value: entry), to: shared) } + /// Whether this home leaves records for the widgets. + var sharesWidgets: Bool { siteId != nil && shared != nil } + + /// Leave today over the grid for the grid widget, dated when the box's + /// totals arrived. `srcState` is the grid meter's as it was then. + func shareGridToday(_ entry: GridTodayEntry, readAtMs: Double, srcState: SourceState) { + guard let siteId, let shared, entry != lastGridToday else { return } + lastGridToday = entry + write(CacheRecord(siteId: siteId, carrier: session.carrier, srcState: srcState, readAtMs: readAtMs, value: entry), to: shared) + } + /// Leave the plan for the plan widget. `sentAtMs` is when the box sent /// it, which is what the widget dates it by. func sharePlan(_ entry: PlanEntry, sentAtMs: Double) { @@ -766,6 +783,7 @@ public final class SiteModel { // 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 } + if Value.self == GridTodayEntry.self { self?.lastGridToday = nil } } } writing = task diff --git a/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift b/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift new file mode 100644 index 0000000..d34e915 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/GridGlance.swift @@ -0,0 +1,400 @@ +import Foundation + +/// The grid widget at one moment, worked out from a `GridTodayEntry` alone: +/// today's net energy with its direction and both sides of it, today's cost +/// as the box priced it, and the day as a chart on a fixed 00–24 axis. +/// +/// Net alone is never shown as if it were the bill: the line under it +/// always holds what was bought and what was sold. After local midnight a +/// record from yesterday is not today's, so the widget says today's figures +/// have not come yet rather than showing yesterday as today. +public enum GridGlance { + /// The symbol beside the cost: the grid's own on Now (`NodeLook`), so + /// one thing has one look. In the SDK since iOS 13. + public static let symbol = "bolt.fill" + /// Below this share of the day priced, the cost is marked partial. + public static let fullCoverage = 0.95 + /// Below this a quarter's mean is sensor noise, as on Now. + static let noiseW = PowerFormat.noiseW + /// A side of the chart narrower than this share of it has no label. + static let labelRoom = 0.2 + + public enum State: Equatable, Sendable { + /// The app has left nothing: not paired, or signed out. + case nothing + /// Today's figures, as of `readAtMs`. + case today + /// The record is from a day before this moment's: past midnight, + /// before the app has read the new day. + case newDay + } + + /// Which way the day leans, net. + public enum Direction: Equatable, Sendable { + /// The home took more from the grid than it gave: ↓. + case fromGrid + /// It gave more than it took: ↑. + case toGrid + /// Even, to the tenth of a kWh. + case even + + public var arrow: String { + switch self { + case .fromGrid: return "↓" + case .toGrid: return "↑" + case .even: return "" + } + } + + /// The SF Symbol for the arrow. Nil when even. + public var symbol: String? { + switch self { + case .fromGrid: return "arrow.down" + case .toGrid: return "arrow.up" + case .even: return nil + } + } + } + + /// One import bar: a quarter hour of mean power bought. + public struct Bar: Equatable, Sendable, Identifiable { + public let startMs: Double + public let endMs: Double + public let watts: Double + public var id: Double { startMs } + } + + /// One point of the export curve, at the middle of its quarter, in + /// chart units: negative watts, so it sits below zero. `run` breaks the + /// curve where the box has no record. + public struct CurvePoint: Equatable, Sendable, Identifiable { + public let t: Double + public let watts: Double + public let run: Int + /// Two runs can share the zero between them. + public var id: String { "\(run)/\(Int64(t))" } + } + + /// A label on an axis, at a value in chart units. + public struct Tick: Equatable, Sendable { + public let value: Double + public let label: String + } + + public struct Moment: Equatable, Sendable { + public let atMs: Double + public let state: State + /// When the app read these figures from the box. + public let readAtMs: Double? + + public let direction: Direction + /// "1,8": net, never signed; the arrow says which way. + public let netText: String? + public let unit: String + /// "in 6,5 · ut 8,3 kWh" + public let inOutText: String? + /// "12,40" or "−4,20". Nil when the box had no prices. + public let costText: String? + /// "kr" + public let costUnit: String + /// The day so far earned more than it cost. + public let earned: Bool + /// The box priced less than `fullCoverage` of the day. + public let partial: Bool + /// "delvis", "inga priser", or nil. + public let costNote: String? + /// "kl 16:55", or "senast mån 23:55" once the day has turned. + public let timeText: String? + /// Why there is nothing for today. + public let emptyText: String? + + /// The fixed axis: local midnight to the next. + public let dayStartMs: Double + public let dayEndMs: Double + public let bars: [Bar] + public let curve: [CurvePoint] + /// The chart's value range: top above zero, bottom below. + public let yTop: Double + public let yBottom: Double + /// 0 and at most one round value each way. + public let yTicks: [Tick] + /// 00, 06, 12, 18, 24 on the household's clock. + public let xTicks: [Tick] + public let timeZone: TimeZone + /// What VoiceOver reads for the whole widget. + public let spoken: String + + public var isGrey: Bool { state != .today } + public var hasChart: Bool { !bars.isEmpty || !curve.isEmpty } + } + + // MARK: The timeline + + /// One moment now and one at the next local midnight, where today's + /// figures turn into yesterday's. The app reloads the timeline when it + /// writes, so nothing here asks to be woken. + public static func moments(_ read: CacheRead?, nowMs: Double, calendar given: Calendar? = nil, language: GridLanguage = .current) -> [Moment] { + let calendar = given ?? .household(read?.value.timeZone) + let now = moment(read, atMs: nowMs, calendar: calendar, language: language) + guard read != nil, now.state == .today, + let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000))) else { return [now] } + return [now, moment(read, atMs: midnight.timeIntervalSince1970 * 1000, calendar: calendar, language: language)] + } + + public static func moment(_ read: CacheRead?, atMs t: Double, calendar given: Calendar? = nil, language: GridLanguage = .current) -> Moment { + let calendar = given ?? .household(read?.value.timeZone) + guard let read else { + let day = dayBounds(t, calendar: calendar) + return empty(atMs: t, state: .nothing, readAtMs: nil, text: language.text(.openToSee), time: nil, day: day, calendar: calendar, language: language) + } + let e = read.value + if e.day < GridTodayEntry.dayName(t, calendar: calendar) { + let day = dayBounds(t, calendar: calendar) + return empty(atMs: t, state: .newDay, readAtMs: read.record.readAtMs, text: language.text(.newDay), + time: language.format(.last, clockText(read.record.readAtMs, from: t, calendar: calendar, language: language)), day: day, calendar: calendar, language: language) + } + + let format = Format(language: language) + let netWh = e.importWh - e.exportWh + let netText = format.kwh(abs(netWh)) + let direction: Direction = netText == format.kwh(0) ? .even : (netWh > 0 ? .fromGrid : .toGrid) + let inOut = language.format(.inOut, format.kwh(e.importWh), format.kwh(e.exportWh)) + + let cost = e.costMinor.map { format.money($0) } + let partial = e.costMinor != nil && e.pricedCoverage < fullCoverage + let note: String? = e.costMinor == nil ? language.text(.noPrices) : (partial ? language.text(.partial) : nil) + let unit = currencyUnit(e.currency) + + let chart = chart(e.quarters, dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs) + let time = language.format(.at, clockText(read.record.readAtMs, from: t, calendar: calendar, language: language)) + + var spoken = [String]() + switch direction { + case .fromGrid: spoken.append(language.format(.spokenFrom, netText)) + case .toGrid: spoken.append(language.format(.spokenTo, netText)) + case .even: spoken.append(language.format(.spokenEven, netText)) + } + spoken.append(inOut) + if let cost { + spoken.append(language.format(cost.isNegative ? .spokenEarned : .spokenCost, "\(cost.magnitude) \(unit)")) + } + if let note { spoken.append(note) } + spoken.append(time) + + return Moment( + atMs: t, state: .today, readAtMs: read.record.readAtMs, + direction: direction, netText: netText, unit: "kWh", inOutText: inOut, + costText: cost?.shown, costUnit: unit, earned: cost?.isNegative ?? false, + partial: partial, costNote: note, timeText: time, emptyText: nil, + dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs, + bars: chart.bars, curve: chart.curve, yTop: chart.top, yBottom: chart.bottom, yTicks: chart.ticks, + xTicks: xTicks(dayStartMs: e.dayStartMs, dayEndMs: e.dayEndMs, calendar: calendar), + timeZone: calendar.timeZone, spoken: spoken.joined(separator: ". ") + ) + } + + private static func empty(atMs t: Double, state: State, readAtMs: Double?, text: String, time: String?, day: (Double, Double), calendar: Calendar, language: GridLanguage) -> Moment { + Moment( + atMs: t, state: state, readAtMs: readAtMs, direction: .even, netText: nil, unit: "kWh", inOutText: nil, + costText: nil, costUnit: "", earned: false, partial: false, costNote: nil, timeText: time, emptyText: text, + dayStartMs: day.0, dayEndMs: day.1, bars: [], curve: [], yTop: 1, yBottom: -1, yTicks: [], + xTicks: xTicks(dayStartMs: day.0, dayEndMs: day.1, calendar: calendar), timeZone: calendar.timeZone, + spoken: [text, time].compactMap { $0 }.joined(separator: ". ") + ) + } + + static func dayBounds(_ t: Double, calendar: Calendar) -> (Double, Double) { + let start = calendar.startOfDay(for: Date(timeIntervalSince1970: t / 1000)) + let end = calendar.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86_400) + return (start.timeIntervalSince1970 * 1000, end.timeIntervalSince1970 * 1000) + } + + // MARK: The chart + + /// Bars for what was bought, a curve below zero for what was sold. A + /// quarter without a record breaks the curve and has no bar; the curve + /// is drawn only around export, resting on zero at each end of a run. + static func chart(_ quarters: [GridTodayEntry.Quarter], dayStartMs: Double, dayEndMs: Double) -> (bars: [Bar], curve: [CurvePoint], top: Double, bottom: Double, ticks: [Tick]) { + let q = GridTodayEntry.quarterMs + let inDay = quarters.filter { $0.startMs >= dayStartMs && $0.startMs < dayEndMs } + let bars = inDay.compactMap { r -> Bar? in + guard let w = r.importW, w >= noiseW else { return nil } + return Bar(startMs: r.startMs, endMs: min(dayEndMs, r.startMs + q), watts: w) + } + + var curve = [CurvePoint]() + var run = 0 + var open = false + var previous: GridTodayEntry.Quarter? + for r in inDay { + // The quarter before has a record and touches this one. + let joined = previous.map { $0.measured && $0.startMs + q == r.startMs } ?? false + if !r.measured || !joined, open { + // A hole: the run ends where the record does, off zero. + open = false + run += 1 + } + if r.measured, (r.exportW ?? 0) >= noiseW { + // A run leaves zero from the quarter before, when the box + // has a record for it. + if !open, joined, let p = previous { curve.append(CurvePoint(t: p.startMs + q / 2, watts: 0, run: run)) } + open = true + curve.append(CurvePoint(t: r.startMs + q / 2, watts: -(r.exportW ?? 0), run: run)) + } else if open { + // and lands on zero at the first quarter that sold nothing. + curve.append(CurvePoint(t: r.startMs + q / 2, watts: 0, run: run)) + open = false + run += 1 + } + previous = r + } + + let peakIn = bars.map(\.watts).max() ?? 0 + let peakOut = curve.map { -$0.watts }.max() ?? 0 + let top = peakIn > 0 ? roundUp(peakIn) : 0 + let bottom = peakOut > 0 ? -roundUp(peakOut) : 0 + // A side with nothing on it keeps a sliver, so zero is not the edge. + let span = max(top, -bottom, 1_000) + let high = top > 0 ? top : span * 0.12 + let low = bottom < 0 ? bottom : -span * 0.12 + // A side too thin for its own label would print it on top of zero's. + let room = labelRoom * (high - low) + var ticks = [Tick]() + if top > 0, top >= room { ticks.append(Tick(value: top, label: PowerFormat.scale(top))) } + ticks.append(Tick(value: 0, label: "0")) + if bottom < 0, -bottom >= room { ticks.append(Tick(value: bottom, label: PowerFormat.scale(bottom))) } + return (bars, curve, high, low, ticks) + } + + /// The next round value up: half a kilowatt below one, whole kilowatts + /// to ten, then fives. + static func roundUp(_ w: Double) -> Double { + let kw = w / 1000 + if kw <= 0.5 { return 500 } + if kw <= 10 { return kw.rounded(.up) * 1000 } + return (kw / 5).rounded(.up) * 5000 + } + + /// 00, 06, 12, 18 and 24, on the household's clock, so a day that + /// changes to or from summer time still puts noon at 12. + static func xTicks(dayStartMs: Double, dayEndMs: Double, calendar: Calendar) -> [Tick] { + let start = Date(timeIntervalSince1970: dayStartMs / 1000) + var out = [0, 6, 12, 18].compactMap { h -> Tick? in + guard let d = calendar.date(bySettingHour: h, minute: 0, second: 0, of: start) else { return nil } + return Tick(value: d.timeIntervalSince1970 * 1000, label: String(format: "%02d", h)) + } + out.append(Tick(value: dayEndMs, label: "24")) + return out + } + + /// "16:55" on the household's clock, or "mån 23:55" when that is not + /// the moment's day: a widget can sit unread past midnight. + static func clockText(_ ms: Double, from t: Double, calendar: Calendar, language: GridLanguage) -> String { + let date = Date(timeIntervalSince1970: ms / 1000) + let c = calendar.dateComponents([.hour, .minute], from: date) + let hm = String(format: "%02d:%02d", c.hour ?? 0, c.minute ?? 0) + guard !calendar.isDate(date, inSameDayAs: Date(timeIntervalSince1970: t / 1000)) else { return hm } + var named = calendar + named.locale = language.locale + return "\(named.shortWeekdaySymbols[named.component(.weekday, from: date) - 1]) \(hm)" + } + + // MARK: Figures + + struct Money: Equatable { + /// "−4,20", "12,40" + let shown: String + /// "4,20", for a sentence that names the direction in words. + let magnitude: String + let isNegative: Bool + } + + struct Format { + let language: GridLanguage + + /// One decimal, always, with the language's mark: "6,5", "12,0". + func kwh(_ wh: Double) -> String { + language.decimal(PowerFormat.fixed(max(0, wh) / 1000, 1)) + } + + /// Major units from minor: two decimals under a hundred, none from + /// there. A minus sign, not a hyphen, and none on a zero. + func money(_ minor: Double) -> Money { + let a = abs(minor / 100) + let digits = a >= 100 ? 0 : 2 + let figure = PowerFormat.fixed(a, digits) + let body = language.decimal(figure) + let negative = minor < 0 && figure != PowerFormat.fixed(0, digits) + return Money(shown: (negative ? "−" : "") + body, magnitude: body, isNegative: negative) + } + } + + /// "kr" for the Nordic crowns, "€" for the euro, else the code. + public static func currencyUnit(_ code: String) -> String { + switch code.uppercased() { + case "SEK", "NOK", "DKK", "": return "kr" + case "EUR": return "€" + default: return code.uppercased() + } + } + + public static func title(_ language: GridLanguage = .current) -> String { language.text(.title) } + public static func summary(_ language: GridLanguage = .current) -> String { language.text(.summary) } +} + +/// The language the grid widget speaks: Swedish or English, from the +/// package's own localisation, or named outright so a test can read both. +public struct GridLanguage: Equatable, Sendable { + public let code: String + + public init(_ code: String) { + self.code = code.lowercased().hasPrefix("sv") ? "sv" : "en" + } + + public static var current: GridLanguage { GridLanguage(Words.locale.identifier) } + public static let sv = GridLanguage("sv") + public static let en = GridLanguage("en") + + var locale: Locale { Locale(identifier: code) } + + /// "6,5" in Swedish, "6.5" in English. + func decimal(_ text: String) -> String { + let mark = locale.decimalSeparator ?? "." + return mark == "." ? text : text.replacingOccurrences(of: ".", with: mark) + } + + func text(_ word: GridWord) -> String { + let bundle = Words.bundle.url(forResource: code, withExtension: "lproj").flatMap(Bundle.init(url:)) ?? Words.bundle + return bundle.localizedString(forKey: word.rawValue, value: word.rawValue, table: "Grid") + } + + func format(_ word: GridWord, _ args: String...) -> String { + var out = text(word) + for a in args { + guard let r = out.range(of: "%@") else { break } + out.replaceSubrange(r, with: a) + } + return out + } +} + +/// Every sentence the grid widget shows. The English is the key; the +/// Swedish lives in `Resources/sv.lproj/Grid.strings`, and a test checks +/// each one has it. +enum GridWord: String, CaseIterable { + case title = "Grid today" + case summary = "Today's energy from the grid and what it cost, as your box counted it." + case inOut = "in %@ · out %@ kWh" + case at = "at %@" + case last = "last %@" + case partial = "partial" + case noPrices = "no prices" + case openToSee = "Open FTW to see today's grid" + case newDay = "Today's figures aren't here yet · open FTW" + case spokenFrom = "%@ kWh net from the grid today" + case spokenTo = "%@ kWh net to the grid today" + case spokenEven = "%@ kWh net with the grid today" + case spokenCost = "cost %@" + case spokenEarned = "earned %@" +} + diff --git a/appleApp/FTWKit/Sources/FTWShared/GridToday.swift b/appleApp/FTWKit/Sources/FTWShared/GridToday.swift new file mode 100644 index 0000000..0b678c9 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/GridToday.swift @@ -0,0 +1,116 @@ +import Foundation + +/// Today over the grid, as the app leaves it for the grid widget: what the +/// home bought and sold since midnight and what that cost, as the box +/// counted and priced it, and the grid's power a quarter hour at a time. +/// +/// Every number here is the box's. The app computes no cost: the totals are +/// `GET /api/savings/daily` as the box answered it, and the quarters are the +/// box's own five-minute history of `grid_w`, the series the grid's node +/// view draws. +public struct GridTodayEntry: CacheValue { + public static let name = "grid-today" + public static let version = 1 + /// The chart's step. + public static let quarterMs: Double = 900_000 + + /// One quarter hour of the grid. Both are magnitudes in watts, each the + /// mean over the part of the quarter the box has a record for, so a + /// quarter can hold some of each. Both nil: the box has no record. + public struct Quarter: Codable, Sendable, Equatable { + public var startMs: Double + /// Mean power bought, never negative. + public var importW: Double? + /// Mean power sold, never negative. + public var exportW: Double? + + public init(startMs: Double, importW: Double?, exportW: Double?) { + self.startMs = startMs + self.importW = importW + self.exportW = exportW + } + + public var measured: Bool { importW != nil || exportW != nil } + } + + /// The box's own name for the day, "2026-10-13", on its clock. + public var day: String + /// Local midnight the day starts at, and the next one. + public var dayStartMs: Double + public var dayEndMs: Double + public var importWh: Double + public var exportWh: Double + /// Import cost less export revenue, in minor units (öre, cents), as the + /// box priced it quarter by quarter. Nil when the box had no prices. + public var costMinor: Double? + /// The share of the day so far the box had a price for, 0 to 1. + public var pricedCoverage: Double + public var currency: String + /// The household's time zone, as the box names it. + public var timeZone: String? + /// From midnight to the moment of the read, in order. Quarters after it + /// are not here: the rest of the day has not happened. + public var quarters: [Quarter] + + public init(day: String, dayStartMs: Double, dayEndMs: Double, importWh: Double, exportWh: Double, costMinor: Double?, pricedCoverage: Double, currency: String, timeZone: String?, quarters: [Quarter]) { + self.day = day + self.dayStartMs = dayStartMs + self.dayEndMs = dayEndMs + self.importWh = importWh + self.exportWh = exportWh + self.costMinor = costMinor + self.pricedCoverage = pricedCoverage + self.currency = currency + self.timeZone = timeZone + self.quarters = quarters + } + + /// Quarter hours from history steps of any length, weighted by how much + /// of each step falls in each quarter. A step without a value is a hole + /// and adds nothing, so a quarter the box has no record for stays empty; + /// nothing is bridged. Watts are site-signed: positive is bought. + public static func quarters(_ steps: [(startMs: Double, stepMs: Double, watts: Double?)], dayStartMs: Double, untilMs: Double) -> [Quarter] { + let count = max(0, Int(((untilMs - dayStartMs) / quarterMs).rounded(.up))) + guard count > 0 else { return [] } + var bought = [Double](repeating: 0, count: count) + var sold = [Double](repeating: 0, count: count) + var covered = [Double](repeating: 0, count: count) + for s in steps { + guard let w = s.watts, w.isFinite, s.stepMs > 0 else { continue } + let a = max(s.startMs, dayStartMs) + let b = min(s.startMs + s.stepMs, untilMs) + guard b > a else { continue } + var q = Int(((a - dayStartMs) / quarterMs).rounded(.down)) + while q < count { + let qa = dayStartMs + Double(q) * quarterMs + let overlap = min(b, qa + quarterMs) - max(a, qa) + if overlap <= 0 { break } + bought[q] += max(0, w) * overlap + sold[q] += max(0, -w) * overlap + covered[q] += overlap + q += 1 + } + } + return (0.. 0 else { return Quarter(startMs: start, importW: nil, exportW: nil) } + return Quarter(startMs: start, importW: (bought[q] / covered[q]).rounded(), exportW: (sold[q] / covered[q]).rounded()) + } + } + + /// The box's "2026-10-13" as local midnight and the next one, on the + /// household's calendar. Nil for a day that does not parse. + public static func bounds(day: String, calendar: Calendar) -> (startMs: Double, endMs: Double)? { + let parts = day.split(separator: "-").compactMap { Int($0) } + guard parts.count == 3, + let start = calendar.date(from: DateComponents(year: parts[0], month: parts[1], day: parts[2])), + let end = calendar.date(byAdding: .day, value: 1, to: start) else { return nil } + return (start.timeIntervalSince1970 * 1000, end.timeIntervalSince1970 * 1000) + } + + /// "2026-10-13" for the day `ms` falls on, on the household's calendar. + public static func dayName(_ ms: Double, calendar: Calendar) -> String { + let c = calendar.dateComponents([.year, .month, .day], from: Date(timeIntervalSince1970: ms / 1000)) + return String(format: "%04d-%02d-%02d", c.year ?? 1970, c.month ?? 1, c.day ?? 1) + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings new file mode 100644 index 0000000..0f1db56 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Grid.strings @@ -0,0 +1,15 @@ +/* The grid widget. English is the key (GridWord). */ +"Grid today" = "Grid today"; +"Today's energy from the grid and what it cost, as your box counted it." = "Today's energy from the grid and what it cost, as your box counted it."; +"in %@ · out %@ kWh" = "in %@ · out %@ kWh"; +"at %@" = "at %@"; +"last %@" = "last %@"; +"partial" = "partial"; +"no prices" = "no prices"; +"Open FTW to see today's grid" = "Open FTW to see today's grid"; +"Today's figures aren't here yet · open FTW" = "Today's figures aren't here yet · open FTW"; +"%@ kWh net from the grid today" = "%@ kWh net from the grid today"; +"%@ kWh net to the grid today" = "%@ kWh net to the grid today"; +"%@ kWh net with the grid today" = "%@ kWh net with the grid today"; +"cost %@" = "cost %@"; +"earned %@" = "earned %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings new file mode 100644 index 0000000..780d63d --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Grid.strings @@ -0,0 +1,15 @@ +/* Nätwidgeten. Nyckeln är engelskan (GridWord). */ +"Grid today" = "Nätet i dag"; +"Today's energy from the grid and what it cost, as your box counted it." = "Dagens el från nätet och vad den kostade, som din box räknade."; +"in %@ · out %@ kWh" = "in %@ · ut %@ kWh"; +"at %@" = "kl %@"; +"last %@" = "senast %@"; +"partial" = "delvis"; +"no prices" = "inga priser"; +"Open FTW to see today's grid" = "Öppna FTW för att se nätet i dag"; +"Today's figures aren't here yet · open FTW" = "Dagens siffror har inte kommit än · öppna FTW"; +"%@ kWh net from the grid today" = "%@ kWh netto från nätet i dag"; +"%@ kWh net to the grid today" = "%@ kWh netto till nätet i dag"; +"%@ kWh net with the grid today" = "%@ kWh netto mot nätet i dag"; +"cost %@" = "kostnad %@"; +"earned %@" = "tjänat %@"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift new file mode 100644 index 0000000..4fd12ab --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/GridTodayShareTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing +@testable import FTWKit +@testable import FTWDemo +import FTWShared + +/// Today over the grid, from the box to the App Group: the demo box's +/// `savings/daily` and history, read by `GridTodayShare` as on a real box, +/// and left as the record the grid widget draws. +@MainActor +@Suite struct GridTodayShareTests { + static let stockholm = TimeZone(identifier: "Europe/Stockholm")! + + func shared() -> AppGroupFiles { + AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-grid-\(UUID().uuidString)")) + } + + /// 13 October 2026 at 17:30 in Stockholm. + static let evening: Double = { + var c = Calendar(identifier: .gregorian) + c.timeZone = stockholm + return c.date(from: DateComponents(year: 2026, month: 10, day: 13, hour: 17, minute: 30))!.timeIntervalSince1970 * 1000 + }() + + /// The demo as a Debug build photographs it: it leaves its records for + /// the widgets under the home "demo". + func demo(nowMs: Double = evening, sunny: Bool = false, shares: Bool = true, files: AppGroupFiles) async -> (AppModel, HomeModels, ManualScheduler) { + let scheduler = ManualScheduler(nowMs: nowMs) + let app = AppModel(store: MemoryStore(), files: nil, shared: files, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: Self.stockholm)) + box.house.sunny = sunny + return LoopbackCarrier(box: box, scheduler: s) + }) + app.demoSharesWidgets = shares + app.startDemo() + let home = app.home! + await run(scheduler, 3_000) + return (app, home, scheduler) + } + + func run(_ s: ManualScheduler, _ ms: Double, step: Double = 50) async { + var left = ms + while left > 0 { + for _ in 0..<10 { await Task.yield() } + s.advance(min(step, left)) + left -= step + } + for _ in 0..<10 { await Task.yield() } + } + + func record(_ files: AppGroupFiles) async -> CacheRecord? { + // The write runs off the main actor. + for _ in 0..<200 { + if let r = files.read(GridTodayEntry.self, siteId: "demo") { return r } + try? await Task.sleep(for: .milliseconds(5)) + } + return nil + } + + @Test func theDemoFillsEveryPartOfTheWidget() async throws { + let files = shared() + let (_, home, s) = await demo(files: files) + let r = try #require(await record(files)) + let e = r.value + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = Self.stockholm + #expect(e.day == "2026-10-13") + #expect(e.timeZone == "Europe/Stockholm") + #expect(e.dayEndMs - e.dayStartMs == 24 * 3_600_000) + #expect(e.importWh > 1_000 && e.exportWh > 0) + #expect(e.costMinor != nil) + #expect(e.pricedCoverage == 1) + #expect(e.currency == "SEK") + #expect(r.carrier == .relay) + #expect(r.srcState == .live) + #expect(r.readAtMs <= s.nowMs && r.readAtMs >= Self.evening) + + // The chart covers midnight to now, a quarter at a time, and none + // of the evening still to come. + #expect(e.quarters.count == 71) + #expect(e.quarters.first?.startMs == e.dayStartMs) + #expect(e.quarters.allSatisfy { $0.measured }) + #expect((e.quarters.last?.startMs ?? 0) <= s.nowMs) + + // The totals and the chart are one day: the bars and the curve add + // up to what the box counted, within the quarter still filling. + let bought = e.quarters.reduce(0) { $0 + ($1.importW ?? 0) * 0.25 } + let sold = e.quarters.reduce(0) { $0 + ($1.exportW ?? 0) * 0.25 } + #expect(abs(bought - e.importWh) / e.importWh < 0.03, "\(bought) vs \(e.importWh)") + #expect(abs(sold - e.exportWh) < 0.03 * e.exportWh + 50, "\(sold) vs \(e.exportWh)") + + // And the widget draws it all. + let m = GridGlance.moment(CacheRead(record: r, origin: .app), atMs: s.nowMs, language: .sv) + #expect(m.state == .today) + #expect(m.netText != nil && m.inOutText != nil && m.costText != nil) + #expect(m.costNote == nil) + #expect(!m.bars.isEmpty) + #expect(m.timeText?.hasPrefix("kl ") == true) + // An October evening buys more than it sells. + #expect(m.direction == .fromGrid) + _ = home + } + + /// `-FTWDemoSunny YES`: a long clear day that sells more than it buys, + /// so the arrow points up and the cost is money earned. + @Test func aSunnyDemoDayIsANetExportDay() async throws { + let files = shared() + _ = await demo(sunny: true, files: files) + let r = try #require(await record(files)) + #expect(r.value.exportWh > r.value.importWh) + let m = GridGlance.moment(CacheRead(record: r, origin: .app), atMs: r.readAtMs, language: .sv) + #expect(m.direction == .toGrid) + #expect(m.earned) + #expect(m.costText?.hasPrefix("−") == true) + #expect(!m.curve.isEmpty) + #expect(m.yTicks.count == 3) + } + + @Test func aPartlyPricedDemoDayIsMarked() async throws { + let files = shared() + let scheduler = ManualScheduler(nowMs: Self.evening) + let app = AppModel(store: MemoryStore(), files: nil, shared: files, passkeys: nil, scheduler: scheduler, build: "test", ua: "test", demo: { s in + let box = SimulatedBox(scheduler: s, house: SimulatedHouse(timeZone: Self.stockholm)) + box.pricedShare = 0.8 + return LoopbackCarrier(box: box, scheduler: s) + }) + app.demoSharesWidgets = true + app.startDemo() + await run(scheduler, 3_000) + let r = try #require(await record(files)) + #expect(r.value.pricedCoverage == 0.8) + #expect(GridGlance.moment(CacheRead(record: r, origin: .app), atMs: r.readAtMs, language: .en).costNote == "partial") + } + + /// Without the Debug switch the demo still writes nothing a widget + /// could take for a home. + @Test func theDemoAloneLeavesNoGridRecord() async { + let files = shared() + _ = await demo(shares: false, files: files) + #expect(files.siteIDs(GridTodayEntry.self).isEmpty) + #expect(files.siteIDs(PriceEntry.self).isEmpty) + } + + /// Once a quarter hour while the app is on screen, and not at all while + /// it is hidden. + @Test func itReadsEachQuarterHourOnlyWhileOnScreen() async throws { + let files = shared() + let (_, home, s) = await demo(files: files) + let first = try #require(await record(files)) + // Within the quarter: nothing new. + await run(s, 5 * 60_000, step: 1_000) + #expect(files.read(GridTodayEntry.self, siteId: "demo")?.readAtMs == first.readAtMs) + // Past 17:45 the next quarter is read. + await run(s, 11 * 60_000, step: 1_000) + try? await Task.sleep(for: .milliseconds(50)) + let second = try #require(files.read(GridTodayEntry.self, siteId: "demo")) + #expect(second.readAtMs > first.readAtMs) + #expect(second.value.quarters.count > first.value.quarters.count) + + // Hidden, the app asks nothing. + home.site.setVisible(false) + await run(s, 16 * 60_000, step: 1_000) + try? await Task.sleep(for: .milliseconds(50)) + #expect(files.read(GridTodayEntry.self, siteId: "demo")?.readAtMs == second.readAtMs) + } + + /// The box's row as it answers it. The cost is `actual_cost_ore`, never + /// one this app worked out. + @Test func theTotalsAreTheBoxsOwn() throws { + let row: JSON = ["day": "2026-10-13", "import_wh": 6_512.4, "export_wh": 8_301, "import_cost_ore": 900, "export_revenue_ore": 1_320, + "actual_cost_ore": -420, "priced_coverage_pct": 0.97, "resolution": "slot"] + let t = try #require(GridTodayShare.totals(row)) + #expect(t.day == "2026-10-13") + #expect(t.importWh == 6_512 && t.exportWh == 8_301) + #expect(t.costMinor == -420) + #expect(t.pricedCoverage == 0.97) + + let unpriced: JSON = ["day": "2026-10-13", "import_wh": 6_500, "export_wh": 0, "actual_cost_ore": 0, "priced_coverage_pct": 0, "resolution": "no_prices"] + #expect(try #require(GridTodayShare.totals(unpriced)).costMinor == nil) + // A counter fault is not energy, and a share over one is one. + let odd: JSON = ["day": "2026-10-13", "import_wh": -5, "priced_coverage_pct": 1.4] + let o = try #require(GridTodayShare.totals(odd)) + #expect(o.importWh == 0 && o.exportWh == 0) + #expect(o.pricedCoverage == 1) + #expect(o.costMinor == nil) + #expect(GridTodayShare.totals(["import_wh": 1]) == nil) + } + + /// The widget's two colours are FTW's own roles, `ftw.importing` and + /// `ftw.exporting`, not a copy that drifts from them. + @Test func theWidgetsColoursAreFTWsImportAndExportRoles() throws { + let source = try RepoFiles.read("appleApp/FTWWidgets/GridToday/GridColors.swift") + func pair(_ name: String) throws -> [String] { + let line = try #require(source.split(separator: "\n").first { $0.contains("static let \(name) = Pair(") }) + return line.matches(of: /0x([0-9A-Fa-f]{6})/).map { String($0.1).uppercased() } + } + func role(_ name: String) throws -> [String] { + let text = try RepoFiles.read("appleApp/FTW/UI/Theme.xcassets/\(name).colorset/Contents.json") + let json = try #require(try JSONSerialization.jsonObject(with: Data(text.utf8)) as? [String: Any]) + let colors = try #require(json["colors"] as? [[String: Any]]) + // Light first, then dark, as `Pair` takes them. + let sorted = colors.sorted { ($0["appearances"] == nil ? 0 : 1) < ($1["appearances"] == nil ? 0 : 1) } + return try sorted.map { c in + let comps = try #require((c["color"] as? [String: Any])?["components"] as? [String: String]) + return ["red", "green", "blue"].map { String(comps[$0]!.dropFirst(2)) }.joined().uppercased() + } + } + let importing = try role("ftw.importing"), exporting = try role("ftw.exporting") + #expect(try pair("importing") == importing) + #expect(try pair("exporting") == exporting) + #expect(importing.count == 2) + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift new file mode 100644 index 0000000..ae70163 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/GridGlanceTests.swift @@ -0,0 +1,359 @@ +import Foundation +import Testing +@testable import FTWShared +#if canImport(AppKit) +import AppKit +#endif + +/// The grid widget, worked out from a `GridTodayEntry` alone. Times are +/// Stockholm's; 13 October 2026 is a Tuesday, and summer time ends on 25 +/// October. +@Suite struct GridGlanceTests { + static let quarter: Double = 900_000 + 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 + } + + /// A day that sold more than it bought: 400 W bought through the night, + /// a hole from 10:00 to 10:30, 3 kW sold around noon, bought again from + /// 16:00. Read at 16:55. + func entry(importWh: Double = 6_500, exportWh: Double = 8_300, costMinor: Double? = -420, coverage: Double = 1) -> GridTodayEntry { + let start = at(13, 0) + let quarters = (0..<68).map { i -> GridTodayEntry.Quarter in + let t = start + Double(i) * Self.quarter + switch i { + case 40..<42: return .init(startMs: t, importW: nil, exportW: nil) + case 44..<60: return .init(startMs: t, importW: 0, exportW: i == 52 ? 3_000 : 1_500) + case 60...: return .init(startMs: t, importW: 900, exportW: 0) + default: return .init(startMs: t, importW: 400, exportW: 0) + } + } + return GridTodayEntry(day: "2026-10-13", dayStartMs: start, dayEndMs: at(14, 0), importWh: importWh, exportWh: exportWh, costMinor: costMinor, + pricedCoverage: coverage, currency: "SEK", timeZone: "Europe/Stockholm", quarters: quarters) + } + + func read(_ e: GridTodayEntry, readAtMs: Double) -> CacheRead { + CacheRead(record: CacheRecord(siteId: "home", carrier: .relay, srcState: .live, readAtMs: readAtMs, value: e), origin: .app) + } + + func moment(_ e: GridTodayEntry, readAtMs: Double? = nil, at t: Double? = nil, _ language: GridLanguage) -> GridGlance.Moment { + let read = read(e, readAtMs: readAtMs ?? at(13, 16, 55)) + return GridGlance.moment(read, atMs: t ?? at(13, 17), calendar: calendar, language: language) + } + + // MARK: Figures + + @Test func swedishTakesACommaAndEnglishAPoint() { + let sv = moment(entry(), .sv) + #expect(sv.state == .today) + #expect(sv.netText == "1,8") + #expect(sv.unit == "kWh") + #expect(sv.inOutText == "in 6,5 · ut 8,3 kWh") + #expect(sv.timeText == "kl 16:55") + + let en = moment(entry(), .en) + #expect(en.netText == "1.8") + #expect(en.inOutText == "in 6.5 · out 8.3 kWh") + #expect(en.timeText == "at 16:55") + + // One decimal however large: the line never changes its shape. + let big = moment(entry(importWh: 14_000, exportWh: 2_000), .sv) + #expect(big.netText == "12,0") + #expect(big.inOutText == "in 14,0 · ut 2,0 kWh") + } + + /// Net is a magnitude with an arrow, never a signed figure: ↓ when the + /// home took more than it gave, ↑ when it gave more. + @Test func theArrowSaysWhichWayAndTheFigureNeverCarriesASign() { + let selling = moment(entry(importWh: 6_500, exportWh: 8_300), .sv) + #expect(selling.direction == .toGrid) + #expect(selling.direction.arrow == "↑") + #expect(selling.direction.symbol == "arrow.up") + + let buying = moment(entry(importWh: 8_300, exportWh: 6_500, costMinor: 1_240), .sv) + #expect(buying.direction == .fromGrid) + #expect(buying.direction.arrow == "↓") + #expect(buying.direction.symbol == "arrow.down") + #expect(buying.netText == "1,8") + + // Even to the tenth: no arrow at all. + let even = moment(entry(importWh: 5_000, exportWh: 5_040), .en) + #expect(even.direction == .even) + #expect(even.direction.arrow.isEmpty) + #expect(even.direction.symbol == nil) + #expect(even.netText == "0.0") + + for m in [selling, buying, even] { + #expect(!(m.netText ?? "").contains("-") && !(m.netText ?? "").contains("−")) + // Both sides are always there beside the net. + #expect(m.inOutText != nil) + } + } + + /// The box's `actual_cost_ore / 100`. Money earned carries a minus sign, + /// a real one, and is marked as earned so the view can colour it. + @Test func aDayThatEarnedShowsAMinusAndSaysSo() { + let earned = moment(entry(costMinor: -420), .sv) + #expect(earned.costText == "−4,20") + #expect(earned.costUnit == "kr") + #expect(earned.earned) + #expect(moment(entry(costMinor: -420), .en).costText == "−4.20") + + let paid = moment(entry(costMinor: 1_240), .sv) + #expect(paid.costText == "12,40") + #expect(!paid.earned) + + // Two decimals under a hundred kronor, none from there. + #expect(moment(entry(costMinor: 12_345), .sv).costText == "123") + #expect(moment(entry(costMinor: -12_345), .en).costText == "−123") + // A minus on a figure that rounds to nothing would claim money that + // was not earned. + let nothing = moment(entry(costMinor: -0.4), .sv) + #expect(nothing.costText == "0,00") + #expect(!nothing.earned) + #expect(GridGlance.currencyUnit("EUR") == "€") + #expect(GridGlance.currencyUnit("PLN") == "PLN") + } + + @Test func voiceOverHearsTheDirectionInWords() { + let en = moment(entry(), .en) + #expect(en.spoken == "1.8 kWh net to the grid today. in 6.5 · out 8.3 kWh. earned 4.20 kr. at 16:55") + let sv = moment(entry(importWh: 8_300, exportWh: 6_500, costMinor: 1_240, coverage: 0.5), .sv) + #expect(sv.spoken == "1,8 kWh netto från nätet i dag. in 8,3 · ut 6,5 kWh. kostnad 12,40 kr. delvis. kl 16:55") + } + + // MARK: The cost's mark + + @Test func aCostTheBoxCouldNotFullyPriceIsMarkedPartial() { + let partial = moment(entry(coverage: 0.94), .sv) + #expect(partial.partial) + #expect(partial.costNote == "delvis") + #expect(moment(entry(coverage: 0.94), .en).costNote == "partial") + + let whole = moment(entry(coverage: 0.95), .sv) + #expect(!whole.partial) + #expect(whole.costNote == nil) + + // No prices at all: no cost, said in words, not a zero. + let none = moment(entry(costMinor: nil, coverage: 0), .sv) + #expect(none.costText == nil) + #expect(!none.partial) + #expect(none.costNote == "inga priser") + #expect(moment(entry(costMinor: nil, coverage: 0), .en).costNote == "no prices") + } + + // MARK: The day boundary + + /// After local midnight yesterday's figures are not today's: the + /// widget goes grey and says so until the app has read the new day. + @Test func afterMidnightYesterdayIsNotShownAsToday() { + let late = read(entry(), readAtMs: at(13, 23, 55)) + let moments = GridGlance.moments(late, nowMs: at(13, 23, 58), calendar: calendar, language: .sv) + #expect(moments.count == 2) + #expect(moments[0].state == .today) + #expect(moments[0].netText == "1,8") + #expect(moments[1].atMs == at(14, 0)) + #expect(moments[1].state == .newDay) + #expect(moments[1].isGrey) + #expect(moments[1].netText == nil) + #expect(moments[1].costText == nil) + #expect(!moments[1].hasChart) + #expect(moments[1].emptyText == "Dagens siffror har inte kommit än · öppna FTW") + #expect(moments[1].timeText == "senast tis 23:55") + // The new day's axis, not yesterday's. + #expect(moments[1].dayStartMs == at(14, 0)) + + let en = GridGlance.moment(late, atMs: at(14, 0, 1), calendar: calendar, language: .en) + #expect(en.emptyText == "Today's figures aren't here yet · open FTW") + #expect(en.timeText == "last Tue 23:55") + + // Read after midnight, the new day's record is today's again. + var fresh = entry() + fresh.day = "2026-10-14" + let next = GridGlance.moment(read(fresh, readAtMs: at(14, 0, 10)), atMs: at(14, 0, 15), calendar: calendar, language: .sv) + #expect(next.state == .today) + #expect(next.timeText == "kl 00:10") + } + + @Test func nothingLeftSendsTheReaderToTheApp() { + let moments = GridGlance.moments(nil, nowMs: at(13, 12), calendar: calendar, language: .sv) + #expect(moments.count == 1) + #expect(moments[0].state == .nothing) + #expect(moments[0].emptyText == "Öppna FTW för att se nätet i dag") + #expect(moments[0].timeText == nil) + } + + /// The day the clocks go back has 25 hours; the axis still runs from + /// midnight to midnight, with noon at 12. + @Test func theAxisRunsMidnightToMidnightOnTheHouseholdsClock() throws { + let day = try #require(GridTodayEntry.bounds(day: "2026-10-25", calendar: calendar)) + #expect(day.endMs - day.startMs == 25 * 3_600_000) + let ticks = GridGlance.xTicks(dayStartMs: day.startMs, dayEndMs: day.endMs, calendar: calendar) + #expect(ticks.map(\.label) == ["00", "06", "12", "18", "24"]) + #expect(ticks[2].value == at(25, 12)) + #expect(ticks[2].value - day.startMs == 13 * 3_600_000) + #expect(ticks[4].value == day.endMs) + #expect(GridTodayEntry.dayName(at(25, 23, 30), calendar: calendar) == "2026-10-25") + #expect(GridTodayEntry.bounds(day: "yesterday", calendar: calendar) == nil) + } + + // MARK: The chart + + @Test func importIsBarsAndExportACurveBelowZeroThatBreaksAtAHole() { + let m = moment(entry(), .sv) + // Every quarter that bought: the night and from 16:00, not the hole, + // not noon. + #expect(m.bars.count == 40 + 2 + 8) + #expect(m.bars.allSatisfy { $0.watts >= 400 && $0.endMs - $0.startMs == Self.quarter }) + #expect(!m.bars.contains { $0.startMs == at(13, 10) }) + #expect(!m.bars.contains { $0.startMs == at(13, 13) }) + + // One run: off zero at 10:45, down to 3 kW, back on zero at 15:00. + let curve = m.curve + #expect(Set(curve.map(\.run)).count == 1) + #expect(curve.first?.watts == 0) + #expect(curve.first?.t == at(13, 10, 45) + Self.quarter / 2) + #expect(curve.last?.watts == 0) + #expect(curve.last?.t == at(13, 15) + Self.quarter / 2) + #expect(curve.map(\.watts).min() == -3_000) + #expect(curve.allSatisfy { $0.watts <= 0 }) + + // A quiet axis: one round value each way and zero, never signed. + #expect(m.yTicks.map(\.label) == ["1 kW", "0", "3 kW"]) + #expect(m.yTop == 1_000 && m.yBottom == -3_000) + #expect(m.xTicks.map(\.label) == ["00", "06", "12", "18", "24"]) + } + + @Test func aHoleWhileSellingEndsTheCurveOffZero() { + var e = entry() + // The box lost the meter at 12:00 for half an hour. + for i in e.quarters.indices where e.quarters[i].startMs >= at(13, 12) && e.quarters[i].startMs < at(13, 12, 30) { + e.quarters[i] = .init(startMs: e.quarters[i].startMs, importW: nil, exportW: nil) + } + let curve = moment(e, .sv).curve + let runs = Dictionary(grouping: curve, by: \.run) + #expect(runs.count == 2) + // The first run stops at its last record, not on zero; the second + // picks up at its first, and only lands on zero at the end. + let first = runs.keys.sorted().map { runs[$0]! } + #expect(first[0].last?.watts != 0) + #expect(first[0].last?.t == at(13, 11, 45) + Self.quarter / 2) + #expect(first[1].first?.watts != 0) + #expect(first[1].first?.t == at(13, 12, 30) + Self.quarter / 2) + #expect(first[1].last?.watts == 0) + } + + /// A cloud for one quarter: the curve lands on zero and leaves it again + /// from the same point, and each point keeps an identity of its own. + @Test func twoRunsCanShareTheZeroBetweenThem() { + var e = entry() + e.quarters[50] = .init(startMs: e.quarters[50].startMs, importW: 0, exportW: 0) + let curve = moment(e, .sv).curve + #expect(Set(curve.map(\.run)).count == 2) + #expect(Set(curve.map(\.id)).count == curve.count) + #expect(curve.filter { $0.t == at(13, 12, 30) + Self.quarter / 2 }.map(\.watts) == [0, 0]) + } + + @Test func aDayThatOnlyBoughtHasNoLowerValue() { + var e = entry() + e.quarters = e.quarters.map { .init(startMs: $0.startMs, importW: $0.importW.map { max($0, 2_300) }, exportW: $0.exportW.map { _ in 0 }) } + let m = moment(e, .en) + #expect(m.curve.isEmpty) + #expect(m.yTicks.map(\.label) == ["3 kW", "0"]) + // Zero sits just off the bottom edge. + #expect(m.yBottom < 0 && m.yBottom > -1_000) + // A sliver of export under a night of car charging keeps its + // curve but not its label, which would sit on zero's. + var car = entry() + car.quarters[4] = .init(startMs: car.quarters[4].startMs, importW: 11_400, exportW: 0) + let crowded = moment(car, .en) + #expect(crowded.yTicks.map(\.label) == ["15 kW", "0"]) + #expect(crowded.yBottom == -3_000) + #expect(!crowded.curve.isEmpty) + #expect(GridGlance.roundUp(300) == 500) + #expect(GridGlance.roundUp(9_100) == 10_000) + #expect(GridGlance.roundUp(11_000) == 15_000) + } + + // MARK: Quarters from history + + /// Five-minute steps into quarter hours: a mean over what the box has, + /// a hole only where it has nothing, and nothing after the read. + @Test func quartersAreMeansOfWhatTheBoxRecorded() { + let start = at(13, 0) + let five: Double = 300_000 + func step(_ i: Int, _ w: Double?) -> (startMs: Double, stepMs: Double, watts: Double?) { (start + Double(i) * five, five, w) } + let steps = [step(0, 400), step(1, 400), step(2, nil), + step(3, nil), step(4, nil), step(5, nil), + step(6, 600), step(7, -300), step(8, 0), + step(9, -1_200), step(10, 5_000), step(11, 5_000)] + // Read at 00:50: the fourth quarter is under way. + let q = GridTodayEntry.quarters(steps, dayStartMs: start, untilMs: start + 10 * five) + #expect(q.count == 4) + #expect(q[0] == .init(startMs: start, importW: 400, exportW: 0)) + #expect(q[1] == .init(startMs: start + Self.quarter, importW: nil, exportW: nil)) + #expect(!q[1].measured) + #expect(q[2] == .init(startMs: start + 2 * Self.quarter, importW: 200, exportW: 100)) + // Only the step that began before the read counts. + #expect(q[3] == .init(startMs: start + 3 * Self.quarter, importW: 0, exportW: 1_200)) + + // An hourly step covers four quarters, and a step before midnight + // none. + let hourly = GridTodayEntry.quarters([(start - 3_600_000, 3_600_000, 9_999), (start, 3_600_000, 800)], dayStartMs: start, untilMs: start + 3_600_000) + #expect(hourly.count == 4) + #expect(hourly.allSatisfy { $0.importW == 800 }) + #expect(GridTodayEntry.quarters(steps, dayStartMs: start, untilMs: start).isEmpty) + } + + // MARK: The record + + @Test func theSnapshotComesBackAsItWasWrittenAndAsTheCache() throws { + let files = AppGroupFiles(directory: URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ftw-grid-\(UUID().uuidString)")) + let e = entry(costMinor: nil, coverage: 0.4) + try files.write(CacheRecord(siteId: "home", carrier: .relay, srcState: .lagging, readAtMs: at(13, 16, 55), value: e)) + #expect(try FileManager.default.contentsOfDirectory(atPath: files.directory.path) == ["grid-today-home"]) + let reader = CacheReader(sources: [files]) + #expect(reader.latestSiteID(GridTodayEntry.self) == "home") + let back = try #require(reader.read(GridTodayEntry.self, siteId: "home")) + #expect(back.value == e) + // The hole and the missing cost survive the trip as holes. + #expect(back.value.quarters[40].importW == nil) + #expect(back.value.costMinor == nil) + #expect(back.record.carrier == .relay) + #expect(back.carrier == .cache) + #expect(back.srcState == .lagging) + #expect(back.record.readAtMs == at(13, 16, 55)) + // Not one of the other records. + #expect(files.read(PlanEntry.self, siteId: "home") == nil) + } + + // MARK: Words and symbols + + @Test func everyWordHasSwedish() throws { + let url = try #require(Words.bundle.url(forResource: "Grid", withExtension: "strings", subdirectory: nil, localization: "sv")) + let sv = try #require(NSDictionary(contentsOf: url) as? [String: String]) + for word in GridWord.allCases { + let swedish = try #require(sv[word.rawValue], "no Swedish for \"\(word.rawValue)\"") + #expect(swedish != word.rawValue) + #expect(word.rawValue.components(separatedBy: "%@").count == swedish.components(separatedBy: "%@").count, "\(word.rawValue)") + } + #expect(GridGlance.title(.sv) == "Nätet i dag") + #expect(GridGlance.title(.en) == "Grid today") + } + + #if canImport(AppKit) + /// The symbols the widget draws exist. macOS shares iOS's names for + /// these; the iOS 26 runtime lists them in `name_availability.plist`. + @Test func theSymbolsExist() { + for name in [GridGlance.symbol, "arrow.up", "arrow.down"] { + #expect(NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil, "\(name)") + } + } + #endif +} diff --git a/appleApp/FTWUITests/GridWidgetTour.swift b/appleApp/FTWUITests/GridWidgetTour.swift new file mode 100644 index 0000000..b705005 --- /dev/null +++ b/appleApp/FTWUITests/GridWidgetTour.swift @@ -0,0 +1,140 @@ +import XCTest + +/// Photographs the grid widget for review: on the home screen in each of +/// iOS 26's icon styles, and on the lock screen. Skipped unless asked for. +/// It drives SpringBoard, whose labels follow the simulator's language +/// (Swedish here), and it changes the home and lock screens. +/// +/// Place the widgets first (`IconState.plist`, before boot) and launch the +/// app once with `-FTWDemo YES -FTWDemoWidgets YES`, so the demo leaves its +/// records. Then: +/// +/// TEST_RUNNER_FTW_GRID=Tonat xcodebuild test … -only-testing:FTWUITests/GridWidgetTour/testHomeScreenStyle +/// TEST_RUNNER_FTW_GRID=lock xcodebuild test … -only-testing:FTWUITests/GridWidgetTour/testLockScreen +/// +/// The style is the button's label: Förval, Mörkt, Klart or Tonat. +@MainActor +final class GridWidgetTour: XCTestCase { + var springboard: XCUIApplication { XCUIApplication(bundleIdentifier: "com.apple.springboard") } + var asked: String { ProcessInfo.processInfo.environment["FTW_GRID"] ?? "" } + + func testHomeScreenStyle() throws { + try XCTSkipIf(asked.isEmpty || asked == "lock", "Set TEST_RUNNER_FTW_GRID to Förval, Mörkt, Klart or Tonat.") + XCUIDevice.shared.press(.home) + Thread.sleep(forTimeInterval: 2) + springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.72)).press(forDuration: 2) + Thread.sleep(forTimeInterval: 1.5) + guard tap(springboard.buttons["Ändra"].firstMatch) else { return note("no Ändra") } + Thread.sleep(forTimeInterval: 1) + guard tap(labelled("Anpassa")) else { return note("no Anpassa") } + Thread.sleep(forTimeInterval: 1.5) + guard tap(labelled(asked)) else { return note("no \(asked)") } + Thread.sleep(forTimeInterval: 2) + shoot("style-\(asked)-sheet") + // The sheet is dragged away by its title; Klar leaves editing. + let title = labelled("Anpassa") + if title.waitForExistence(timeout: 2) { + title.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + .press(forDuration: 0.1, thenDragTo: springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))) + } + Thread.sleep(forTimeInterval: 1.5) + let done = springboard.buttons["Klar"].firstMatch + if done.waitForExistence(timeout: 2) { done.tap() } + Thread.sleep(forTimeInterval: 3) + shoot("home-\(asked)") + } + + /// The grid widget into the lock screen's widget row. FTW's widgets are + /// pages in the gallery, in the bundle's order: price, plan, grid. + func testLockScreen() throws { + try XCTSkipUnless(asked == "lock", "Set TEST_RUNNER_FTW_GRID=lock to place the widget.") + let pages = Int(ProcessInfo.processInfo.environment["FTW_GRID_PAGE"] ?? "") ?? 2 + lockButton() + lockButton() + springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).press(forDuration: 2) + Thread.sleep(forTimeInterval: 3) + guard tap(springboard.buttons["posterboard-customize-button"].firstMatch) else { return note("no Customize") } + Thread.sleep(forTimeInterval: 2) + guard tap(springboard.buttons["grouped-widgets-reticle-view"].firstMatch) else { return note("no widget slot") } + Thread.sleep(forTimeInterval: 3) + let ftw = springboard.descendants(matching: .any).matching(NSPredicate(format: "label BEGINSWITH 'FTW'")).firstMatch + if !ftw.waitForExistence(timeout: 3) { springboard.swipeUp() } + guard tap(ftw) else { return note("no FTW in the gallery") } + Thread.sleep(forTimeInterval: 2) + for _ in 0.. XCUIElement { + springboard.descendants(matching: .any).matching(NSPredicate(format: "label == %@", label)).firstMatch + } + + private func tap(_ element: XCUIElement) -> Bool { + guard element.waitForExistence(timeout: 5) else { return false } + element.tap() + return true + } + + /// Says where it stopped, without failing: a failure makes Xcode collect + /// a simulator diagnosis that takes minutes. + private func note(_ text: String) { + print("GridWidgetTour stopped: \(text)") + shoot("stopped") + } + + private func lockButton() { + XCUIDevice.shared.perform(NSSelectorFromString("pressLockButton")) + Thread.sleep(forTimeInterval: 1.5) + } + + private func shoot(_ name: String) { + let shot = XCTAttachment(screenshot: XCUIScreen.main.screenshot()) + shot.name = name + shot.lifetime = .keepAlways + add(shot) + } +} diff --git a/appleApp/FTWWidgets/GridToday/GridColors.swift b/appleApp/FTWWidgets/GridToday/GridColors.swift new file mode 100644 index 0000000..61449be --- /dev/null +++ b/appleApp/FTWWidgets/GridToday/GridColors.swift @@ -0,0 +1,17 @@ +import SwiftUI + +/// The grid widget's two colours, in one place. They are FTW's own roles: +/// `importing` for what the home buys and `exporting` for what it sells, as +/// `Theme.importing` and `Theme.exporting` (`ftw.importing` and +/// `ftw.exporting` in Theme.xcassets). The request asked for purple bars, +/// but purple is the car in FTW (`Theme.mobility`). A test holds these two +/// to the asset catalog. +enum GridColors { + static let importing = Pair(0xCC243D, 0xFF6E74) + static let exporting = Pair(0x009639, 0x5FD37F) + + /// The import bars, and the arrow on a day that bought more. + static var bars: Pair { importing } + /// The export curve, the arrow on a day that sold more, and money earned. + static var curve: Pair { exporting } +} diff --git a/appleApp/FTWWidgets/GridToday/GridTodayViews.swift b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift new file mode 100644 index 0000000..05b53c9 --- /dev/null +++ b/appleApp/FTWWidgets/GridToday/GridTodayViews.swift @@ -0,0 +1,318 @@ +import Charts +import FTWShared +import SwiftUI +import WidgetKit + +/// One moment of the grid widget, in whichever family it sits. Every +/// sentence and figure comes from `GridGlance`; this file only lays them +/// out. +struct GridTodayView: View { + let moment: GridGlance.Moment + @Environment(\.widgetFamily) private var family + + var body: some View { + content + .containerBackground(for: .widget) { Rectangle().fill(background) } + } + + /// Grey when there is nothing for today. The lock screen draws its own, + /// so the accessory family brings none. + private var background: AnyShapeStyle { + switch family { + case .accessoryRectangular, .accessoryInline, .accessoryCircular: return AnyShapeStyle(.clear) + default: return AnyShapeStyle(moment.isGrey ? Tone.empty : Tone.surface) + } + } + + @ViewBuilder private var content: some View { + switch family { + case .accessoryRectangular: RectangularGrid(moment: moment) + case .systemSmall: SmallGrid(moment: moment) + default: MediumGrid(moment: moment) + } + } +} + +/// Net on the left and the cost on the right, the line under each, and +/// the day below them. +struct MediumGrid: View { + let moment: GridGlance.Moment + + var body: some View { + if moment.state == .today { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline) { + NetFigure(moment: moment, size: 30) + Spacer(minLength: 8) + CostFigure(moment: moment, size: 22) + } + HStack(alignment: .firstTextBaseline) { + InOutLine(moment: moment) + Spacer(minLength: 8) + TimeLine(moment: moment, withNote: true) + } + .accessibilityHidden(true) + GridDayChart(moment: moment, labels: true) + .padding(.top, 2) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(moment.spoken) + } else { + GridEmpty(moment: moment) + } + } +} + +/// The figures, stacked, around a small day. +struct SmallGrid: View { + let moment: GridGlance.Moment + + var body: some View { + if moment.state == .today { + VStack(alignment: .leading, spacing: 2) { + NetFigure(moment: moment, size: 26) + InOutLine(moment: moment) + GridDayChart(moment: moment, labels: false) + .padding(.vertical, 3) + // The time has a line of its own, so a long cost never + // cuts it short. + CostFigure(moment: moment, size: 17) + TimeLine(moment: moment, withNote: true) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(moment.spoken) + } else { + GridEmpty(moment: moment) + } + } +} + +/// The lock screen's rectangle: net and cost on one line, both sides of +/// net under it, and the time. +struct RectangularGrid: View { + let moment: GridGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if moment.state == .today, let net = moment.netText { + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("\(arrow)\(Text(net).font(.headline.monospacedDigit())) \(Text(moment.unit).font(.caption))") + .widgetAccentable() + Spacer(minLength: 2) + if let cost = moment.costText { + Text("\(Text(cost).font(.headline.monospacedDigit())) \(Text(moment.costUnit).font(.caption))") + } + } + .lineLimit(1) + .minimumScaleFactor(0.7) + if let inOut = moment.inOutText { + Text(inOut).font(.caption).lineLimit(1).minimumScaleFactor(0.7) + } + Text([moment.costNote, moment.timeText].compactMap { $0 }.joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } else { + Text("\(Image(systemName: GridGlance.symbol)) \(GridGlance.title())") + .font(.caption.weight(.semibold)) + .widgetAccentable() + Text(moment.emptyText ?? "").font(.caption).foregroundStyle(.secondary).lineLimit(2) + if let time = moment.timeText { + Text(time).font(.caption2).foregroundStyle(.secondary).lineLimit(1) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .ignore) + .accessibilityLabel(moment.spoken) + } + + private var arrow: Text { + guard let symbol = moment.direction.symbol else { return Text("") } + return Text("\(Image(systemName: symbol)) ").font(.caption.weight(.bold)) + } +} + +// MARK: Parts + +/// "↓ 1,8 kWh": the arrow says which way, the figure never carries a sign. +struct NetFigure: View { + let moment: GridGlance.Moment + let size: CGFloat + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 3) { + if let symbol = moment.direction.symbol { + Image(systemName: symbol) + .font(.system(size: size * 0.62, weight: .bold)) + .foregroundStyle(moment.direction == .fromGrid ? GridColors.bars : GridColors.curve) + .widgetAccentable() + } + Text(moment.netText ?? "") + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .contentTransition(.numericText()) + .widgetAccentable() + Text(moment.unit) + .font(.caption) + .foregroundStyle(.secondary) + } + .lineLimit(1) + .minimumScaleFactor(0.6) + } +} + +/// "⚡ 12,40 kr", or "−4,20 kr" in the colour of money earned. +struct CostFigure: View { + let moment: GridGlance.Moment + let size: CGFloat + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 3) { + Image(systemName: GridGlance.symbol) + .font(.system(size: size * 0.6, weight: .semibold)) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(moment.costText ?? "–") + .font(.system(size: size, weight: .semibold, design: .rounded).monospacedDigit()) + .foregroundStyle(moment.earned ? AnyShapeStyle(GridColors.curve) : AnyShapeStyle(.primary)) + .contentTransition(.numericText()) + if moment.costText != nil { + Text(moment.costUnit) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .lineLimit(1) + .minimumScaleFactor(0.6) + } +} + +/// "in 6,5 · ut 8,3 kWh": both sides, always, so net is never read as the +/// bill. +struct InOutLine: View { + let moment: GridGlance.Moment + + var body: some View { + Text(moment.inOutText ?? "") + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.75) + } +} + +/// "delvis · kl 16:55": what time the figures are from. +struct TimeLine: View { + let moment: GridGlance.Moment + let withNote: Bool + + var body: some View { + Text((withNote ? [moment.costNote, moment.timeText] : [moment.timeText]).compactMap { $0 }.joined(separator: " · ")) + .font(.caption2) + .foregroundStyle(.tertiary) + .lineLimit(1) + .minimumScaleFactor(0.8) + } +} + +/// Nothing for today: say why and when the last figures were from. +struct GridEmpty: View { + let moment: GridGlance.Moment + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 3) { + Image(systemName: GridGlance.symbol) + .imageScale(.small) + .foregroundStyle(Tone.accent) + .widgetAccentable() + Text(GridGlance.title()).foregroundStyle(.secondary) + } + .font(.caption) + Spacer(minLength: 4) + Text(moment.emptyText ?? "") + .font(.footnote.weight(.medium)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 4) + if let time = moment.timeText { + Text(time).font(.caption2).foregroundStyle(.tertiary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityElement(children: .combine) + } +} + +/// The day on a fixed 00–24 axis: a bar for each quarter hour that bought, +/// a curve with a soft fill below zero for what was sold, nothing for the +/// hours still to come, and a break wherever the box has no record. +/// +/// Tinted and clear home screens draw in one colour: there the bars are +/// solid and the curve and its fill fainter, and zero tells them apart. +struct GridDayChart: View { + let moment: GridGlance.Moment + /// The hour and power labels; the small widget has no room for them. + let labels: Bool + @Environment(\.widgetRenderingMode) private var renderingMode + + var body: some View { + let full = renderingMode == .fullColor + let inset = GridTodayEntry.quarterMs * 0.12 + Chart { + RuleMark(y: .value("Zero", 0)) + .foregroundStyle(Color.secondary.opacity(0.5)) + .lineStyle(StrokeStyle(lineWidth: 0.5)) + ForEach(moment.bars) { bar in + RectangleMark( + xStart: .value("From", date(bar.startMs + inset)), + xEnd: .value("To", date(bar.endMs - inset)), + yStart: .value("Zero", 0.0), + yEnd: .value("Bought", bar.watts) + ) + .foregroundStyle(full ? AnyShapeStyle(GridColors.bars) : AnyShapeStyle(.primary)) + .cornerRadius(1) + } + ForEach(moment.curve) { p in + AreaMark(x: .value("Time", date(p.t)), yStart: .value("Sold", p.watts), yEnd: .value("Zero", 0.0), series: .value("Run", "\(p.run)-")) + .interpolationMethod(.monotone) + .foregroundStyle(full ? AnyShapeStyle(GridColors.curve.opacity(0.22)) : AnyShapeStyle(.primary.opacity(0.18))) + LineMark(x: .value("Time", date(p.t)), y: .value("Sold", p.watts), series: .value("Run", "\(p.run)")) + .interpolationMethod(.monotone) + .foregroundStyle(full ? AnyShapeStyle(GridColors.curve) : AnyShapeStyle(.primary.opacity(0.7))) + .lineStyle(StrokeStyle(lineWidth: 1.5, lineCap: .round, lineJoin: .round)) + } + } + .chartXScale(domain: date(moment.dayStartMs)...date(moment.dayEndMs)) + .chartYScale(domain: moment.yBottom...moment.yTop) + .chartXAxis { + AxisMarks(values: labels ? moment.xTicks.map { date($0.value) } : []) { value in + AxisValueLabel(anchor: anchor(value.index)) { + Text(moment.xTicks[value.index].label) + } + .font(.system(size: 9).monospacedDigit()) + .foregroundStyle(Color.secondary) + } + } + .chartYAxis { + AxisMarks(position: .trailing, values: labels ? moment.yTicks.map(\.value) : []) { value in + AxisValueLabel { + Text(moment.yTicks[value.index].label) + } + .font(.system(size: 9).monospacedDigit()) + .foregroundStyle(Color.secondary) + } + } + .widgetAccentable() + .accessibilityHidden(true) + } + + /// The first and last hour labels sit inside the plot, not off its ends. + private func anchor(_ index: Int) -> UnitPoint { + index == 0 ? .topLeading : (index == moment.xTicks.count - 1 ? .topTrailing : .top) + } + + private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } +} diff --git a/appleApp/FTWWidgets/GridToday/GridTodayWidget.swift b/appleApp/FTWWidgets/GridToday/GridTodayWidget.swift new file mode 100644 index 0000000..4deb642 --- /dev/null +++ b/appleApp/FTWWidgets/GridToday/GridTodayWidget.swift @@ -0,0 +1,81 @@ +import FTWShared +import os +import SwiftUI +import WidgetKit + +private let log = Logger(subsystem: "energy.ftw.app", category: "widgets") + +/// Today over the grid: net energy with its direction and both sides of +/// it, today's cost as the box priced it, and the day as a chart. Like the +/// other widgets it reads only what the app left in the App Group, and says +/// what time its figures are from. +struct GridTodayWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "energy.ftw.grid-today", provider: GridTodayTimeline()) { entry in + GridTodayView(moment: entry.moment) + } + .configurationDisplayName(GridGlance.title()) + .description(GridGlance.summary()) + .supportedFamilies([.systemMedium, .systemSmall, .accessoryRectangular]) + } +} + +struct GridTodayTimelineEntry: TimelineEntry { + let date: Date + let moment: GridGlance.Moment +} + +struct GridTodayTimeline: TimelineProvider { + func placeholder(in context: Context) -> GridTodayTimelineEntry { + // Drawn redacted while the real entry loads, so only the shape shows. + let now = Date.now.timeIntervalSince1970 * 1000 + return GridTodayTimelineEntry(date: .now, moment: GridGlance.moment(Self.sample(nowMs: now), atMs: now)) + } + + func getSnapshot(in context: Context, completion: @escaping (GridTodayTimelineEntry) -> Void) { + // The gallery shows the shape of a day, not "open FTW". + let real = entries(now: .now)[0] + if real.moment.state == .nothing, context.isPreview { + completion(placeholder(in: context)) + } else { + completion(real) + } + } + + /// The app reloads every timeline after it writes. The last entry is + /// local midnight, where today's figures become yesterday's. + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + let entries = entries(now: .now) + if let last = entries.last?.moment { + log.info("grid timeline: \(entries.count, privacy: .public) entries, last \(String(describing: last.state), privacy: .public)") + } + completion(Timeline(entries: entries, policy: .never)) + } + + private func entries(now: Date) -> [GridTodayTimelineEntry] { + let reader = CacheReader.shared() + let read = reader.flatMap { r in r.latestSiteID(GridTodayEntry.self).flatMap { r.read(GridTodayEntry.self, siteId: $0) } } + if reader == nil { log.error("grid widget: no App Group, nothing to read") } + return GridGlance.moments(read, nowMs: now.timeIntervalSince1970 * 1000) + .map { GridTodayTimelineEntry(date: Date(timeIntervalSince1970: $0.atMs / 1000), moment: $0) } + } + + /// A day's shape for the gallery and the redacted placeholder: a night + /// that buys, a noon that sells. Never shown as the home's. + static func sample(nowMs: Double) -> CacheRead { + let calendar = Calendar.current + let start = calendar.startOfDay(for: Date(timeIntervalSince1970: nowMs / 1000)).timeIntervalSince1970 * 1000 + let quarter = GridTodayEntry.quarterMs + let quarters = (0..<68).map { i -> GridTodayEntry.Quarter in + let h = Double(i) / 4 + let sun = max(0, sin(Double.pi * (h - 7) / 11)) + let use: Double = 450 + (h >= 6.5 && h < 8.5 ? 1_400 : 0) + (h >= 16 ? 1_900 : 0) + let net = use - 4_200 * sun + return GridTodayEntry.Quarter(startMs: start + Double(i) * quarter, importW: max(0, net), exportW: max(0, -net)) + } + let entry = GridTodayEntry(day: GridTodayEntry.dayName(nowMs, calendar: calendar), dayStartMs: start, dayEndMs: start + 86_400_000, + importWh: 9_400, exportWh: 11_200, costMinor: -360, pricedCoverage: 1, currency: "SEK", timeZone: nil, quarters: quarters) + let record = CacheRecord(siteId: "", carrier: .none, srcState: .never, readAtMs: start + 68 * quarter, value: entry) + return CacheRead(record: record, origin: .app) + } +} diff --git a/appleApp/FTWWidgets/PriceWidget.swift b/appleApp/FTWWidgets/PriceWidget.swift index ce61cbd..cd4af44 100644 --- a/appleApp/FTWWidgets/PriceWidget.swift +++ b/appleApp/FTWWidgets/PriceWidget.swift @@ -13,6 +13,7 @@ struct FTWWidgets: WidgetBundle { PlanWidget() ChargeNowControl() ChargeNowLiveActivity() + GridTodayWidget() } }