diff --git a/CLAUDE.md b/CLAUDE.md index e5dacbaf..6673a39f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -150,19 +150,29 @@ native, and smooth enough to impress. longer one, or one the box confirms empty, breaks the line, and no "no data" ever sits over a joined span. What this phone did not see live is drawn in ink on the same curve. No dot travels along a line. -- **Every chart takes a finger.** The point under it is marked, and the - line under the chart says what was there. Two fingers give the span. -- **History sits small and grows** (Fredrik, 14 Oct 2026). A node's chart - is compact, so the view never scrolls for it; a tap opens it on the - whole screen, growing out of the small one. Chips add or take away - series, kept per viewer; the last one stays, and a hidden series never - changes what the others say. -- **One material for every power trace** (Fredrik, 14 Oct 2026). Live, - Today and 48 h, small and enlarged, draw through `TraceInk` (band, - body, line, glow only while live), `ChartAxes`, `LockOn` and - `SlidingSurface` (a slide of 0.25 s, a spring of 0.35 s), and the hero - follows the finger on each. Going from Live to Today changes the time, - never the material. A new chart starts from these. +- **A node has one timeline** (Fredrik, 18 Oct 2026). It opens from the + phone's cached box history, adds source observations as they arrive, + and shows the box's plan or forecast beyond now. No Live/Today/48 h/ + Ahead picker. Drag to move through time, pinch around the fingers to + zoom, and hold to read. A panned window stays put while new data lands; + Back to now follows the readings again. Compact and enlarged share it. +- **Keep the evidence distinct.** Closed history means keep the interval + the box returned; the car's ledger means stay hourly. An unfinished bucket is not + instantaneous power: wait for it to close, with actual source readings + drawn beside the stored past. A lone live point hides no history, source + silences and confirmed history holes stay open, and fetched means never + become synthetic second-by-second values. Only a fresh observed head + glows. A forecast stays dashed and a plan stays ghost bars after now. +- **Visible energy follows visible time.** Sum only closed recorded + intervals in the shown past; never add live and forecast energy to it. + A cut through a stored interval is an estimate, and missing or unfinished + time is explicit. A held point names its source granularity. +- **History sits small and grows.** A node's chart is compact; a tap opens + it on the whole screen. Chips keep the chosen series per viewer; the + last one stays, and a hidden series changes no other series' values. +- **One material for every power trace.** The node timeline uses + `TraceInk`, `ChartAxes` and `LockOn`. Other charts keep their own scrub + and two-finger span where those describe the data they show. - **Charts draw on the engine.** Geometry is a value built once per data change (`FTWShared/Charts`, tested). A chart is one Canvas (`ChartSurface`) that never sees the finger and a small `ScrubOverlay` diff --git a/appleApp/FTW/Car/CarView.swift b/appleApp/FTW/Car/CarView.swift index a9fe5d45..44d1951f 100644 --- a/appleApp/FTW/Car/CarView.swift +++ b/appleApp/FTW/Car/CarView.swift @@ -19,7 +19,6 @@ struct CarView: View { let id: String? @State private var editingGoal = false @State private var scrub = ScrubModel() - @State private var trace = NodeTraceModel() @State private var text = PeriodText() private var model: LoadpointsModel { home.loadpoints } @@ -86,7 +85,7 @@ struct CarView: View { } // The hours ahead are a plan, not a reading: a finger on // the plan leaves the hero on now. - NodeHero(site: home.site, node: .car, home: home, trace: trace, scrub: scrub, timebase: .ahead, text: text) + NodeHero(site: home.site, node: .car, home: home, scrub: scrub, text: text, followsChart: false) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) // The status line under the title is the only one. diff --git a/appleApp/FTW/UI/NodeHistory.swift b/appleApp/FTW/UI/NodeHistory.swift index 39aa727e..38dae903 100644 --- a/appleApp/FTW/UI/NodeHistory.swift +++ b/appleApp/FTW/UI/NodeHistory.swift @@ -128,117 +128,11 @@ final class PeriodText { } } -// MARK: - Today and two days +// MARK: - The stored past and the box's plan -/// Today on its fixed day, or the last two days, on the engine, in the live -/// trace's material: each chosen series with its band, the node's own with -/// its body, holes as they are, and the finger reading one slot at a time, -/// up to the last slot with data. Under the finger the hero and the line -/// below speak as on Live. The rest of today is the ghost of what the box -/// plans or forecasts for each series, so the whole day reads as one -/// picture. A tap opens the chart on the whole screen. -struct HistoryChartArea: View { - let site: SiteModel - let node: EnergyNode - let home: HomeModels - let scrub: ScrubModel - let period: NodeView.Timebase - let series: [EnergyNode] - let text: PeriodText - var onTap: (() -> Void)? - @State private var size: CGSize = .zero - @State private var shown: YDomain? - @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 - @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 - - /// The frame for a scale: the right gutter holds its widest label. - private func frame(_ domain: YDomain?) -> ChartFrame { - ChartFrame(top: tag * 1.35 + 8, bottom: axis * 1.4 + 4, trailing: ChartAxes.gutter(domain, axis: axis)) - } - - /// The picture and its frame: built for the scale on screen, and again - /// in the rare case the new scale's labels need another gutter. - private func made() -> (frame: ChartFrame, picture: HistoryPicture?) { - let first = frame(shown) - let picture = build(first.plot(in: size).size) - guard let domain = picture?.domain, frame(domain) != first else { return (first, picture) } - let fitted = frame(domain) - return (fitted, build(fitted.plot(in: size).size)) - } - - var body: some View { - let (frame, picture) = made() - let own = picture?.layers.first - ZStack { - Color.clear - if let picture, let own { - let looks = Dictionary(uniqueKeysWithValues: picture.layers.compactMap { l in EnergyNode(rawValue: l.key).map { (l.key, NodeChartLook.look($0)) } }) - let labels = ChartLabels(fetching: L("FETCHING…"), asOf: picture.asOfMs.map { NodeWords.format("AS OF %@", AppWords.clock($0, zone: .current)) }, now: NodeWords.text("NOW")) - let live = site.freshness(node).live - SlidingSurface(picture: picture) { from, slide, spring in - HistoryDrawing(picture: picture, looks: looks, frame: frame, words: labels, axis: axis, live: live, from: from, slide: slide, spring: spring) - } - ScrubOverlay( - track: ScrubTrack( - grid: picture.grid, - domain: picture.domain, - values: own.series.buckets.map(\.mean), - atMs: own.series.buckets.map(\.atMs), - tags: own.series.readouts.when.indices.map { own.series.readouts.when[$0] + " " + (own.series.readouts.what[$0].components(separatedBy: " · ").first ?? "") }, - landmarks: own.series.readouts.landmarks.union([0, max(0, picture.limit ?? 0)]), - color: NodeChartLook.look(node).up, - downColor: NodeChartLook.look(node).down, - limit: picture.limit - ), - frame: frame, - model: scrub, - tagSize: tag, - // As on the live trace: "07:30–14:30 avg 522 W". - spanTag: { r in - let readouts = own.series.readouts - let when = (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") - let means = own.series.buckets[r].compactMap(\.mean) - return means.isEmpty ? when : when + " " + NodeWords.format("avg %@", AppWords.power(abs(means.reduce(0, +) / Double(means.count)))) - }, - onTap: onTap - ) - } else if let problem = home.nodes.history.problem { - Text(problem).font(.footnote).foregroundStyle(Theme.ink3).multilineTextAlignment(.center) - } - } - .dynamicTypeSize(...DynamicTypeSize.xxLarge) - .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } - .onChange(of: picture?.domain) { _, now in shown = now } - .onChange(of: picture, initial: true) { _, now in words(now) } - .accessibilityElement() - .accessibilityLabel(node.title) - .accessibilityChartDescriptor(PictureDescriptor(node: node, picture: picture)) - } - - /// The picture for the chosen series, the node's own first. - private func build(_ plot: CGSize) -> HistoryPicture? { - guard plot.width > 0, plot.height > 0 else { return nil } - let clock = ZoneClock.of(.current) - let now = site.minuteMs + 60_000 - let window = period == .today ? HistoryPicture.day(now, clock: clock) : HistoryPicture.twoDays(now) - var inputs: [(key: String, samples: [ChartSample])] = [] - var ahead: [String: (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])] = [:] - for n in series { - let samples = Self.samples(n, home: home, fromMs: window.fromMs) - if !samples.isEmpty || n == node { inputs.append((n.rawValue, samples)) } - // Today runs to midnight: the rest of it as the box sees it. - if period == .today, let plan = Self.ahead(n, home: home, nowMs: site.minuteMs) { ahead[n.rawValue] = plan } - } - let words = ReadoutWords(value: { AppWords.power(abs($0)) }, range: { "\(AppWords.decimal(PowerFormat.fixed(abs($0) / 1000, 1)))–\(AppWords.decimal(PowerFormat.fixed(abs($1) / 1000, 1)))" }, none: NodeWords.text("no data")) - let weekday: @Sendable (Double) -> String = { ms in - Date(timeIntervalSince1970: ms / 1000).formatted(.dateTime.weekday(.abbreviated).locale(Locale(identifier: AppWords.language == .sv ? "sv_SE" : "en_US"))).uppercased().replacingOccurrences(of: ".", with: "") - } - return HistoryPicture.build(inputs, ahead: ahead, fromMs: window.fromMs, toMs: window.toMs, nowMs: now, size: plot, previous: shown, floor: node.scaleFloorW(), clock: clock, everyHours: period == .today ? 6 : 12, weekday: period == .today ? nil : weekday, words: words) - } - - /// What the box plans or forecasts for a node from now on, as Ahead - /// draws it: the battery's and the grid's plan, the car's windows, the - /// sun's and the home's forecast. Nil where it has none. +/// Shared inputs for the continuous node timeline. +@MainActor +enum NodeHistory { static func ahead(_ node: EnergyNode, home: HomeModels, nowMs: Double) -> (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])? { let plan = home.nodes.plan let marks: [AheadPicture.Mark] @@ -259,77 +153,18 @@ struct HistoryChartArea: View { return marks.isEmpty ? nil : (kind, marks) } - /// One node's five-minute means as the chart reads them. The box keeps - /// no car tiles, so the car's hours stand in, each hour's mean power - /// over its twelve five-minute steps. + /// The box's power means at their stored step. The car's ledger is + /// hourly; those means stay hourly, including while zoomed in. static func samples(_ node: EnergyNode, home: HomeModels, fromMs: Double) -> [ChartSample] { if node == .car { guard home.nodes.carEnergy.loaded else { return [] } - return home.nodes.carEnergy.hours.filter { $0.startMs + $0.lenMs > fromMs }.flatMap { h -> [ChartSample] in - let watts = h.wh * 3_600_000 / max(1, h.lenMs) - return stride(from: h.startMs, to: h.startMs + h.lenMs, by: 300_000).map { ChartSample(startMs: $0, value: watts) } + return home.nodes.carEnergy.hours.filter { $0.startMs + $0.lenMs > fromMs }.map { h in + ChartSample(startMs: h.startMs, value: h.wh * 3_600_000 / max(1, h.lenMs)) } } return home.nodes.history.samples(node, fromMs: fromMs).map { ChartSample(startMs: $0.startMs, value: $0.watts, fetching: $0.fetching) } } - /// What a finger says, per slot, in the live trace's words. The hero: - /// the node's own mean and "14:30–15:00 · charging". The line under the - /// chart: the energy so far, then each other series' mean. Two fingers: - /// their mean in the hero, the energy between under the chart. A - /// hidden series changes nothing the others say. - private func words(_ picture: HistoryPicture?) { - guard let picture, let own = picture.layers.first else { return text.set([], spans: { _ in "" }) } - let node = node - let others = picture.layers.dropFirst().compactMap { l in EnergyNode(rawValue: l.key).map { ($0, l.series) } } - let readouts = own.series.readouts - let buckets = own.series.buckets - let slots = readouts.when.indices.map { i -> String in - var parts = [NodeLive.soFar(node, readouts.soFar(i))] - for (n, s) in others where s.readouts.what.indices.contains(i) { - parts.append(n.title + " " + (s.readouts.what[i].components(separatedBy: " · ").first ?? "")) - } - return parts.joined(separator: " · ") - } - let heroes = buckets.indices.map { i -> PeriodText.Hero in - let when = readouts.when[i] - guard let m = buckets[i].mean else { - return PeriodText.Hero(watts: nil, signed: 0, word: when + " · " + (buckets[i].fetching ? L("FETCHING…").lowercased() : NodeWords.text("no data"))) - } - return PeriodText.Hero(watts: abs(m), signed: m, word: when + " · " + Readouts.word(node, m)) - } - func span(_ r: ClosedRange) -> String { - (readouts.when[r.lowerBound].components(separatedBy: "–").first ?? "") + "–" + (readouts.when[r.upperBound].components(separatedBy: "–").last ?? "") - } - text.set(slots, spans: { r in NodeLive.between(node, readouts.between(r.lowerBound, r.upperBound)) }, heroes: heroes, heroSpans: { r in - let means = buckets[r].compactMap(\.mean) - guard !means.isEmpty else { return PeriodText.Hero(watts: nil, signed: 0, word: span(r) + " · " + NodeWords.text("no data")) } - let mean = means.reduce(0, +) / Double(means.count) - // Short words, so the hero keeps one size on one line. - return PeriodText.Hero(watts: abs(mean), signed: mean, word: span(r) + " · " + NodeWords.format("avg %@", AppWords.power(abs(mean)))) - }) - } -} - -/// A history as VoiceOver reads it: the chart's own slot means, in the -/// slot's length. -private struct PictureDescriptor: AXChartDescriptorRepresentable { - let node: EnergyNode - let picture: HistoryPicture? - - func makeChartDescriptor() -> AXChartDescriptor { - let buckets = picture?.layers.first?.series.buckets ?? [] - let start = buckets.first?.startMs ?? 0, end = max(start + 1, buckets.last?.endMs ?? 1) - let values = buckets.compactMap(\.mean) - let lo = min(0, values.min() ?? 0), hi = max(lo + 1, values.max() ?? 1) - let x = AXNumericDataAxisDescriptor(title: L("Time"), range: start...end, gridlinePositions: []) { Readouts.clock($0) } - let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...hi, gridlinePositions: []) { AppWords.power(abs($0)) } - let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: buckets.compactMap { b in - b.mean.map { AXDataPoint(x: b.startMs, y: $0, additionalValues: [], label: Readouts.word(node, $0)) } - }) - let minutes = Int(((picture?.stepMs ?? 1_800_000) / 60_000).rounded()) - return AXChartDescriptor(title: node.title, summary: NodeWords.format("%@-minute means", String(minutes)), xAxis: x, yAxis: y, additionalAxes: [], series: [series]) - } } // MARK: - Ahead diff --git a/appleApp/FTW/UI/NodeTimeline.swift b/appleApp/FTW/UI/NodeTimeline.swift new file mode 100644 index 00000000..683a5d62 --- /dev/null +++ b/appleApp/FTW/UI/NodeTimeline.swift @@ -0,0 +1,449 @@ +import Accessibility +import FTWKit +import SwiftUI + +/// One timeline, shared by the compact and full-screen charts. Histories +/// are already cached by the home: a gesture sends no network request. +struct TimelineChartArea: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + let scrub: ScrubModel + @Binding var window: TimelineWindow + let series: [EnergyNode] + let text: PeriodText + var onTap: (() -> Void)? + @State private var size = CGSize.zero + @State private var shown: YDomain? + @State private var interacting = false + @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 + @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 + + private struct Input { + var node: EnergyNode + var history: [ChartSample] + var observations: [LiveSample] + var stepMs: Double + var gapMs: Double + var ahead: (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])? + } + + private func inputs() -> [Input] { + _ = home.nodes.live.revision + return series.map { n in + let points = home.nodes.live.samples(n, sinceMs: site.nowMs - LiveTrace.windowMs) + let period = LiveSeries.period(of: points) ?? 1_000 + let step = n == .car ? 3_600_000 : (home.nodes.history.frame?.stepMs ?? 300_000) + var history = NodeHistory.samples(n, home: home, fromMs: window.fromMs - 3_600_000) + if history.isEmpty, n != .car, !home.nodes.history.loaded, home.nodes.history.problem == nil { + let first = (window.fromMs / 300_000).rounded(.down) * 300_000 + history = stride(from: first, to: min(window.toMs, site.nowMs), by: 300_000).map { ChartSample(startMs: $0, value: nil, fetching: true) } + } + history = history.map { sample in + sample.startMs + step <= site.nowMs ? sample : ChartSample(startMs: sample.startMs, value: nil, fetching: true) + } + return Input(node: n, history: history, observations: points, + stepMs: step, + gapMs: max(site.readingGapMs, LiveSeries.joinPeriods * period), + ahead: NodeHistory.ahead(n, home: home, nowMs: site.nowMs)) + } + } + + private func frame(_ domain: YDomain?) -> ChartFrame { + ChartFrame(top: tag * 1.35 + 8, bottom: axis * 1.4 + 4, trailing: ChartAxes.gutter(domain, axis: axis)) + } + + private func picture(_ inputs: [Input], size: CGSize) -> HistoryPicture { + let values = inputs.flatMap { $0.observations.filter { $0.t >= window.fromMs && $0.t <= min(window.toMs, site.nowMs) }.map(\.v) } + let extent = values.min().flatMap { lo in values.max().map { lo...$0 } } + let words = ReadoutWords(value: { AppWords.power(abs($0)) }, range: { "\(AppWords.power(abs($0)))–\(AppWords.power(abs($1)))" }, none: L("no data")) + var picture = HistoryPicture.build(inputs.map { ($0.node.rawValue, $0.history) }, + ahead: Dictionary(uniqueKeysWithValues: inputs.compactMap { i in i.ahead.map { (i.node.rawValue, $0) } }), + fromMs: window.fromMs, toMs: window.toMs, nowMs: site.nowMs, size: size, previous: shown, + floor: node.scaleFloorW(), clock: .of(.current), everyHours: window.spanMs > 24 * 3_600_000 ? 12 : max(1, Int(window.spanMs / (4 * 3_600_000))), + observationExtent: extent, sampleSteps: Dictionary(uniqueKeysWithValues: inputs.map { ($0.node.rawValue, $0.stepMs) }), words: words) + // At short scales labels need minutes, and at long ones the date. + let step = window.spanMs <= 3_600_000 ? 900_000.0 : window.spanMs <= 6 * 3_600_000 ? 3_600_000.0 : window.spanMs <= 24 * 3_600_000 ? 6 * 3_600_000.0 : 12 * 3_600_000.0 + let clock = ZoneClock.of(.current) + var at = clock.alignDown(window.fromMs, stepMs: step) + step + picture.ticks = [] + while at < window.toMs - window.spanMs * 0.04 { + let label = window.spanMs >= 24 * 3_600_000 && clock.minuteOfDay(at) == 0 + ? Date(timeIntervalSince1970: at / 1000).formatted(.dateTime.day().month(.abbreviated).locale(Locale(identifier: AppWords.language == .sv ? "sv_SE" : "en_US"))) + : clock.label(at) + picture.ticks.append(.init(ms: at, label: label)) + at += step + } + for i in picture.layers.indices { + guard var ghost = picture.layers[i].ghost, ghost.kind == .forecast, + let input = inputs.first(where: { $0.node.rawValue == picture.layers[i].key }), let ahead = input.ahead else { continue } + ghost.line = AheadPicture.forecastLine(ahead.slots, fromMs: window.fromMs, toMs: window.toMs, domain: picture.domain, size: size, intervalMeans: true, notBeforeMs: site.nowMs) + picture.layers[i].ghost = ghost + } + return picture + } + + var body: some View { + let data = inputs() + let initial = frame(shown) + let first = picture(data, size: initial.plot(in: size).size) + let frame = frame(first.domain) + let plot = frame.plot(in: size) + let picture = frame == initial ? first : picture(data, size: plot.size) + let traces = Dictionary(uniqueKeysWithValues: data.compactMap { input -> (String, LiveSeries)? in + let points = input.observations.filter { $0.t <= site.nowMs } + guard !points.isEmpty else { return nil } + let trace = LiveSeries.build(points, sourceGapMs: input.gapMs, windowMs: window.spanMs, nowMs: window.toMs, size: plot.size, inset: 0) + return trace.readings.isEmpty ? nil : (input.node.rawValue, trace) + }) + let looks = Dictionary(uniqueKeysWithValues: data.map { ($0.node.rawValue, NodeChartLook.look($0.node)) }) + let live = Set(data.filter { site.freshness($0.node).live }.map { $0.node.rawValue }) + let empty = emptyMessage(data) + chart(data, picture: picture, traces: traces, frame: frame, looks: looks, live: live, empty: empty) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .onChange(of: picture.domain) { shown = picture.domain } + .onChange(of: home.nodes.live.revision, initial: true) { followIfFree() } + .onChange(of: site.minuteMs) { followIfFree() } + .onChange(of: interacting) { followIfFree() } + .modifier(TimelineAccessibility(node: node, window: $window, nowMs: site.nowMs, + empty: empty, history: picture, observations: traces[node.rawValue]?.readings ?? [], + sampleMs: data.first(where: { $0.node == node })?.stepMs ?? 300_000)) + } + + private func followIfFree() { + if !interacting { window.follow(nowMs: site.nowMs) } + } + + private func chart(_ data: [Input], picture: HistoryPicture, traces: [String: LiveSeries], frame: ChartFrame, looks: [String: TraceLook], live: Set, empty: String?) -> some View { + ZStack { + ChartSurface(drawing: TimelineDrawing(history: picture, traces: traces, looks: looks, frame: frame, liveKeys: live, axis: axis, words: ChartLabels(fetching: L("Waiting for history"), now: L("NOW")))) + .equatable() + TimelineTouchOverlay(window: $window, nowMs: site.nowMs, frame: frame, domain: picture.domain, scrub: scrub, text: text, node: node, + reading: { ms in + guard let own = data.first(where: { $0.node == node }) else { return .missing } + return TimelineReading.at(ms, history: own.history, sampleMs: own.stepMs, observations: own.observations, gapMs: own.gapMs, nowMs: site.nowMs, ahead: own.ahead) + }, onTap: onTap, touchingChanged: { interacting = $0 }) + if let message = empty { + Text(message).font(.footnote).foregroundStyle(Theme.ink2) + .multilineTextAlignment(.center).padding(.horizontal, 28) + .allowsHitTesting(false) + } + } + } + + private func emptyMessage(_ inputs: [Input]) -> String? { + guard let own = inputs.first(where: { $0.node == node }) else { return nil } + let end = min(window.toMs, site.nowMs) + if window.fromMs >= site.nowMs { + return own.ahead?.slots.contains(where: { $0.startMs < window.toMs && $0.endMs > window.fromMs }) == true ? nil : L("No plan or forecast for this time.") + } + let hasHistory = own.history.contains { $0.value != nil && $0.startMs < end && $0.startMs + own.stepMs > window.fromMs } + let hasObservation = own.observations.contains { $0.t >= window.fromMs && $0.t <= end } + guard !hasHistory, !hasObservation else { return nil } + let problem = node == .car ? home.nodes.carEnergy.problem : home.nodes.history.problem + let loaded = node == .car ? home.nodes.carEnergy.loaded : home.nodes.history.loaded + if let problem { return problem } + if !loaded { return L("Fetching history…") } + if own.history.contains(where: { $0.fetching && $0.startMs < end && $0.startMs + own.stepMs > window.fromMs }) { return L("Waiting for history") } + return L("No recorded history in this window.") + } +} + +private struct TimelineAccessibility: ViewModifier { + let node: EnergyNode + @Binding var window: TimelineWindow + let nowMs: Double + let empty: String? + let history: HistoryPicture + let observations: [LiveSample] + let sampleMs: Double + + func body(content: Content) -> some View { + content + .accessibilityElement(children: .ignore) + .accessibilityLabel(node.title + ". " + L("History and live readings")) + .accessibilityValue(TimelineWords.window(window) + (empty.map { ". " + $0 } ?? "")) + .accessibilityHint(L("Swipe up or down to move through time. Actions change the zoom.")) + .accessibilityAdjustableAction(adjust) + .accessibilityAction(named: Text(L("Zoom in"))) { zoom(2) } + .accessibilityAction(named: Text(L("Zoom out"))) { zoom(0.5) } + .accessibilityAction(named: Text(L("Back to now"))) { window.returnToNow(nowMs: nowMs) } + .accessibilityChartDescriptor(TimelineDescriptor(node: node, history: history, observations: observations, sampleMs: sampleMs)) + .accessibilityIdentifier("node.timeline.chart") + } + + private func adjust(_ direction: AccessibilityAdjustmentDirection) { + let destination: Double + switch direction { + case .increment: destination = -0.25 + case .decrement: destination = 0.25 + @unknown default: return + } + window = window.transformed(anchor: 0, destination: destination, magnification: 1, nowMs: nowMs) + } + + private func zoom(_ factor: Double) { + window = window.transformed(anchor: 0.5, destination: 0.5, magnification: factor, nowMs: nowMs) + } +} + +/// Touches redraw this small overlay, not the chart geometry. A quick +/// drag pans; a hold reads. Two fingers move and zoom around their own +/// centroid. Starting at the leading edge leaves the system back gesture. +private struct TimelineTouchOverlay: View { + @Binding var window: TimelineWindow + let nowMs: Double + let frame: ChartFrame + let domain: YDomain + let scrub: ScrubModel + let text: PeriodText + let node: EnergyNode + let reading: (Double) -> TimelineReading + var onTap: (() -> Void)? + var touchingChanged: (Bool) -> Void + @State private var size = CGSize.zero + @State private var start: Touch? + @State private var selectedMs: Double? + @GestureState private var touching = false + + private struct Touch { + var window: TimelineWindow + var positions: [CGFloat] + var began: Date + var lastX: CGFloat + // 0 waits for intent, 1 pans, 2 reads, 3 zooms. + var mode = 0 + } + + var body: some View { + Canvas { g, size in + guard scrub.isDown, let selectedMs else { return } + let plot = frame.plot(in: size) + let x = plot.minX + CGFloat((selectedMs - window.fromMs) / window.spanMs) * plot.width + let px = 1 / max(1, g.environment.displayScale) + LockOn.rule(at: x, plot: plot, px: px, in: &g) + let value = reading(selectedMs) + if let watts = value.watts { + let y = plot.minY + YMap(domain: domain, height: plot.height).y(watts) + LockOn.dot(at: CGPoint(x: x, y: y), color: NodeChartLook.color(node), in: &g) + } + LockOn.tag(TimelineWords.reading(value, atMs: selectedMs), over: x, plot: plot, size: 10, in: &g, canvas: size) + } + .leavesTheEdge() + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .gesture(SpatialEventGesture() + .updating($touching) { _, state, _ in state = true } + .onChanged { events in + let positions = events.filter { $0.phase == .active }.map { $0.location.x }.sorted() + guard !positions.isEmpty, size.width > frame.trailing else { return } + if start == nil || start?.positions.count != positions.count { + scrub.lift() + start = Touch(window: window, positions: positions, began: Date(), lastX: positions[0]) + return + } + guard var saved = start else { return } + let width = size.width - frame.trailing + if positions.count >= 2 { + saved.mode = 3 + let oldSpan = max(1, saved.positions.last! - saved.positions[0]) + let nextSpan = max(1, positions.last! - positions[0]) + let anchor = Double((saved.positions[0] + saved.positions.last!) / 2 / width) + let destination = Double((positions[0] + positions.last!) / 2 / width) + window = saved.window.transformed(anchor: anchor, destination: destination, magnification: Double(nextSpan / oldSpan), nowMs: nowMs) + scrub.lift() + } else if saved.mode == 2 { + read(positions[0]) + } else if abs(positions[0] - saved.positions[0]) >= 6 { + saved.mode = 1 + window = saved.window.transformed(anchor: Double(saved.positions[0] / width), destination: Double(positions[0] / width), magnification: 1, nowMs: nowMs) + } + saved.lastX = positions[0] + start = saved + } + .onEnded { _ in + if start?.mode == 0 { onTap?() } + end() + }) + .simultaneousGesture(LongPressGesture(minimumDuration: 0.25, maximumDistance: 6).onEnded { _ in + guard var saved = start, saved.positions.count == 1, saved.mode == 0 else { return } + saved.mode = 2 + start = saved + read(saved.lastX) + }) + .onChange(of: touching) { _, down in + touchingChanged(down) + if !down { end() } + } + } + + private func read(_ x: CGFloat) { + let fraction = Double(min(max(x / max(1, size.width - frame.trailing), 0), 1)) + let ms = window.time(at: fraction) + selectedMs = ms + let value = reading(ms) + let words = TimelineWords.reading(value, atMs: ms) + let watts = value.watts + text.set([words], spans: { _ in words }, heroes: [.init(watts: watts.map(abs), signed: watts ?? 0, word: words)]) + scrub.move(to: 0, landmark: false) + } + + private func end() { + start = nil + selectedMs = nil + scrub.lift() + } +} + +struct TimelineControls: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + @Binding var window: TimelineWindow + let scrub: ScrubModel + let text: PeriodText + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + HStack { + Text(TimelineWords.window(window)).font(.caption).foregroundStyle(Theme.ink2).monospacedDigit() + Spacer(minLength: 8) + Button(L("Back to now")) { window.returnToNow(nowMs: site.nowMs) } + .font(.caption.weight(.semibold)) + .opacity(window.followsNow ? 0 : 1) + .disabled(window.followsNow) + .accessibilityHidden(window.followsNow) + .accessibilityIdentifier("timeline.now") + } + Group { + if typeSize.isAccessibilitySize { readout.fixedSize(horizontal: false, vertical: true) } + else { readout.lineLimit(2, reservesSpace: true) } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("chart.readout") + Text(TimelineWords.material(node, sampleMs: sampleMs) + "\n" + L("Drag through time · pinch to zoom · hold to read")) + .font(.caption2).foregroundStyle(Theme.ink3) + } + } + + private var readout: some View { + Text(text.text(scrub) ?? summary).font(.subheadline).foregroundStyle(Theme.ink2) + } + + private var sampleMs: Double { + node == .car ? 3_600_000 : (home.nodes.history.frame?.stepMs ?? 300_000) + } + + private var summary: String { + let problem = node == .car ? home.nodes.carEnergy.problem : home.nodes.history.problem + let loaded = node == .car ? home.nodes.carEnergy.loaded : home.nodes.history.loaded + if let problem { return problem } + guard window.fromMs < site.nowMs else { return L("Plan and forecast · not measured energy") } + let samples = NodeHistory.samples(node, home: home, fromMs: window.fromMs - 3_600_000) + let step = node == .car ? 3_600_000 : (home.nodes.history.frame?.stepMs ?? 300_000) + let energy = TimelineEnergy.recorded(samples, sampleMs: step, fromMs: window.fromMs, toMs: window.toMs, nowMs: site.nowMs) + guard energy.knownMs > 0 else { return loaded ? L("No recorded energy in this window.") : L("Fetching history…") } + let prefix = energy.prorated ? "≈ " : energy.missingMs > 0 || energy.fetchingMs > 0 ? "≥ " : "" + var words = L("Recorded in view") + ": " + prefix + NodeLive.between(node, (energy.inWh, energy.outWh)) + if energy.missingMs >= 60_000 { + let minutes = Int((energy.missingMs / 60_000).rounded()) + words += " · " + NodeWords.format("%@ not recorded", minutes < 120 ? "\(minutes) min" : "\(minutes / 60) h") + } + if energy.fetchingMs > 0 { words += " · " + L("last interval filling") } + return words + } +} + +struct TimelineFullScreen: View { + let site: SiteModel + let node: EnergyNode + let home: HomeModels + @Binding var window: TimelineWindow + @Binding var series: [EnergyNode] + @State private var scrub = ScrubModel() + @State private var text = PeriodText() + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + + var body: some View { + NavigationStack { + Group { + if typeSize.isAccessibilitySize { ScrollView { content(height: 360) } } + else { content(height: nil) } + } + .background(Theme.ground) + .navigationTitle(node.title).navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button(role: .close) { dismiss() }.accessibilityIdentifier("node.chart.close") } } + } + } + + private func content(height: CGFloat?) -> some View { + VStack(alignment: .leading, spacing: 12) { + NodeHero(site: site, node: node, home: home, scrub: scrub, text: text).padding(.horizontal, 20) + SeriesChips(own: node, available: NodeView.chartable(node), chosen: $series).padding(.horizontal, 14) + TimelineChartArea(site: site, node: node, home: home, scrub: scrub, window: $window, series: series, text: text) + .frame(height: height).frame(maxHeight: height == nil ? .infinity : nil).padding(.horizontal, 4) + TimelineControls(site: site, node: node, home: home, window: $window, scrub: scrub, text: text).padding(.horizontal, 20).padding(.bottom, 16) + NodeFooter(site: site, node: node, live: home.nodes.live, titleSaysAge: false).padding(.horizontal, 20).padding(.bottom, 8) + } + } +} + +private enum TimelineWords { + static func material(_ node: EnergyNode, sampleMs: Double) -> String { + switch node { + case .car, .battery, .grid: NodeWords.timelineMaterial(sampleMs: sampleMs, planned: true) + case .home, .solar: NodeWords.timelineMaterial(sampleMs: sampleMs, planned: false) + } + } + + static func window(_ window: TimelineWindow) -> String { + func label(_ ms: Double) -> String { + let date = Date(timeIntervalSince1970: ms / 1000) + return date.formatted(.dateTime.day().month(.abbreviated).hour().minute().locale(Locale(identifier: AppWords.language == .sv ? "sv_SE" : "en_US"))) + } + return label(window.fromMs) + " – " + label(window.toMs) + } + + static func reading(_ value: TimelineReading, atMs: Double) -> String { + switch value { + case let .observation(at, watts, seen): + return Readouts.seconds(at) + " · " + L(seen ? "source reading" : "saved reading") + " · " + AppWords.power(abs(watts)) + case let .mean(start, end, watts): + return Readouts.span(start, end) + " · " + L("interval mean") + " · " + AppWords.power(abs(watts)) + case let .forecast(start, end, watts): + return Readouts.span(start, end) + " · " + L("forecast") + " · " + AppWords.power(abs(watts)) + case let .plan(start, end, watts): + return Readouts.span(start, end) + " · " + L("planned") + " · " + AppWords.power(abs(watts)) + case .fetching: return Readouts.clock(atMs) + " · " + L("Waiting for history") + case .missing: return Readouts.clock(atMs) + " · " + L("no data") + } + } +} + +private struct TimelineDescriptor: AXChartDescriptorRepresentable { + let node: EnergyNode + let history: HistoryPicture + let observations: [LiveSample] + let sampleMs: Double + + func makeChartDescriptor() -> AXChartDescriptor { + let grid = history.grid + let x = AXNumericDataAxisDescriptor(title: L("Time"), range: grid.fromMs...max(grid.fromMs + 1, grid.toMs), gridlinePositions: []) { Readouts.clock($0) } + let y = AXNumericDataAxisDescriptor(title: L("Power"), range: history.domain.lower...max(history.domain.lower + 1, history.domain.upper), gridlinePositions: []) { AppWords.power(abs($0)) } + let means = history.layers.first?.series.buckets ?? [] + let stored = AXDataSeriesDescriptor(name: L("interval mean"), isContinuous: false, dataPoints: means.compactMap { b in b.mean.map { AXDataPoint(x: b.atMs, y: $0) } }) + let readings = AXDataSeriesDescriptor(name: L("source reading"), isContinuous: false, dataPoints: observations.map { AXDataPoint(x: $0.t, y: $0.v) }) + var series = [stored, readings] + if let ghost = history.layers.first?.ghost { + let kind = ghost.kind == .plan ? L("planned") : L("forecast") + let points = ghost.values.indices.compactMap { i -> AXDataPoint? in + let at = max(ghost.grid.startMs(i), history.nowMs ?? ghost.grid.fromMs) + guard at < ghost.grid.toMs, let watts = ghost.values[i] else { return nil } + return AXDataPoint(x: at, y: watts, additionalValues: [], label: kind) + } + series.append(AXDataSeriesDescriptor(name: kind, isContinuous: false, dataPoints: points)) + } + return AXChartDescriptor(title: node.title, summary: TimelineWords.material(node, sampleMs: sampleMs), xAxis: x, yAxis: y, additionalAxes: [], series: series) + } +} diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 4852377a..cacde8ef 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -1,34 +1,17 @@ -import Accessibility import FTWKit -import os import SwiftUI -#if DEBUG -enum TraceCost { - static let log = Logger(subsystem: "energy.ftw.app", category: "frames") -} -#endif - -/// One node of the flow, on one fixed screen: the reading now as the hero, -/// its last five minutes streaming as the chart, and the same chart for -/// today, the last two days and the hours ahead behind one picker. The chart -/// takes the room; the picker, the line that says what the period adds up -/// to and the node's extra row sit over the footer, in the thumb's reach. -/// The footer says how the reading reaches the phone, from which device and -/// how often it reads. The status line under the title is the only one. -/// -/// The tap sends no request: the trace has been recording since the home -/// opened, so it opens drawn. History and the plan are asked for when the -/// view opens and kept for the next visit. +/// One continuous timeline: the box's stored means, source observations, +/// and its plan or forecast. Drag time, pinch to zoom, hold to read. The +/// same window opens on the whole screen and only follows now at now. struct NodeView: View { let app: AppModel let home: HomeModels let node: EnergyNode let zoom: Namespace.ID @State private var scrub = ScrubModel() - @State private var trace = NodeTraceModel() @State private var choosingMode = false - @State private var timebase: Timebase + @State private var window = TimelineWindow(nowMs: Date().timeIntervalSince1970 * 1000) @State private var text = PeriodText() @State private var series: [EnergyNode] @State private var enlarged = false @@ -40,34 +23,17 @@ struct NodeView: View { self.home = home self.node = node self.zoom = zoom - _timebase = State(initialValue: Timebase.remembered(node)) _series = State(initialValue: SeriesChoice.load(node)) - } - - /// Live, today, the last two days and the hours ahead: one chart, one - /// place. - enum Timebase: String, CaseIterable, Hashable { - case live, today, twoDays, ahead - - var label: String { - switch self { - case .live: return L("Live") - case .today: return L("Today") - case .twoDays: return L("48 h") - case .ahead: return L("Ahead") - } - } - - /// The last choice for this node; debug builds take `-FTWPeriod - /// ahead` and the like, for screenshots. - static func remembered(_ node: EnergyNode) -> Timebase { - #if DEBUG - if let asked = UserDefaults.standard.string(forKey: "FTWPeriod").flatMap(Timebase.init(rawValue:)) { return asked } - #endif - return UserDefaults.standard.string(forKey: key(node)).flatMap(Timebase.init(rawValue:)) ?? .live + #if DEBUG + let hours = UserDefaults.standard.double(forKey: "FTWTimelineHours") + if hours > 0 { + let now = Date().timeIntervalSince1970 * 1000 + var initial = TimelineWindow(nowMs: now, spanMs: hours * 3_600_000) + let back = UserDefaults.standard.double(forKey: "FTWTimelineBackHours") * 3_600_000 + if back > 0 { initial = initial.transformed(anchor: 0, destination: back / initial.spanMs, magnification: 1, nowMs: now) } + _window = State(initialValue: initial) } - - static func key(_ node: EnergyNode) -> String { "ftw.node.timebase.\(node.rawValue)" } + #endif } private var models: NodeModels { home.nodes } @@ -91,8 +57,8 @@ struct NodeView: View { .navigationBarTitleDisplayMode(.inline) .onAppear { models.history.activate() - // Every node: Today draws the sun's and the home's forecast as - // the rest of the day, beside whatever series it shows. + // The same window can pan from stored history into the box's + // plan or forecast. models.plan.activate(forecast: true) models.mode.activate() models.sources.activate(node) @@ -109,20 +75,12 @@ struct NodeView: View { home.prices.deactivate() if node == .car { models.carEnergy.deactivate() } if node == .home { home.savings.deactivate() } - trace.stopSound() - } - .onChange(of: timebase) { _, now in - UserDefaults.standard.set(now.rawValue, forKey: Timebase.key(node)) - scrub.lift() - } - .onChange(of: scrub.isDown) { _, down in - if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } } .sheet(isPresented: $choosingMode) { ModeSheet(mode: models.mode, plan: models.planShare) } .fullScreenCover(isPresented: $enlarged) { - ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases) + TimelineFullScreen(site: site, node: node, home: home, window: $window, series: $series) .navigationTransition(.zoom(sourceID: "chart", in: chartZoom)) } #if DEBUG @@ -136,33 +94,26 @@ struct NodeView: View { #endif } - private var timebases: [Timebase] { Timebase.allCases.filter { $0 != .ahead || hasAhead } } - /// `chartMax` is half the screen's height on the fixed page, nil where /// the page scrolls. On the fixed page the chart takes the height the /// rest leaves, from 200 pt up to `chartMax`; what is left after that /// sits between the chart and the picker, never at the foot. private func page(chartMax: CGFloat?) -> some View { let fill = chartMax != nil - let chips = timebase == .today || timebase == .twoDays return VStack(alignment: .leading, spacing: 0) { - NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + NodeHero(site: site, node: node, home: home, scrub: scrub, text: text) .dynamicTypeSize(...DynamicTypeSize.accessibility1) .padding(.horizontal, 20) .fixedSize(horizontal: false, vertical: true) - // The series a history shows; the row keeps its room on every - // timebase, so nothing moves on a switch. + // Series choices keep their room while time moves. SeriesChips(own: node, available: Self.chartable(node), chosen: $series) .dynamicTypeSize(...DynamicTypeSize.xxLarge) .padding(.horizontal, 14) .padding(.top, 16) - .opacity(chips ? 1 : 0) - .allowsHitTesting(chips) - .accessibilityHidden(!chips) .fixedSize(horizontal: false, vertical: true) // A tap opens it on the whole screen, growing out of this one. // The chart's own overlay tells a tap from a scrub. - NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text, onTap: { enlarged = true }) + TimelineChartArea(site: site, node: node, home: home, scrub: scrub, window: $window, series: series, text: text, onTap: { enlarged = true }) .frame(minHeight: 200, idealHeight: 200, maxHeight: chartMax ?? 200) .padding(.horizontal, 4) .overlay(alignment: .topTrailing) { @@ -173,14 +124,7 @@ struct NodeView: View { .layoutPriority(1) if fill { Spacer(minLength: 0) } VStack(alignment: .leading, spacing: 0) { - Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { - ForEach(timebases, id: \.self) { Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - .accessibilityIdentifier("node.period") - .padding(.horizontal, 20) - .padding(.top, 12) - RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) + TimelineControls(site: site, node: node, home: home, window: $window, scrub: scrub, text: text) .padding(.horizontal, 20) .padding(.top, 12) if node == .battery { @@ -218,95 +162,6 @@ struct NodeView: View { EnergyNode.allCases.filter { $0 != .car || node == .car } } - /// Ahead shows only where the box plans or forecasts for this node. - private var hasAhead: Bool { - switch node { - case .battery, .grid: return models.plan.plan != nil - case .solar, .home: return models.plan.forecastStamp != nil - case .car: return !home.loadpoints.points.isEmpty - } - } -} - - -// MARK: - The trace - -/// The live trace's state for one node's view: the geometry for the newest -/// reading, the one held still under a finger, and how the window springs -/// back on lift. Built once per reading. -@Observable -@MainActor -final class NodeTraceModel { - private(set) var frame: TraceFrame? - private(set) var held: TraceFrame? - private(set) var frozenAtMs: Double? - private(set) var catchUp: (fromMs: Double, at: Date)? - @ObservationIgnored private var size: CGSize = .zero - @ObservationIgnored private var playing = false - - /// What the chart and the hero read: held still while a finger is down. - var shown: TraceFrame? { held ?? frame } - - /// `gapMs` is the silence the source in use allows before the line - /// breaks. `plot` gives the plot's size for a scale, since the right - /// gutter holds the scale's widest label: the trace is built for the - /// scale on screen, and again when the new scale needs another width. - /// The scale comes from the window's own readings; a node other than - /// the home rests below `Flow.idleW`. - func build(_ samples: [LiveSample], node: EnergyNode, gapMs: Double, plot: (YDomain?) -> CGSize) { - let previous = frame?.series.domain - var size = plot(previous) - guard size.width > 0, size.height > 0 else { return } - let now = Date() - func make(_ size: CGSize) -> LiveSeries { - LiveSeries.build( - samples, - sourceGapMs: gapMs, - windowMs: LiveTrace.windowMs, - nowMs: now.timeIntervalSince1970 * 1000, - size: size, - previous: previous, - restW: node == .home ? 0 : Flow.idleW - ) - } - var built = make(size) - if plot(built.domain) != size { - size = plot(built.domain) - built = make(size) - } - // A new height starts over; a new width keeps the slide, which - // works in time and watts. - let resized = size.height != self.size.height - self.size = size - let series = built - frame = resized ? TraceFrame(series: series, previous: nil, arrivedAt: now) : TraceFrame(series: series, after: frame, arrivedAt: now) - if playing, let last = series.last, series.domain.span > 0 { - AXLiveAudioGraph.updateValue((last.v - series.domain.lower) / series.domain.span) - } - } - - func hold(atMs: Double) { - held = frame - frozenAtMs = atMs - catchUp = nil - } - - func release() { - if let frozenAtMs { catchUp = (frozenAtMs, Date()) } - held = nil - frozenAtMs = nil - } - - /// The live value as a tone, for VoiceOver. - func toggleSound() { - playing.toggle() - if playing { AXLiveAudioGraph.start() } else { AXLiveAudioGraph.stop() } - } - - func stopSound() { - if playing { AXLiveAudioGraph.stop() } - playing = false - } } // MARK: - The hero @@ -322,10 +177,9 @@ struct NodeHero: View { let site: SiteModel let node: EnergyNode let home: HomeModels - let trace: NodeTraceModel let scrub: ScrubModel - let timebase: NodeView.Timebase let text: PeriodText + var followsChart = true @ScaledMetric(relativeTo: .largeTitle) private var figureSize: CGFloat = 60 @ScaledMetric(relativeTo: .title2) private var unitSize: CGFloat = 22 @@ -340,14 +194,9 @@ struct NodeHero: View { hasCar: !loadpoints.points.isEmpty ).first { $0.node == node } let live = reading?.freshness.live ?? false - // Under a finger the hero says what the finger is on, on Live and - // on the measured past alike. The hours ahead are a plan, not a - // reading, so there the hero stays on now. - let under: (watts: Double?, signed: Double, word: String)? = switch timebase { - case .live: fingered.map { ($0.watts, $0.signed, $0.word) } - case .today, .twoDays: text.hero(scrub).map { ($0.watts, $0.signed, $0.word) } - case .ahead: nil - } + // A held point names its evidence: interval mean, observation, + // plan, forecast or a gap. The separate car plan keeps now here. + let under = followsChart ? text.hero(scrub) : nil let watts = under.map(\.watts) ?? reading?.watts.map(Flow.quantised) let word = under?.word ?? reading?.word ?? L("Your box sends no reading for this.") let tone = under.map { Readouts.tone(node, $0.signed) } ?? reading?.tone ?? .muted @@ -387,22 +236,6 @@ struct NodeHero: View { .minimumScaleFactor(0.5) } - /// The reading under a finger on the trace, or the mean of two - /// fingers' span. - private var fingered: (watts: Double, signed: Double, word: String)? { - guard let frame = trace.shown else { return nil } - let readings = frame.readings - if let i = scrub.index, readings.indices.contains(i) { - let r = readings[i] - return (abs(r.v), r.v, NodeLive.when(r.t, Readouts.word(node, r.v))) - } - if let span = scrub.span, span.upperBound < readings.count { - let slice = readings[span] - let mean = slice.map(\.v).reduce(0, +) / Double(slice.count) - return (abs(mean), mean, NodeLive.spanWords(fromMs: slice.first!.t, toMs: slice.last!.t, meanW: mean, zone: .current)) - } - return nil - } } /// The fact beside the figure that only this node has: the battery's level @@ -556,296 +389,12 @@ private struct EnlargeButton: View { } } -/// One place for the node's chart. Live streams the trace; today and the -/// last two days draw the chosen series on the engine; the hours ahead -/// draw the plan or the forecast with the price. A switch cross-fades in -/// 0.2 s and the scale does not morph. -private struct NodeChart: View { - let site: SiteModel - let node: EnergyNode - let home: HomeModels - let trace: NodeTraceModel - let scrub: ScrubModel - let timebase: NodeView.Timebase - let series: [EnergyNode] - let text: PeriodText - /// A tap that never became a scrub; nil on the whole screen. - var onTap: (() -> Void)? - - var body: some View { - ZStack { - switch timebase { - case .live: - LiveChartArea(site: site, node: node, live: home.nodes.live, trace: trace, scrub: scrub, onTap: onTap) - .transition(.opacity) - case .today, .twoDays: - HistoryChartArea(site: site, node: node, home: home, scrub: scrub, period: timebase, series: series, text: text, onTap: onTap) - .transition(.opacity) - .accessibilityIdentifier("node.period.chart") - case .ahead: - AheadChartArea(site: site, node: node, home: home, scrub: scrub, text: text, onTap: onTap) - .transition(.opacity) - .accessibilityIdentifier("node.period.chart") - } - } - // A chart's words stop growing at xxLarge: they are drawn in the - // Canvas, and said again under the chart and to VoiceOver. Set here, - // above the charts, so their scaled sizes read it. - .dynamicTypeSize(...DynamicTypeSize.xxLarge) - } -} - -/// The chart on the whole screen, grown out of the small one: the same -/// timebases, series and finger, with room to read. It closes as every -/// sheet does, with the system's close button. -private struct ChartFullScreen: View { - let site: SiteModel - let node: EnergyNode - let home: HomeModels - @Binding var timebase: NodeView.Timebase - @Binding var series: [EnergyNode] - let timebases: [NodeView.Timebase] - @State private var scrub = ScrubModel() - @State private var trace = NodeTraceModel() - @State private var text = PeriodText() - @Environment(\.dismiss) private var dismiss - @Environment(\.dynamicTypeSize) private var typeSize - - var body: some View { - NavigationStack { - Group { - // The accessibility sizes scroll, with the chart at a fixed - // height; every other size is one page. - if typeSize.isAccessibilitySize { - ScrollView { content(chart: 360) } - } else { - content(chart: nil) - } - } - .background(Theme.ground) - .navigationTitle(node.title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button(role: .close) { dismiss() } - .accessibilityIdentifier("node.chart.close") - } - } - } - .onChange(of: timebase) { _, _ in scrub.lift() } - .onChange(of: scrub.isDown) { _, down in - if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } - } - } - - private func content(chart height: CGFloat?) -> some View { - VStack(alignment: .leading, spacing: 0) { - NodeHero(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) - .padding(.horizontal, 20) - SeriesChips(own: node, available: NodeView.chartable(node), chosen: $series) - .dynamicTypeSize(...DynamicTypeSize.xxLarge) - .padding(.horizontal, 14) - .padding(.top, 16) - .opacity(timebase == .today || timebase == .twoDays ? 1 : 0) - .allowsHitTesting(timebase == .today || timebase == .twoDays) - NodeChart(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, series: series, text: text) - .frame(height: height) - .frame(maxHeight: height == nil ? .infinity : nil) - .padding(.horizontal, 4) - Picker(L("Period"), selection: $timebase.animation(.easeInOut(duration: 0.2))) { - ForEach(timebases, id: \.self) { Text($0.label).tag($0) } - } - .pickerStyle(.segmented) - .accessibilityIdentifier("node.chart.period") - .padding(.horizontal, 20) - .padding(.top, 12) - RangeLine(site: site, node: node, home: home, trace: trace, scrub: scrub, timebase: timebase, text: text) - .padding(.horizontal, 20) - .padding(.top, 12) - // No status line over the whole screen: the footer gives this - // node's age whenever it is not live. - NodeFooter(site: site, node: node, live: home.nodes.live, titleSaysAge: false) - .padding(.horizontal, 20) - .padding(.top, 16) - .padding(.bottom, 8) - } - } -} - -/// The streaming trace and the finger on it. It reads the ring of live -/// readings once per reading and builds the geometry then; between -/// readings the surface only scrolls. -private struct LiveChartArea: View { - let site: SiteModel - let node: EnergyNode - let live: LiveTrace - let trace: NodeTraceModel - let scrub: ScrubModel - var onTap: (() -> Void)? - @State private var size: CGSize = .zero - @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 - @ScaledMetric(relativeTo: .caption) private var tag: CGFloat = 11 - - /// The frame for a scale: the right gutter holds its widest label. - private func chartFrame(_ domain: YDomain?) -> ChartFrame { - ChartFrame(top: tag * 1.35 + 8, bottom: 6, trailing: ChartAxes.gutter(domain, axis: axis)) - } - - var body: some View { - let revision = live.revision - // The frame the trace on screen was built for. - let drawn = chartFrame(trace.shown?.series.domain) - // The same colours as every other chart of this node. - let look = NodeChartLook.look(node) - let isLive = site.freshness(node).live - ZStack { - // Takes the whole frame before the first reading, so the - // geometry knows its size. - Color.clear - if let frame = trace.shown { - LiveTraceSurface(frame: frame, look: look, live: isLive, chartFrame: drawn, frozenAtMs: trace.frozenAtMs, catchUp: trace.catchUp, axisSize: axis) - LiveScrubOverlay( - frame: frame, - chartFrame: drawn, - model: scrub, - viewMs: trace.frozenAtMs ?? Date().timeIntervalSince1970 * 1000, - look: look, - tagSize: tag, - tag: { NodeLive.tag($0.t, watts: $0.v) }, - spanTag: { r in - let slice = frame.readings[r] - let mean = slice.map(\.v).reduce(0, +) / Double(max(1, slice.count)) - return Readouts.seconds(slice.first!.t) + "–" + Readouts.seconds(slice.last!.t) + " " + NodeWords.format("avg %@", AppWords.power(abs(mean))) - }, - onTap: onTap - ) - } - } - .dynamicTypeSize(...DynamicTypeSize.xxLarge) - .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } - .onChange(of: revision, initial: true) { rebuild() } - .onChange(of: size) { rebuild() } - .accessibilityElement() - .accessibilityLabel(L("Live, the last five minutes")) - .accessibilityChartDescriptor(LiveDescriptor(node: node, readings: trace.frame?.readings ?? [])) - .accessibilityAction(named: L("Play as sound")) { trace.toggleSound() } - .accessibilityIdentifier("node.live.chart") - #if DEBUG - .task { await autoScrub() } - #endif - } - - #if DEBUG - /// With `-FTWAutoScrub YES`, as on the price chart: a thumb back and - /// forth over the trace for ten seconds, then two fingers for three, - /// through the scrub model the finger moves. `-FTWAutoScrubAfter 300` - /// waits for a full window first. - private func autoScrub() async { - let defaults = UserDefaults.standard - guard defaults.bool(forKey: "FTWAutoScrub") else { return } - let wait = defaults.double(forKey: "FTWAutoScrubAfter") - try? await Task.sleep(for: .seconds(wait > 0 ? wait : 2)) - func reading(_ along: Double) -> Int? { - guard let frame = trace.shown else { return nil } - let now = trace.frozenAtMs ?? Date().timeIntervalSince1970 * 1000 - return SlotGrid.nearest(frame.times, to: now - (1 - along) * LiveTrace.windowMs) - } - let began = ContinuousClock.now - while ContinuousClock.now - began < .seconds(10) { - let t = (ContinuousClock.now - began) / .milliseconds(1_600) - let phase = t.truncatingRemainder(dividingBy: 2) - if let i = reading(0.05 + 0.95 * (phase < 1 ? phase : 2 - phase)), let frame = trace.shown { - scrub.move(to: i, landmark: frame.landmarks.contains(i)) - } - try? await Task.sleep(for: .milliseconds(8)) - } - if let a = reading(0.45), let b = reading(0.8) { scrub.spread(a, b) } - try? await Task.sleep(for: .seconds(3)) - scrub.lift() - } - #endif - - private func rebuild() { - let size = size - #if DEBUG - let began = ContinuousClock.now - defer { TraceCost.log.debug("trace built in \((ContinuousClock.now - began) / .microseconds(1), format: .fixed(precision: 0)) µs") } - #endif - trace.build(live.samples(node, sinceMs: Date().timeIntervalSince1970 * 1000 - LiveTrace.windowMs - 10_000), node: node, gapMs: site.readingGapMs) { chartFrame($0).plot(in: size).size } - } - -} - -/// The trace as VoiceOver reads it: every reading, in watts. -private struct LiveDescriptor: AXChartDescriptorRepresentable { - let node: EnergyNode - let readings: [LiveSample] - - func makeChartDescriptor() -> AXChartDescriptor { - let start = readings.first?.t ?? 0, end = max(start + 1, readings.last?.t ?? 1) - let lo = min(0, readings.map(\.v).min() ?? 0), hi = max(lo + 1, readings.map(\.v).max() ?? 1) - let x = AXNumericDataAxisDescriptor(title: L("Time"), range: start...end, gridlinePositions: []) { Readouts.seconds($0) } - let y = AXNumericDataAxisDescriptor(title: L("Power"), range: lo...hi, gridlinePositions: []) { AppWords.power(abs($0)) } - let series = AXDataSeriesDescriptor(name: node.title, isContinuous: true, dataPoints: readings.map { - AXDataPoint(x: $0.t, y: $0.v, additionalValues: [], label: Readouts.word(node, $0.v)) - }) - return AXChartDescriptor(title: node.title, summary: L("Live, the last five minutes"), xAxis: x, yAxis: y, additionalAxes: [], series: [series]) - } -} - -// MARK: - Under the chart - -/// Two reserved lines: what the period adds up to, or under a finger what -/// it adds up to so far. The page never moves. -private struct RangeLine: View { - @Environment(\.dynamicTypeSize) private var typeSize - let site: SiteModel - let node: EnergyNode - let home: HomeModels - let trace: NodeTraceModel - let scrub: ScrubModel - let timebase: NodeView.Timebase - let text: PeriodText - - var body: some View { - ZStack(alignment: .topLeading) { - Text(verbatim: "\n") - Text(line) - .accessibilityIdentifier("chart.readout") - } - .font(.subheadline) - .foregroundStyle(Theme.ink2) - .lineLimit(typeSize.isAccessibilitySize ? 5 : 2, reservesSpace: true) - .frame(maxWidth: .infinity, alignment: .leading) - .transaction { $0.animation = nil } - } - - private var line: String { - guard timebase == .live else { - if let under = text.text(scrub) { return under } - return PeriodWords.summary(site: site, node: node, period: timebase, models: home.nodes, loadpoints: home.loadpoints) - } - guard let frame = trace.shown else { return " " } - let runs = frame.series.runs - if let i = scrub.index, frame.readings.indices.contains(i) { - return NodeLive.soFar(node, NodeLive.energy(runs, untilMs: frame.readings[i].t)) - } - if let span = scrub.span, span.upperBound < frame.readings.count { - let a = NodeLive.energy(runs, untilMs: frame.readings[span.lowerBound].t) - let b = NodeLive.energy(runs, untilMs: frame.readings[span.upperBound].t) - return NodeLive.between(node, (b.inWh - a.inWh, b.outWh - a.outWh)) - } - return NodeLive.range(node, NodeLive.energy(runs)) - } -} - /// `VIA RELAY · SUNGROW · READS EVERY 1 S`: how the reading reaches the /// phone, the device behind it, and how often the device itself reads, /// from its readings. When this node is not live and the title does not /// already give an age, it starts `○ AS OF 14:05`. One line, so the /// controls over it never move. -private struct NodeFooter: View { +struct NodeFooter: View { let site: SiteModel let node: EnergyNode let live: LiveTrace @@ -1028,46 +577,6 @@ private struct SavingsRow: View { } } -/// The line under today, the last two days and the hours ahead. -private enum PeriodWords { - @MainActor - static func summary(site: SiteModel, node: EnergyNode, period: NodeView.Timebase, models: NodeModels, loadpoints: LoadpointsModel) -> String { - let fromMs = period == .today ? models.history.midnightMs : site.nowMs - HistoryModel.spanMs - let zone = TimeZone.current - switch period { - case .live, .today, .twoDays: - if node == .car { - guard models.carEnergy.loaded else { return " " } - let wh = models.carEnergy.wh(sinceMs: fromMs) - return NodeWords.format(period == .today ? "Today: charged %@" : "48 h: charged %@", AppWords.energy(wh)) - } - guard let frame = models.history.frame, frame.points > 0 else { return models.history.problem ?? " " } - let e = EnergyToday.sum(models.history.samples(node, fromMs: fromMs), fromMs: fromMs) - return NodeSummary.energy(node, e, today: period == .today) - case .ahead: - let now = site.nowMs - // "No plan" only once the box has answered without one; until - // then the line keeps its place. - let noPlan = models.plan.plan == nil && !models.plan.loaded - let noForecast = models.plan.forecastStamp == nil && !models.plan.forecastLoaded - switch node { - case .battery where noPlan, .grid where noPlan: - return models.plan.problem ?? " " - case .solar where noForecast, .home where noForecast: - return " " - case .battery: return PlanText.battery(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false, nowMs: now, zone: zone) - case .grid: return PlanText.grid(models.plan.ahead(nowMs: now), stale: models.plan.plan?.stale ?? false) - case .solar: return PlanText.solar(models.plan.forecastAhead(nowMs: now)) - case .home: return PlanText.home(models.plan.forecastAhead(nowMs: now)) - case .car: - guard !loadpoints.points.isEmpty else { return L("This box has no charger.") } - let windows = loadpoints.points.flatMap { loadpoints.windows[$0.id] ?? $0.planWindows } - return PlanText.car(windows, nowMs: now, zone: loadpoints.zone) - } - } - } -} - /// The energy line under a node's history, in the node's own words. enum NodeSummary { static func energy(_ node: EnergyNode, _ e: EnergyToday, today: Bool) -> String { diff --git a/appleApp/FTWCharts/HistoryCharts.swift b/appleApp/FTWCharts/HistoryCharts.swift index e31a7889..a8c67c36 100644 --- a/appleApp/FTWCharts/HistoryCharts.swift +++ b/appleApp/FTWCharts/HistoryCharts.swift @@ -37,6 +37,10 @@ struct HistoryDrawing: ChartDrawing { var from: HistoryPicture? var slide: Double = 1 var spring: Double = 1 + /// Source runs can cover the stored means without changing them. + /// A lone point hides no history, and a silence hides no later mean. + var observationRuns: [String: [ClosedRange]] = [:] + var showsFetchingEstimate = true func draw(in g: inout GraphicsContext, size: CGSize) { let plot = frame.plot(in: size) @@ -111,6 +115,18 @@ struct HistoryDrawing: ChartDrawing { band = band.applying(toShown) } var p = p + if let runs = observationRuns[layer.key], !runs.isEmpty { + var visible = Path() + var cursor: CGFloat = 0 + for run in runs { + let a = min(max(series.grid.x(atMs: run.lowerBound, width: plot.width), 0), plot.width) + let b = min(max(series.grid.x(atMs: run.upperBound, width: plot.width), 0), plot.width) + if a > cursor { visible.addRect(CGRect(x: cursor, y: -40, width: a - cursor, height: plot.height + 80)) } + cursor = max(cursor, b) + } + if cursor < plot.width { visible.addRect(CGRect(x: cursor, y: -40, width: plot.width - cursor, height: plot.height + 80)) } + p.clip(to: visible) + } // A series just added fades in. if from != nil, before == nil, k < 1 { p.opacity = k } TraceInk.band(band, look: look, above: above, below: below, in: p) @@ -122,14 +138,18 @@ struct HistoryDrawing: ChartDrawing { if own { for r in series.fetching { let x0 = series.grid.x(atMs: series.grid.startMs(r.lowerBound), width: plot.width) - let x1 = series.grid.x(atMs: series.grid.endMs(r.upperBound), width: plot.width) + let end = min(series.grid.endMs(r.upperBound), picture.nowMs ?? series.grid.toMs) + let x1 = series.grid.x(atMs: end, width: plot.width) + guard x1 > x0 else { continue } let rect = CGRect(x: x0, y: 0, width: max(px, x1 - x0), height: plot.height) var hatch = p hatch.clip(to: Path(rect)) hatch.fill(Path(rect), with: .color(Theme.ink.opacity(0.04))) hatch.stroke(Self.hatch(rect), with: .color(Theme.ink.opacity(0.10)), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) - let text = p.resolve(ChartText.label(words.fetching, size: axis)) - p.draw(text, at: CGPoint(x: rect.midX, y: 4), anchor: .top) + let text = g.resolve(ChartText.label(words.fetching, size: axis)) + let width = text.measure(in: size).width + let x = min(max(0, rect.midX - width / 2), max(0, plot.width - width)) + g.draw(text, at: CGPoint(x: plot.minX + x, y: plot.minY + 4), anchor: .topLeading) } var dotted = Path() for r in series.missing { @@ -138,7 +158,9 @@ struct HistoryDrawing: ChartDrawing { } TraceInk.dotted(dotted, px: px, in: p) } - p.stroke(Path(series.estimate.cgPath).applying(toShown), with: .color(look.up.opacity(0.6)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + if showsFetchingEstimate { + p.stroke(Path(series.estimate.cgPath).applying(toShown), with: .color(look.up.opacity(0.6)), style: StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } TraceInk.line(line, look: look, width: own ? TraceInk.line : TraceInk.otherLine, opacity: own ? 1 : 0.85, glows: own && filling != nil, glowClip: filling, above: above, below: below, in: p) } diff --git a/appleApp/FTWCharts/TimelineDrawing.swift b/appleApp/FTWCharts/TimelineDrawing.swift new file mode 100644 index 00000000..0d65ca02 --- /dev/null +++ b/appleApp/FTWCharts/TimelineDrawing.swift @@ -0,0 +1,49 @@ +import FTWShared +import SwiftUI + +/// The same history material and scale, with source observations at the +/// right edge. Stored means never glow. Only a fresh observed head can. +struct TimelineDrawing: ChartDrawing { + let history: HistoryPicture + let traces: [String: LiveSeries] + let looks: [String: TraceLook] + let frame: ChartFrame + let liveKeys: Set + var axis: CGFloat = 10 + var words = ChartLabels() + + func draw(in g: inout GraphicsContext, size: CGSize) { + let runs = traces.mapValues(\.observedRanges) + HistoryDrawing(picture: history, looks: looks, frame: frame, words: words, axis: axis, live: false, observationRuns: runs, showsFetchingEstimate: false).draw(in: &g, size: size) + let plot = frame.plot(in: size) + let base = YMap(domain: history.domain, height: plot.height).baseline + var p = g + p.clip(to: Path(plot.insetBy(dx: 0, dy: -12))) + p.translateBy(x: plot.minX, y: plot.minY) + let above = Path(CGRect(x: -10, y: -40, width: plot.width + 20, height: base + 40)) + let below = Path(CGRect(x: -10, y: base, width: plot.width + 20, height: plot.height - base + 40)) + for layer in history.layers.reversed() { + guard let trace = traces[layer.key] else { continue } + let look = looks[layer.key] ?? TraceLook(up: Theme.ink, down: Theme.ink) + let transform = TraceInk.rescale(trace.domain, history.domain, height: plot.height) + let own = layer.key == history.layers.first?.key + if own { + var body = Path(trace.body.cgPath) + body.addPath(Path(trace.bodyUnseen.cgPath)) + body = body.applying(transform) + TraceInk.body(body, look: look, baseline: base, height: plot.height, above: above, below: below, in: p) + } + TraceInk.band(Path(trace.band.cgPath).applying(transform), look: look, above: above, below: below, in: p) + TraceInk.line(Path(trace.wholeLine.cgPath).applying(transform), look: look, width: own ? TraceInk.line : TraceInk.otherLine, glows: false, above: above, below: below, in: p) + if own, liveKeys.contains(layer.key), let last = trace.last, last.seen, + let now = history.nowMs, now - last.t <= trace.gapMs { + let point = CGPoint(x: trace.x(last.t), y: YMap(domain: history.domain, height: plot.height).y(last.v)) + let color = last.v >= 0 ? look.up : look.down + if TraceInk.glows(live: true, in: g) { + p.fill(Path(ellipseIn: CGRect(x: point.x - 5, y: point.y - 5, width: 10, height: 10)), with: .color(color.opacity(0.25))) + } + p.fill(Path(ellipseIn: CGRect(x: point.x - 2.5, y: point.y - 2.5, width: 5, height: 5)), with: .color(color)) + } + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index bac2f3f9..fc47b72e 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -34,6 +34,9 @@ public final class SimulatedBox { /// then sends it, as a slow box would: the app bridges the gap with an /// estimate meanwhile and the data grows into it. 0 sends all at once. public var holdTileMs: Double = 0 + /// History-only failures for native UI checks. Live keeps running. + public var historyOnlyMissing = false + public var historyUnavailable = false private var heldTile = false /// The plan as last made, with the quarter and revision it was made /// for. Core plans each quarter and sends the same plan until the next. @@ -168,6 +171,7 @@ public final class SimulatedBox { guard let from = Self.wholeMs(body["fromMs"]), let to = Self.wholeMs(body["toMs"]) else { return } sendBulk(Envelope(t: "price", id: env.id, b: pricesCBOR(from: from, to: to))) case "hist.query": + if historyUnavailable { refuse(env.id, "E_UNAVAILABLE", []); return } // The box refuses a malformed query by name and answers the rest // tile by tile, then once more to say it is done. guard let q = HistQuery(cbor: body), !q.series.isEmpty, q.toMs > q.fromMs else { @@ -947,6 +951,11 @@ extension SimulatedBox { #if DEBUG // For recordings: `-FTWDemoGap YES` holds a tile back for 2 s. if UserDefaults.standard.bool(forKey: "FTWDemoGap") { box.holdTileMs = 2_000 } + if let state = UserDefaults.standard.string(forKey: "FTWDemoHistory") { + box.historyOnlyMissing = state == "empty" + box.historyUnavailable = state == "unavailable" + if state == "waiting" { box.holdTileMs = 15_000 } + } // For the grid widget: `-FTWDemoSunny YES` makes today a long // summer's day that sells more than it buys, and // `-FTWDemoPartial YES` leaves a fifth of it unpriced. diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index cd477891..e41d3980 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -198,7 +198,7 @@ extension SimulatedBox { } if let v = house.bucket(at, nowMs: now)?.value(series) { sum += v; n += 1 } } - column.append(n > 0 ? Int32(max(Double(Int32.min) + 1, min(Double(Int32.max), (sum / n).rounded()))) : missingSample) + column.append(!historyOnlyMissing && n > 0 ? Int32(max(Double(Int32.min) + 1, min(Double(Int32.max), (sum / n).rounded()))) : missingSample) } columns.append(column) } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 1c2a0394..8a0e5cab 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -23,7 +23,40 @@ public enum NodeWords { return out } + public static func timelineMaterial(sampleMs: Double, planned: Bool) -> String { + let minutes = max(1, Int((sampleMs / 60_000).rounded())) + let history = minutes.isMultiple(of: 60) + ? format("History: closed %@-hour means", "\(minutes / 60)") + : format("History: closed %@-minute means", "\(minutes)") + return history + " · " + text("live: source readings") + " · " + + text(planned ? "future bars: plan" : "dashed: forecast") + } + static let sv: [String: String] = [ + "History and live readings": "Historik och livevärden", + "Swipe up or down to move through time. Actions change the zoom.": "Svep uppåt eller nedåt för att flytta tiden. Åtgärder ändrar zoom.", + "Zoom in": "Zooma in", + "Zoom out": "Zooma ut", + "Back to now": "Tillbaka till nu", + "Drag through time · pinch to zoom · hold to read": "Dra genom tiden · nyp för att zooma · håll för att läsa", + "History: closed %@-minute means": "Historik: avslutade %@-minutersmedel", + "History: closed %@-hour means": "Historik: avslutade %@-timmarsmedel", + "live: source readings": "live: källvärden", + "future bars: plan": "framtida staplar: plan", + "dashed: forecast": "streckat: prognos", + "source reading": "källvärde", + "saved reading": "sparat värde", + "interval mean": "intervallmedel", + "forecast": "prognos", + "planned": "planerat", + "No plan or forecast for this time.": "Ingen plan eller prognos för den här tiden.", + "Fetching history…": "Hämtar historik…", + "No recorded history in this window.": "Ingen sparad historik i det här tidsfönstret.", + "No recorded energy in this window.": "Ingen sparad energi i det här tidsfönstret.", + "Plan and forecast · not measured energy": "Plan och prognos · inte uppmätt energi", + "Recorded in view": "Sparat i perioden", + "Waiting for history": "Väntar på historik", + "last interval filling": "senaste intervallet pågår", // Modes, by the registry's keys "Plan": "Plan", "Plan, selling too": "Plan, säljer också", diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift index ec413be5..ccce6818 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/AheadPicture.swift @@ -129,11 +129,23 @@ public struct AheadPicture: Equatable, Sendable { /// the window's start also gives it a point there, so the line starts /// at the left edge. One subpath per run: where the slots stop touching, /// the line breaks. - public static func forecastLine(_ marks: [Mark], fromMs: Double, toMs: Double, domain: YDomain, size: CGSize) -> ChartPath { + public static func forecastLine(_ marks: [Mark], fromMs: Double, toMs: Double, domain: YDomain, size: CGSize, intervalMeans: Bool = false, notBeforeMs: Double? = nil) -> ChartPath { guard toMs > fromMs else { return ChartPath() } let map = YMap(domain: domain, height: size.height) func x(_ ms: Double) -> CGFloat { CGFloat((ms - fromMs) / (toMs - fromMs)) * size.width } var line = ChartPath() + if intervalMeans { + // A zoomed window can fit inside a single forecast interval. + // Draw that interval's own value, clipped at now, rather than + // losing it because its midpoint lies outside the viewport. + for m in marks.sorted(by: { $0.startMs < $1.startMs }) { + let a = max(m.startMs, fromMs, notBeforeMs ?? fromMs) + let b = min(m.endMs, toMs) + guard b > a, m.watts.isFinite else { continue } + line.elements += [.move(CGPoint(x: x(a), y: map.y(m.watts))), .line(CGPoint(x: x(b), y: map.y(m.watts)))] + } + return line + } var run: [CGPoint] = [] var end = -Double.infinity for m in marks.sorted(by: { $0.startMs < $1.startMs }) where m.endMs > fromMs && m.startMs < toMs { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift index ce4a1b4c..8182b20b 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/ChartSeries.swift @@ -160,9 +160,10 @@ public struct ChartSeries: Equatable, Sendable { clock: ZoneClock, landmarksMs: [Double] = [], domain forced: YDomain? = nil, + displayStepMs: Double? = nil, words: ReadoutWords ) -> ChartSeries { - let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) + let stepMs = displayStepMs ?? max(sampleMs, DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width)) let buckets = DisplayBucket.buckets(samples, sampleMs: sampleMs, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock) let grid = SlotGrid(firstMs: buckets.first?.startMs ?? fromMs, stepMs: stepMs, count: buckets.count, fromMs: fromMs, toMs: toMs) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift index 05c4e871..78b46f4f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/HistoryPicture.swift @@ -55,6 +55,11 @@ public struct HistoryPicture: Equatable, Sendable { public struct Tick: Equatable, Sendable { public var ms: Double public var label: String + + public init(ms: Double, label: String) { + self.ms = ms + self.label = label + } } /// In the order given; the first is the chart's own series. @@ -89,27 +94,33 @@ public struct HistoryPicture: Equatable, Sendable { clock: ZoneClock, everyHours: Int = 6, weekday: (@Sendable (Double) -> String)? = nil, + observationExtent: ClosedRange? = nil, + sampleSteps: [String: Double] = [:], words: ReadoutWords ) -> HistoryPicture { // One scale for every series: the union of their bands. - let stepMs = DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width) + let stepMs = max(sampleSteps.values.max() ?? 300_000, DisplayBucket.stepMs(spanMs: toMs - fromMs, width: size.width)) var low = 0.0, high = 0.0 for input in inputs { - let e = DisplayBucket.extent(DisplayBucket.buckets(input.samples, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock)) + let e = DisplayBucket.extent(DisplayBucket.buckets(input.samples, sampleMs: sampleSteps[input.key] ?? 300_000, stepMs: stepMs, fromMs: fromMs, toMs: toMs, clock: clock)) low = min(low, e.low) high = max(high, e.high) if let nowMs, let plan = ahead[input.key] { - for m in plan.slots where m.endMs > nowMs && m.startMs < toMs { + for m in plan.slots where m.endMs > max(nowMs, fromMs) && m.startMs < toMs { low = min(low, m.watts) high = max(high, m.watts) } } } + if let observationExtent { + low = min(low, observationExtent.lowerBound) + high = max(high, observationExtent.upperBound) + } let domain = YDomain.stable(low: low, high: high, previous: previous, floor: floor) let layers = inputs.map { input in Layer( key: input.key, - series: ChartSeries.history(input.samples, fromMs: fromMs, toMs: toMs, nowMs: nowMs, size: size, clock: clock, domain: domain, words: words), + series: ChartSeries.history(input.samples, sampleMs: sampleSteps[input.key] ?? 300_000, fromMs: fromMs, toMs: toMs, nowMs: nowMs, size: size, clock: clock, domain: domain, displayStepMs: stepMs, words: words), ghost: nowMs.flatMap { now in ahead[input.key].flatMap { Ghost.build($0.kind, slots: $0.slots, fromMs: fromMs, toMs: toMs, nowMs: now, domain: domain, size: size) } } ) } @@ -123,7 +134,7 @@ public struct HistoryPicture: Equatable, Sendable { let newest = layers.compactMap { layer -> Double? in guard let i = layer.series.lastData else { return nil } let b = layer.series.buckets[i] - return min(b.endMs, b.startMs + Double(b.count) * 300_000) + return min(b.endMs, b.startMs + Double(b.count) * (sampleSteps[layer.key] ?? 300_000)) }.max() ?? grid.endMs(limit) if nowMs - newest > stepMs { asOf = newest } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift index 1146caca..5a890d13 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/LiveSeries.swift @@ -113,6 +113,15 @@ public struct LiveSeries: Equatable, Sendable { /// Every reading, oldest first, for one binary search under a finger. public var readings: [LiveSample] { runs.flatMap { $0 } } + /// Where an observed curve can cover a stored mean. A lone reading + /// is a point, not a stretch, and a source silence covers nothing. + public var observedRanges: [ClosedRange] { + runs.compactMap { run in + guard run.count > 1, let first = run.first, let last = run.last, last.t > first.t else { return nil } + return first.t...last.t + } + } + /// The whole line, seen or not, for tests and the calm metrics. public var wholeLine: ChartPath { var p = ChartPath() diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/TimelineWindow.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/TimelineWindow.swift new file mode 100644 index 00000000..99208c9b --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/TimelineWindow.swift @@ -0,0 +1,139 @@ +import Foundation + +/// An elapsed-time window, independent of the data that arrives in it. +/// Only a window following now moves on a reading. A finger keeps its +/// time through a pan or a zoom, including across a daylight-saving turn. +public struct TimelineWindow: Equatable, Sendable { + public static let historyMs: Double = 48 * 3_600_000 + public static let aheadMs: Double = 24 * 3_600_000 + public static let minimumSpanMs: Double = 5 * 60_000 + public static let maximumSpanMs = historyMs + public static let nowPosition: Double = 0.85 + + public private(set) var fromMs: Double + public private(set) var toMs: Double + public private(set) var followsNow: Bool + public var spanMs: Double { toMs - fromMs } + + public init(nowMs: Double, spanMs: Double = 3_600_000) { + let span = min(max(spanMs, Self.minimumSpanMs), Self.maximumSpanMs) + fromMs = nowMs - span * Self.nowPosition + toMs = fromMs + span + followsNow = true + } + + public mutating func follow(nowMs: Double) { + guard followsNow else { return } + self = Self(nowMs: nowMs, spanMs: spanMs) + } + + public mutating func returnToNow(nowMs: Double) { + self = Self(nowMs: nowMs, spanMs: spanMs) + } + + public func time(at fraction: Double) -> Double { + fromMs + min(max(fraction, 0), 1) * spanMs + } + + /// Transform the window saved when the gesture started. `anchor` is + /// the first finger/centroid's fraction, `destination` its new one. + /// This is absolute from that saved window, never an accumulated delta. + public func transformed(anchor: Double, destination: Double, magnification: Double, nowMs: Double) -> Self { + guard anchor.isFinite, destination.isFinite, magnification.isFinite, magnification > 0 else { return self } + let span = min(max(spanMs / magnification, Self.minimumSpanMs), Self.maximumSpanMs) + let wanted = fromMs + anchor * spanMs - destination * span + let lower = nowMs - Self.historyMs + let upper = nowMs + Self.aheadMs - span + let start = min(max(wanted, lower), upper) + var next = Self(nowMs: nowMs, spanMs: span) + next.fromMs = start + next.toMs = start + span + next.followsNow = abs(start - (nowMs - span * Self.nowPosition)) <= span * 0.01 + return next + } +} + +/// A stored interval mean and a source observation remain different kinds +/// of evidence even when they share one chart. A history mean is never +/// resampled into seconds or added to live energy a second time. +public enum TimelineReading: Equatable, Sendable { + case mean(startMs: Double, endMs: Double, watts: Double) + case observation(atMs: Double, watts: Double, seen: Bool) + case fetching + case missing + case forecast(startMs: Double, endMs: Double, watts: Double) + case plan(startMs: Double, endMs: Double, watts: Double) + + public var watts: Double? { + switch self { + case let .mean(_, _, watts), let .observation(_, watts, _), let .forecast(_, _, watts), let .plan(_, _, watts): watts + case .fetching, .missing: nil + } + } + + /// A nearby observation wins over an overlapping interval mean. Across + /// a source silence the stored mean can still be read as a mean, but + /// never as a fresh observation. A confirmed history hole stays empty. + public static func at( + _ ms: Double, + history: [ChartSample], + sampleMs: Double = 300_000, + observations: [LiveSample], + gapMs: Double, + nowMs: Double, + ahead: (kind: AheadPicture.Kind, slots: [AheadPicture.Mark])? = nil + ) -> Self { + if ms > nowMs { + guard let ahead, let mark = ahead.slots.first(where: { $0.startMs <= ms && ms < $0.endMs }) else { return .missing } + return ahead.kind == .plan + ? .plan(startMs: max(nowMs, mark.startMs), endMs: mark.endMs, watts: mark.watts) + : .forecast(startMs: max(nowMs, mark.startMs), endMs: mark.endMs, watts: mark.watts) + } + let seen = observations.filter { $0.t <= nowMs && $0.v.isFinite } + if let i = SlotGrid.nearest(seen.map(\.t), to: ms), abs(seen[i].t - ms) <= gapMs / 2 { + let point = seen[i] + return .observation(atMs: point.t, watts: point.v, seen: point.seen) + } + guard let sample = history.first(where: { $0.startMs <= ms && ms < $0.startMs + sampleMs }) else { return .missing } + // An open bucket is energy so far divided by its whole length, + // not the mean power over the seconds that have elapsed. + guard sample.startMs + sampleMs <= nowMs else { return .fetching } + guard let watts = sample.value, watts.isFinite else { return sample.fetching ? .fetching : .missing } + return .mean(startMs: sample.startMs, endMs: sample.startMs + sampleMs, watts: watts) + } +} + +/// Recorded energy inside the visible past only. Source observations and +/// projections are deliberately not inputs, so neither can count twice. +public struct TimelineEnergy: Equatable, Sendable { + public var inWh: Double = 0 + public var outWh: Double = 0 + public var knownMs: Double = 0 + public var fetchingMs: Double = 0 + public var missingMs: Double = 0 + /// A boundary cuts a stored interval: its share uses that interval's + /// mean, so the UI marks the energy as an estimate. + public var prorated = false + + public static func recorded(_ samples: [ChartSample], sampleMs: Double = 300_000, fromMs: Double, toMs: Double, nowMs: Double) -> Self { + var result = Self() + let pastEnd = min(toMs, nowMs) + guard pastEnd > fromMs else { return result } + let closedUntil = (nowMs / sampleMs).rounded(.down) * sampleMs + let until = min(pastEnd, closedUntil) + result.fetchingMs = max(0, pastEnd - max(fromMs, closedUntil)) + for sample in samples { + guard sample.startMs + sampleMs <= nowMs else { continue } + let duration = max(0, min(sample.startMs + sampleMs, until) - max(sample.startMs, fromMs)) + guard duration > 0 else { continue } + if let watts = sample.value, watts.isFinite { + if duration < sampleMs { result.prorated = true } + result.knownMs += duration + if watts >= 0 { result.inWh += watts * duration / 3_600_000 } + else { result.outWh -= watts * duration / 3_600_000 } + } else if sample.fetching { result.fetchingMs += duration } + } + result.missingMs = max(0, pastEnd - fromMs - result.knownMs - result.fetchingMs) + return result + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index d4806849..bb212031 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -835,6 +835,17 @@ import FTWShared // MARK: Words @Suite struct NodeWordsTests { + @Test func timelineNamesTheReturnedHistoryResolution() { + AppWords.$language.withValue(.en) { + #expect(NodeWords.timelineMaterial(sampleMs: 300_000, planned: false) == "History: closed 5-minute means · live: source readings · dashed: forecast") + #expect(NodeWords.timelineMaterial(sampleMs: 3_600_000, planned: true) == "History: closed 1-hour means · live: source readings · future bars: plan") + #expect(NodeWords.timelineMaterial(sampleMs: 7_200_000, planned: false) == "History: closed 2-hour means · live: source readings · dashed: forecast") + } + AppWords.$language.withValue(.sv) { + #expect(NodeWords.timelineMaterial(sampleMs: 1_800_000, planned: false) == "Historik: avslutade 30-minutersmedel · live: källvärden · streckat: prognos") + } + } + /// Every sentence Now and the nodes' views hand to `L`, `NodeWords` or /// `AppWords` has a Swedish entry. Read from the source, so a new /// sentence cannot slip through. @@ -845,7 +856,7 @@ import FTWShared "appleApp/FTW/UI/PlanStrip.swift", "appleApp/FTW/UI/PlanView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift", "appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift", "appleApp/FTWKit/Sources/FTWKit/State/NowFlow.swift", "appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift", "appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift", - "appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift", "appleApp/FTW/UI/PairView.swift", + "appleApp/FTWKit/Sources/FTWKit/State/HistoryModel.swift", "appleApp/FTW/UI/PairView.swift", "appleApp/FTW/UI/NodeTimeline.swift", "appleApp/FTW/UI/RootView.swift", "appleApp/FTWKit/Sources/FTWKit/State/PairModel.swift", "appleApp/FTW/Car/CarView.swift", "appleApp/FTWKit/Sources/FTWKit/State/ChartWords.swift", "appleApp/FTW/UI/PriceView.swift", diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/TimelineTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/TimelineTests.swift new file mode 100644 index 00000000..0332779f --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/TimelineTests.swift @@ -0,0 +1,164 @@ +import CoreGraphics +import Foundation +import Testing +@testable import FTWShared + +@Suite struct TimelineTests { + static let now = 1_800_000_000_000.0 + + @Test func aPanKeepsTheTimeUnderTheMovingFinger() { + let original = TimelineWindow(nowMs: Self.now) + let next = original.transformed(anchor: 0.25, destination: 0.75, magnification: 1, nowMs: Self.now) + #expect(next.time(at: 0.75) == original.time(at: 0.25)) + #expect(!next.followsNow) + } + + @Test func aPinchKeepsItsCentroidTimeWhileItMoves() { + let original = TimelineWindow(nowMs: Self.now) + let next = original.transformed(anchor: 0.3, destination: 0.6, magnification: 2, nowMs: Self.now) + #expect(next.spanMs == original.spanMs / 2) + #expect(next.time(at: 0.6) == original.time(at: 0.3)) + } + + @Test func readingsAndLateHistoryAnswersCannotMoveAPannedWindow() { + var window = TimelineWindow(nowMs: Self.now).transformed(anchor: 0, destination: 0.5, magnification: 1, nowMs: Self.now) + let held = window + window.follow(nowMs: Self.now + 120_000) + #expect(window == held) + window.returnToNow(nowMs: Self.now + 120_000) + #expect(window.followsNow) + #expect(window.time(at: TimelineWindow.nowPosition) == Self.now + 120_000) + } + + @Test func theAvailablePastAndZoomAreBounded() { + let initial = TimelineWindow(nowMs: Self.now) + let oldest = initial.transformed(anchor: 0, destination: 1_000, magnification: 0.0001, nowMs: Self.now) + #expect(oldest.fromMs == Self.now - TimelineWindow.historyMs) + #expect(oldest.spanMs == TimelineWindow.maximumSpanMs) + let closest = initial.transformed(anchor: 0.5, destination: 0.5, magnification: 1_000, nowMs: Self.now) + #expect(closest.spanMs == TimelineWindow.minimumSpanMs) + let future = initial.transformed(anchor: 0, destination: -1_000, magnification: 1, nowMs: Self.now) + #expect(future.toMs == Self.now + TimelineWindow.aheadMs) + #expect(!future.followsNow) + #expect(initial.transformed(anchor: .nan, destination: 0, magnification: 1, nowMs: Self.now) == initial) + } + + @Test func elapsedTimeAndTheFingerStayCorrectAcrossBothClockChanges() throws { + let clock = ZoneClock.of(try #require(TimeZone(identifier: "Europe/Stockholm"))) + for parts in [DateComponents(year: 2026, month: 3, day: 29, hour: 4), DateComponents(year: 2026, month: 10, day: 25, hour: 4)] { + let now = try #require(clock.calendar.date(from: parts)).timeIntervalSince1970 * 1000 + let window = TimelineWindow(nowMs: now, spanMs: 6 * 3_600_000) + let zoomed = window.transformed(anchor: 0.4, destination: 0.6, magnification: 2, nowMs: now) + #expect(zoomed.time(at: 0.6) == window.time(at: 0.4)) + #expect(zoomed.spanMs == 3 * 3_600_000) + } + } + + @Test func aSourceObservationWinsWithoutRewritingTheStoredMean() { + let history = [ChartSample(startMs: 0, value: 1_000)] + let observation = LiveSample(t: 210_000, v: 4_000) + #expect(TimelineReading.at(210_100, history: history, observations: [observation], gapMs: 3_500, nowMs: 240_000) == .observation(atMs: 210_000, watts: 4_000, seen: true)) + #expect(history == [ChartSample(startMs: 0, value: 1_000)]) + #expect(TimelineReading.at(30_000, history: history, observations: [observation], gapMs: 3_500, nowMs: 300_000) == .mean(startMs: 0, endMs: 300_000, watts: 1_000)) + } + + @Test func aConfirmedHoleIsNeitherZeroNorAnInterpolatedReading() { + let history = [ChartSample(startMs: 0, value: nil), ChartSample(startMs: 300_000, value: 0)] + let observations = [LiveSample(t: 20_000, v: 1_000), LiveSample(t: 40_000, v: 3_000)] + #expect(TimelineReading.at(30_000, history: history, observations: observations, gapMs: 3_500, nowMs: 600_000) == .missing) + #expect(TimelineReading.at(310_000, history: history, observations: [], gapMs: 3_500, nowMs: 600_000) == .mean(startMs: 300_000, endMs: 600_000, watts: 0)) + #expect(TimelineReading.at(30_000, history: [.init(startMs: 0, value: nil, fetching: true)], observations: [], gapMs: 3_500, nowMs: 600_000) == .fetching) + } + + @Test func restoredPointsStaySavedAndFuturePointsAreNeverReadAsLive() { + let observations = [LiveSample(t: 20_000, v: 1_000, seen: false), LiveSample(t: 40_000, v: 3_000)] + #expect(TimelineReading.at(20_000, history: [], observations: observations, gapMs: 3_500, nowMs: 30_000) == .observation(atMs: 20_000, watts: 1_000, seen: false)) + #expect(TimelineReading.at(40_000, history: [], observations: observations, gapMs: 3_500, nowMs: 30_000) == .missing) + } + + @Test func theFutureIsExplicitAndCannotReplaceTheMeasuredPast() { + let marks = [AheadPicture.Mark(startMs: 0, endMs: 3_600_000, watts: 5_000)] + let history = [ChartSample(startMs: 0, value: 1_000)] + #expect(TimelineReading.at(150_000, history: history, observations: [], gapMs: 3_500, nowMs: 300_000, ahead: (.forecast, marks)) == .mean(startMs: 0, endMs: 300_000, watts: 1_000)) + #expect(TimelineReading.at(400_000, history: history, observations: [], gapMs: 3_500, nowMs: 300_000, ahead: (.forecast, marks)) == .forecast(startMs: 300_000, endMs: 3_600_000, watts: 5_000)) + #expect(TimelineReading.at(400_000, history: history, observations: [], gapMs: 3_500, nowMs: 300_000, ahead: (.plan, marks)) == .plan(startMs: 300_000, endMs: 3_600_000, watts: 5_000)) + } + + @Test func livePeaksFitWithoutChangingTheHistorysValuesOrEnergy() { + let history = [ChartSample(startMs: 0, value: 1_000), ChartSample(startMs: 300_000, value: 2_000)] + let words = ReadoutWords(value: { String($0) }, range: { "\($0)–\($1)" }, none: "no data") + let a = HistoryPicture.build([("home", history)], fromMs: 0, toMs: 600_000, nowMs: 600_000, size: .init(width: 350, height: 200), clock: .of(.gmt), words: words) + let b = HistoryPicture.build([("home", history)], fromMs: 0, toMs: 600_000, nowMs: 600_000, size: .init(width: 350, height: 200), clock: .of(.gmt), observationExtent: 0...8_000, words: words) + #expect(b.domain.upper >= 8_000) + #expect(a.layers[0].series.readouts.inWh == b.layers[0].series.readouts.inWh) + #expect(a.layers[0].series.buckets.map(\.mean) == b.layers[0].series.buckets.map(\.mean)) + } + + @Test func anHourlyLedgerMeanIsNotTwelveFiveMinuteObservations() { + let words = ReadoutWords(value: { String($0) }, range: { "\($0)–\($1)" }, none: "no data") + let picture = HistoryPicture.build([("car", [ChartSample(startMs: 0, value: 3_000)])], fromMs: 0, toMs: 3_600_000, nowMs: 3_600_000, size: .init(width: 350, height: 200), clock: .of(.gmt), sampleSteps: ["car": 3_600_000], words: words) + #expect(picture.grid.stepMs == 3_600_000) + #expect(picture.layers[0].series.buckets.count == 1) + #expect(picture.layers[0].series.buckets[0].count == 1) + #expect(picture.layers[0].series.readouts.inWh.last == 3_000) + } + @Test func onePointDoesNotHideHistoryAndSourceSilencesStayUncovered() { + func series(_ points: [LiveSample]) -> LiveSeries { + LiveSeries.build(points, sourceGapMs: 3_500, periodMs: 1_000, windowMs: 300_000, nowMs: 300_000, size: .init(width: 350, height: 200)) + } + #expect(series([.init(t: 100_000, v: 2_000)]).observedRanges.isEmpty) + let runs = series([.init(t: 100_000, v: 2_000), .init(t: 101_000, v: 2_100), .init(t: 200_000, v: 1_000), .init(t: 201_000, v: 900)]) + #expect(runs.observedRanges == [100_000...101_000, 200_000...201_000]) + } + + @Test func visibleEnergyClipsThePastAndNeverIncludesFutureOrLiveTwice() { + let samples = [ChartSample(startMs: 0, value: 1_200), ChartSample(startMs: 300_000, value: nil), ChartSample(startMs: 600_000, value: 9_000)] + let energy = TimelineEnergy.recorded(samples, fromMs: 150_000, toMs: 900_000, nowMs: 600_000) + #expect(energy.inWh == 50) + #expect(energy.knownMs == 150_000) + #expect(energy.missingMs == 300_000) + #expect(energy.prorated) + let future = TimelineEnergy.recorded(samples, fromMs: 600_000, toMs: 900_000, nowMs: 600_000) + #expect(future.inWh == 0 && future.knownMs == 0 && future.missingMs == 0) + } + + @Test func allMissingIsUnknownButARecordedZeroIsKnown() { + let absent = TimelineEnergy.recorded([.init(startMs: 0, value: nil)], fromMs: 0, toMs: 300_000, nowMs: 300_000) + #expect(absent.knownMs == 0 && absent.missingMs == 300_000) + let pending = TimelineEnergy.recorded([.init(startMs: 0, value: nil, fetching: true)], fromMs: 0, toMs: 300_000, nowMs: 300_000) + #expect(pending.knownMs == 0 && pending.missingMs == 0 && pending.fetchingMs == 300_000) + let zero = TimelineEnergy.recorded([.init(startMs: 0, value: 0)], fromMs: 0, toMs: 300_000, nowMs: 300_000) + #expect(zero.inWh == 0 && zero.knownMs == 300_000 && zero.missingMs == 0) + } + + @Test func anOpenBucketsSmallMeanDoesNotPretendTheLiveLoadFellToZero() { + // Energy recorded during the first second, divided by the whole + // five-minute bucket, can round near zero while power is 3 kW. + let partial = [ChartSample(startMs: 300_000, value: 10)] + #expect(TimelineReading.at(300_500, history: partial, observations: [], gapMs: 3_500, nowMs: 301_000) == .fetching) + #expect(TimelineReading.at(300_500, history: partial, observations: [.init(t: 300_500, v: 3_000)], gapMs: 3_500, nowMs: 301_000) == .observation(atMs: 300_500, watts: 3_000, seen: true)) + let energy = TimelineEnergy.recorded(partial, fromMs: 300_000, toMs: 600_000, nowMs: 301_000) + #expect(energy.knownMs == 0 && energy.inWh == 0 && energy.fetchingMs == 1_000 && energy.missingMs == 0) + #expect(TimelineReading.at(300_500, history: partial, observations: [], gapMs: 3_500, nowMs: 600_000) == .mean(startMs: 300_000, endMs: 600_000, watts: 10)) + } + + @Test func aShortFutureViewportKeepsItsHourlyForecastAndClipsItAtNow() { + let domain = YDomain(lower: 0, upper: 4_000) + let marks = [AheadPicture.Mark(startMs: 0, endMs: 3_600_000, watts: 2_000)] + let path = AheadPicture.forecastLine(marks, fromMs: 1_800_000, toMs: 2_400_000, domain: domain, size: .init(width: 300, height: 100), intervalMeans: true, notBeforeMs: 2_100_000) + #expect(path.elements == [.move(.init(x: 150, y: 50)), .line(.init(x: 300, y: 50))]) + let beforeNow = AheadPicture.forecastLine(marks, fromMs: 1_800_000, toMs: 2_000_000, domain: domain, size: .init(width: 300, height: 100), intervalMeans: true, notBeforeMs: 2_100_000) + #expect(beforeNow.isEmpty) + } + + @Test func aFutureWindowScalesOnlyItsVisiblePlan() { + let marks = [AheadPicture.Mark(startMs: 0, endMs: 3_600_000, watts: 20_000), + AheadPicture.Mark(startMs: 3_600_000, endMs: 7_200_000, watts: 1_000)] + let picture = HistoryPicture.build([("home", [])], ahead: ["home": (.plan, marks)], + fromMs: 3_600_000, toMs: 7_200_000, nowMs: 0, size: .init(width: 300, height: 100), + clock: .of(TimeZone(secondsFromGMT: 0)!), words: ReadoutWords(value: { "\($0)" }, range: { "\($0)–\($1)" }, none: "none")) + #expect(picture.domain.upper < 20_000) + #expect(picture.domain.upper >= 1_000) + } + +} diff --git a/appleApp/FTWUITests/ChartFingerTests.swift b/appleApp/FTWUITests/ChartFingerTests.swift index f4774aa3..34a1bf11 100644 --- a/appleApp/FTWUITests/ChartFingerTests.swift +++ b/appleApp/FTWUITests/ChartFingerTests.swift @@ -1,7 +1,7 @@ import XCTest /// The finger on the charts (UX pass 3, U4). A tap on a node chart opens -/// it on the whole screen and a drag only scrubs; a swipe up that starts +/// it on the whole screen and a quick drag pans; a swipe up that starts /// on the plan dial's ring scrolls the plan, and once the ring scrubs, the /// page holds still. /// @@ -26,14 +26,14 @@ final class ChartFingerTests: XCTestCase { } @MainActor - func testATapOnEachChartEnlargesItAndADragOnlyScrubs() throws { - for period in ["live", "today", "ahead"] { - let app = launch(["-FTWNode", "battery", "-FTWPeriod", period]) - let chart = app.descendants(matching: .any)[period == "live" ? "node.live.chart" : "node.period.chart"] + func testATapEnlargesTheTimelineAndADragOnlyPans() throws { + for period in ["1", "24", "48"] { + let app = launch(["-FTWNode", "battery", "-FTWTimelineHours", period]) + let chart = app.descendants(matching: .any)["node.timeline.chart"] XCTAssertTrue(chart.waitForExistence(timeout: 15), "no \(period) chart") Thread.sleep(forTimeInterval: 2) let close = app.buttons["node.chart.close"] - // A drag across the chart scrubs and opens nothing. + // A drag across the chart pans and opens nothing. chart.coordinate(withNormalizedOffset: CGVector(dx: 0.3, dy: 0.55)) .press(forDuration: 0.05, thenDragTo: chart.coordinate(withNormalizedOffset: CGVector(dx: 0.7, dy: 0.55)), withVelocity: .default, thenHoldForDuration: 0.2) XCTAssertFalse(close.waitForExistence(timeout: 1.5), "a drag on \(period) opened the chart") diff --git a/appleApp/FTWUITests/ChartTour.swift b/appleApp/FTWUITests/ChartTour.swift index d846a3c5..25b25d90 100644 --- a/appleApp/FTWUITests/ChartTour.swift +++ b/appleApp/FTWUITests/ChartTour.swift @@ -14,27 +14,18 @@ final class ChartTour: XCTestCase { let app = XCUIApplication() app.launchArguments = [ // 48 h: the held tile lies inside it, with data either side. - "-FTWDemo", "YES", "-FTWDemoGap", "YES", "-FTWNode", env["FTW_NODE"] ?? "battery", "-FTWPeriod", "twoDays", + "-FTWDemo", "YES", "-FTWDemoGap", "YES", "-FTWNode", env["FTW_NODE"] ?? "battery", "-FTWTimelineHours", "48", "-FTWTimelineBackHours", "7", "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US", ] app.launch() - let period = app.descendants(matching: .any)["node.period.chart"].firstMatch + let period = app.descendants(matching: .any)["node.timeline.chart"].firstMatch XCTAssertTrue(period.waitForExistence(timeout: 15)) - // The held tile is a dashed estimate for two seconds, then grows in. - shoot(app, "gap-estimate") + // The held tile stays hatched until measured data arrives. + shoot(app, "gap-fetching") Thread.sleep(forTimeInterval: 2.5) shoot(app, "gap-filled") // A slow finger across today, held at the end. drag(period) - // The same across the hours ahead. - let ahead = app.segmentedControls["node.period"].buttons.element(boundBy: 2) - if ahead.exists { - ahead.tap() - Thread.sleep(forTimeInterval: 1.5) - drag(app.descendants(matching: .any)["node.period.chart"].firstMatch) - } - let live = app.descendants(matching: .any)["node.live.chart"].firstMatch - if live.exists { drag(live) } Thread.sleep(forTimeInterval: 1) } diff --git a/appleApp/FTWUITests/HistoryTour.swift b/appleApp/FTWUITests/HistoryTour.swift index 9208a964..44617bcf 100644 --- a/appleApp/FTWUITests/HistoryTour.swift +++ b/appleApp/FTWUITests/HistoryTour.swift @@ -1,66 +1,71 @@ import XCTest -/// A node from Live to Today: a finger on the live trace, then the same -/// finger on Today, the chart small, a second series added by its chip, -/// then the chart grown to the whole screen and scrubbed there. For -/// screenshots and recordings. Skipped unless asked for: -/// -/// TEST_RUNNER_FTW_HISTORY=1 xcodebuild test … -only-testing:FTWUITests/HistoryTour +/// The continuous timeline on the actual simulator: history is present +/// before a five-minute ring fills, a pan holds while readings arrive, +/// pinch changes the window, and the same window opens on the whole screen. final class HistoryTour: XCTestCase { @MainActor - func testCompactThenEnlarged() throws { + func testContinuousHistoryPanPinchAndEnlarge() throws { let env = ProcessInfo.processInfo.environment - try XCTSkipUnless(env["FTW_HISTORY"] == "1", "Set TEST_RUNNER_FTW_HISTORY=1 to drive the history.") + try XCTSkipUnless(env["FTW_HISTORY"] == "1", "Set TEST_RUNNER_FTW_HISTORY=1 to drive the timeline.") let app = XCUIApplication() - app.launchArguments = ["-FTWDemo", "YES", "-FTWNode", env["FTW_NODE"] ?? "battery", "-FTWPeriod", "live", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launchArguments = ["-FTWDemo", "YES", "-FTWNode", "home", "-FTWTimelineHours", "1", "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] app.launch() - // Live first, with a finger on it, then Today in the same material. - let live = app.descendants(matching: .any)["node.live.chart"].firstMatch - XCTAssertTrue(live.waitForExistence(timeout: 15)) - Thread.sleep(forTimeInterval: 8) - shoot(app, "live") - drag(live) - app.segmentedControls["node.period"].buttons["Today"].tap() - let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + let chart = app.descendants(matching: .any)["node.timeline.chart"].firstMatch XCTAssertTrue(chart.waitForExistence(timeout: 15)) - Thread.sleep(forTimeInterval: 2) - shoot(app, "compact") - drag(chart) - // A second series, then a third, by their chips. - let solar = app.buttons["chart.series.solar"] - XCTAssertTrue(solar.waitForExistence(timeout: 5)) - solar.tap() - Thread.sleep(forTimeInterval: 1.2) - shoot(app, "compact-two") - app.buttons["chart.series.grid"].tap() - Thread.sleep(forTimeInterval: 1.2) - // A finger across the small chart. - drag(chart) - // Grown to the whole screen out of the small one. + XCTAssertFalse(app.segmentedControls["node.period"].exists) + Thread.sleep(forTimeInterval: 3) + shoot(app, "timeline-history-on-open") + let initial = chart.value as? String + chart.coordinate(withNormalizedOffset: .init(dx: 0.2, dy: 0.5)).press(forDuration: 0.02, + thenDragTo: chart.coordinate(withNormalizedOffset: .init(dx: 0.8, dy: 0.5)), withVelocity: .slow, thenHoldForDuration: 0) + let now = app.buttons["timeline.now"] + XCTAssertTrue(now.waitForExistence(timeout: 3)) + XCTAssertTrue(now.isEnabled) + let panned = chart.value as? String + XCTAssertNotEqual(initial, panned) + Thread.sleep(forTimeInterval: 4) + XCTAssertEqual(panned, chart.value as? String, "new live readings moved a panned window") + shoot(app, "timeline-panned-held") + chart.pinch(withScale: 0.5, velocity: -1) + XCTAssertNotEqual(panned, chart.value as? String, "pinch did not change the window") + shoot(app, "timeline-pinched") + // A hold reads an interval mean rather than a manufactured live value. + chart.coordinate(withNormalizedOffset: .init(dx: 0.2, dy: 0.5)).press(forDuration: 0.4, + thenDragTo: chart.coordinate(withNormalizedOffset: .init(dx: 0.5, dy: 0.5)), withVelocity: .slow, thenHoldForDuration: 0.7) + now.tap() + XCTAssertFalse(now.isEnabled) app.buttons["node.chart.enlarge"].tap() let close = app.buttons["node.chart.close"] - XCTAssertTrue(close.waitForExistence(timeout: 5)) - Thread.sleep(forTimeInterval: 1.5) - shoot(app, "enlarged") - drag(app.descendants(matching: .any)["node.period.chart"].firstMatch) - app.segmentedControls["node.chart.period"].buttons["48 h"].tap() - Thread.sleep(forTimeInterval: 1.5) - shoot(app, "enlarged-48h") - // Back to one series, then shut. - app.buttons["chart.series.grid"].firstMatch.tap() - app.buttons["chart.series.solar"].firstMatch.tap() - Thread.sleep(forTimeInterval: 1) + XCTAssertTrue(close.waitForExistence(timeout: 4)) + XCTAssertFalse(app.segmentedControls["node.chart.period"].exists) + shoot(app, "timeline-full-screen") close.tap() - Thread.sleep(forTimeInterval: 1.5) - shoot(app, "compact-again") + XCTAssertTrue(close.waitForNonExistence(timeout: 3)) } @MainActor - private func drag(_ element: XCUIElement) { - let from = element.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.5)) - let to = element.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)) - from.press(forDuration: 0.2, thenDragTo: to, withVelocity: .slow, thenHoldForDuration: 1) - Thread.sleep(forTimeInterval: 0.8) + func testMissingWaitingAndUnavailableHistoryStayDistinct() throws { + try XCTSkipUnless(ProcessInfo.processInfo.environment["FTW_HISTORY"] == "1", "Set TEST_RUNNER_FTW_HISTORY=1 to drive the timeline.") + for (state, message) in [ + ("empty", "No recorded history in this window."), + ("unavailable", "Your box can't send its history right now. Still trying."), + ("waiting", "Fetching history…"), + ] { + let app = XCUIApplication() + // The held tile is the previous epoch-aligned 12-hour tile. + // Keep this window inside it at any wall-clock time. + let back = state == "waiting" ? (Date().timeIntervalSince1970 / 3_600).truncatingRemainder(dividingBy: 12) + 3 : 7 + app.launchArguments = ["-FTWDemo", "YES", "-FTWNode", "home", "-FTWTimelineHours", "1", "-FTWTimelineBackHours", String(back), "-FTWDemoHistory", state, "-AppleLanguages", "(en)", "-AppleLocale", "en_US"] + app.launch() + let chart = app.descendants(matching: .any)["node.timeline.chart"].firstMatch + XCTAssertTrue(chart.waitForExistence(timeout: 15)) + let hasState = NSPredicate(format: "value CONTAINS %@", message) + expectation(for: hasState, evaluatedWith: chart) + waitForExpectations(timeout: 8) + shoot(app, "timeline-" + state) + app.terminate() + } } @MainActor diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index be845138..c4e3c6a2 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -392,12 +392,8 @@ final class NowFitsTests: XCTestCase { let app = launchDemo() app.buttons["now.node.battery"].tap() XCTAssertTrue(app.navigationBars["Battery"].waitForExistence(timeout: 5)) - // The view opens on the timebase this phone chose last; Live is one tap. - app.segmentedControls["node.period"].buttons["Live"].tap() - XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].waitForExistence(timeout: 5)) - let ahead = app.segmentedControls["node.period"].buttons["Ahead"] - XCTAssertTrue(ahead.isHittable) - ahead.tap() + XCTAssertTrue(app.descendants(matching: .any)["node.timeline.chart"].waitForExistence(timeout: 5)) + XCTAssertFalse(app.segmentedControls["node.period"].exists) XCTAssertTrue(app.buttons["node.mode.change"].isHittable) // The node's view fits too: its last control is where it stands. XCTAssertTrue(app.windows.firstMatch.frame.contains(app.buttons["node.mode.change"].frame)) @@ -411,8 +407,8 @@ final class NowFitsTests: XCTestCase { func testEveryNodeViewKeepsItsControlsAtTheFoot() throws { for text in [Self.standard, Self.largest] { for node in ["solar", "battery", "home", "grid", "car"] { - let app = launchDemo(text: text, more: ["-FTWNode", node, "-FTWPeriod", "today"]) - let picker = app.segmentedControls["node.period"] + let app = launchDemo(text: text, more: ["-FTWNode", node, "-FTWTimelineHours", "24"]) + let picker = app.descendants(matching: .any)["node.timeline.chart"].firstMatch XCTAssertTrue(picker.waitForExistence(timeout: 15), "\(node) never opened at \(text)") let footer = app.staticTexts["node.footer"] XCTAssertTrue(footer.waitForExistence(timeout: 5)) @@ -421,15 +417,10 @@ final class NowFitsTests: XCTestCase { XCTAssertEqual(app.scrollViews.count, 0, "\(node) scrolls at \(text)") XCTAssertTrue(window.contains(footer.frame), "\(node)'s footer is off screen at \(text)") XCTAssertFalse(app.staticTexts["node.status"].exists, "\(node) has a second status line") - // The foot: the picker and what sits under it take the lower - // part of the screen, and nothing but the footer is under them. - XCTAssertGreaterThan(picker.frame.minY, window.height * 0.55, "\(node)'s picker floats at \(text)") - XCTAssertLessThan(window.maxY - footer.frame.maxY, 60, "\(node) leaves ground under its footer at \(text)") + // A pan or zoom changes time, not the surrounding layout. let top = picker.frame.minY - for period in ["Live", "48 h", "Today"] { - picker.buttons[period].tap() - XCTAssertEqual(picker.frame.minY, top, accuracy: 0.5, "\(node)'s picker moved on \(period) at \(text)") - } + picker.pinch(withScale: 0.8, velocity: -1) + XCTAssertEqual(picker.frame.minY, top, accuracy: 0.5) if node == "battery" { shoot(app, "battery-\(text)") } app.terminate() } @@ -440,7 +431,7 @@ final class NowFitsTests: XCTestCase { /// and closes as a sheet does. @MainActor func testTheNodeChartEnlargesAndCloses() throws { - let app = launchDemo(more: ["-FTWNode", "battery", "-FTWPeriod", "today"]) + let app = launchDemo(more: ["-FTWNode", "battery", "-FTWTimelineHours", "24"]) let enlarge = app.buttons["node.chart.enlarge"] XCTAssertTrue(enlarge.waitForExistence(timeout: 15)) XCTAssertTrue(enlarge.isHittable) diff --git a/appleApp/FTWUITests/ScreenshotTour.swift b/appleApp/FTWUITests/ScreenshotTour.swift index 54f12872..aef7fe04 100644 --- a/appleApp/FTWUITests/ScreenshotTour.swift +++ b/appleApp/FTWUITests/ScreenshotTour.swift @@ -61,12 +61,6 @@ final class ScreenshotTour: XCTestCase { door.tap() Thread.sleep(forTimeInterval: 3) shoot(app, node) - let ahead = app.segmentedControls["node.period"].buttons.element(boundBy: 2) - if ahead.exists { - ahead.tap() - Thread.sleep(forTimeInterval: 2) - shoot(app, "\(node)-ahead") - } if node == "battery", app.buttons["node.mode.change"].exists { app.buttons["node.mode.change"].tap() Thread.sleep(forTimeInterval: 2) diff --git a/appleApp/FTWUITests/SecondOpenTour.swift b/appleApp/FTWUITests/SecondOpenTour.swift index 79133047..b9a7adc6 100644 --- a/appleApp/FTWUITests/SecondOpenTour.swift +++ b/appleApp/FTWUITests/SecondOpenTour.swift @@ -15,7 +15,7 @@ final class SecondOpenTour: XCTestCase { let lang = env["FTW_LANG"] ?? "sv" let base = ["-FTWDemo", "YES", "-FTWDemoCache", "YES", "-AppleLanguages", "(\(lang))", "-AppleLocale", lang == "sv" ? "sv_SE" : "en_US"] let app = XCUIApplication() - app.launchArguments = base + ["-FTWPeriod", "today"] + app.launchArguments = base + ["-FTWTimelineHours", "24"] app.launch() let battery = app.descendants(matching: .any)["now.node.battery"].firstMatch XCTAssertTrue(battery.waitForExistence(timeout: 15)) @@ -24,7 +24,7 @@ final class SecondOpenTour: XCTestCase { // First open, back, second open. for open in ["first", "second"] { battery.tap() - let chart = app.descendants(matching: .any)["node.period.chart"].firstMatch + let chart = app.descendants(matching: .any)["node.timeline.chart"].firstMatch XCTAssertTrue(chart.waitForExistence(timeout: 5)) shoot(app, "\(open)-open") Thread.sleep(forTimeInterval: 3) @@ -38,12 +38,11 @@ final class SecondOpenTour: XCTestCase { Thread.sleep(forTimeInterval: 3) app.terminate() Thread.sleep(forTimeInterval: 2) - app.launchArguments = base + ["-FTWNode", "battery", "-FTWPeriod", "live"] + app.launchArguments = base + ["-FTWNode", "battery", "-FTWTimelineHours", "1"] app.launch() - XCTAssertTrue(app.descendants(matching: .any)["node.live.chart"].firstMatch.waitForExistence(timeout: 5)) + XCTAssertTrue(app.descendants(matching: .any)["node.timeline.chart"].firstMatch.waitForExistence(timeout: 5)) shoot(app, "after-kill") Thread.sleep(forTimeInterval: 4) - app.segmentedControls["node.period"].buttons.element(boundBy: 1).tap() shoot(app, "after-kill-today") Thread.sleep(forTimeInterval: 3) }