diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 5aa28d0..274be16 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -119,6 +119,13 @@ private struct PlanBody: View { } Text(plannedLine(moment, zone: dial.zone)) .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.ink3) + // What the plan rests on: the prices are known, + // the sun and the home's use are a forecast, and + // a style other than balanced leans on it less or + // more. + if let lean = styleLine { + Text(lean).foregroundStyle(Theme.ink3) + } } .font(Theme.mono()) .tracking(0.5) @@ -164,6 +171,13 @@ private struct PlanBody: View { return PlanDial.planned(atMs: at, old: m.state == .old, zone: zone) } + /// "CAREFUL WITH THE FORECAST", when the planning style is not + /// balanced. Nothing when it is, or before the box has said. + private var styleLine: String? { + guard let style = models.mode.shownStyle, style.key != "balanced" else { return nil } + return NodeWords.format("%@ with the forecast", NodeWords.text(style.name)).uppercased() + } + /// When the box made it, or that it could not. private func stateLine(_ m: PlanGlance.Moment) -> String { if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } diff --git a/appleApp/FTWCharts/PlanDialFace.swift b/appleApp/FTWCharts/PlanDialFace.swift index d3e04e2..cfca0c2 100644 --- a/appleApp/FTWCharts/PlanDialFace.swift +++ b/appleApp/FTWCharts/PlanDialFace.swift @@ -7,7 +7,8 @@ import WidgetKit /// face, so the four never differ. /// /// From the outside in: the rim, a hairline with a 2° gap at the seam where -/// +24 h meets now, and the sun's arc on it from the box's forecast; the +/// +24 h meets now, and the sun's arc on it from the box's forecast, dashed +/// because it is a guess; the /// hours just outside it; 96 price ticks in eight levels, cyan where the /// battery stores, ink where it gives power, faint where it waits; stubs /// where the market has not priced a quarter yet; the car's dotted arc @@ -37,7 +38,9 @@ struct PlanDialFace: View, Equatable { rim(g).stroke(Theme.ink.opacity(0.18), style: StrokeStyle(lineWidth: 0.5, lineCap: .butt, lineJoin: .round)) HourLabels(g: g) } - sun(g).stroke(grey ? Theme.ink3 : Theme.solar, style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round)) + // The sun is the box's forecast, a guess: dashed, where the + // prices it plans on are drawn solid. + sun(g).stroke(grey ? Theme.ink3 : Theme.solar, style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round, dash: g.sunDash)) ticks(g, .waits).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink3.opacity(0.6)) ticks(g, .gives).fill(grey ? Theme.ink3.opacity(0.45) : Theme.ink) ticks(g, .stores).fill(grey ? Theme.ink3.opacity(0.45) : Theme.stores) @@ -211,6 +214,8 @@ struct DialGeometry { var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } var dot: CGFloat { max(1.4, side * 0.012) } var sunWidth: CGFloat { size == .app ? 2 : max(1.2, side * 0.015) } + /// The forecast's dash: short enough to read as a guess at any size. + var sunDash: [CGFloat] { size == .app ? [4, 3] : [max(1.5, side * 0.02), max(1.2, side * 0.016)] } /// The hours' and the car goal's mono size. var label: CGFloat { max(9, (side * 0.03).rounded()) } diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index 16ea1d3..e204534 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -98,6 +98,25 @@ struct PlanStripDrawing: ChartDrawing { for (third, path) in thirds { g.fill(path, with: .color(grey ? Theme.ink3.opacity(0.6) : Theme.ink.opacity(Self.ink(third)))) } + // Not priced yet: a hatched stub, so a guess never looks like a price. + if !strip.pending.isEmpty { + var stubs = Path(), hatch = Path() + let stub = PlanStripLayout.levelStep * 2 + for run in strip.pending { + let s = PlanStripLayout.span(first: run.first, last: run.last, grid: grid, width: width, scale: scale) + let rect = CGRect(x: s.x0, y: PlanStripLayout.priceBottom - stub, width: s.x1 - s.x0, height: stub) + stubs.addRect(rect) + var x = rect.minX - stub + while x < rect.maxX { + hatch.move(to: CGPoint(x: x, y: rect.maxY)) + hatch.addLine(to: CGPoint(x: x + stub, y: rect.minY)) + x += 3 + } + } + var clipped = g + clipped.clip(to: stubs) + clipped.stroke(hatch, with: .color(Theme.ink3), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) + } // Now, one pixel across the lanes and the bars. let nowX = snap(grid.x(atMs: strip.nowMs, width: width)) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index df25efd..e51505d 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -42,6 +42,9 @@ public final class SimulatedBox { /// `priced_coverage_pct`. Below 0.95 the grid widget marks its cost /// partial. public var pricedShare: Double = 1 + /// The hour tomorrow's prices publish, local. 24 holds them back all + /// day, as on a day the market is late. + public var publishHour: Double = 13 /// A Core with srcfl/ftw#1586 reads and sends the ready time as /// `time_of_day_min` on the household clock. Off, it is a Core that /// knows only `time_of_day_min_utc`. @@ -298,7 +301,7 @@ public final class SimulatedBox { let slotMs: Double = 900_000 // Tomorrow's rates publish at 13:00 local. let midnight = scheduler.nowMs - house.hourOfDay(scheduler.nowMs) * 3_600_000 - let published = midnight + (house.hourOfDay(scheduler.nowMs) >= 13 ? 48 : 24) * 3_600_000 + let published = midnight + (house.hourOfDay(scheduler.nowMs) >= publishHour ? 48 : 24) * 3_600_000 let end = min(to, published) var slots = [CBOR]() var t = (from / slotMs).rounded(.down) * slotMs @@ -816,6 +819,11 @@ extension SimulatedBox { // `-FTWDemoPartial YES` leaves a fifth of it unpriced. if UserDefaults.standard.bool(forKey: "FTWDemoSunny") { box.house.sunny = true } if UserDefaults.standard.bool(forKey: "FTWDemoPartial") { box.pricedShare = 0.8 } + // For the plan's pictures: `-FTWDemoUnpublished YES` holds + // tomorrow's prices back, and `-FTWDemoSafetyK 0.6` starts on + // another planning style. + if UserDefaults.standard.bool(forKey: "FTWDemoUnpublished") { box.publishHour = 24 } + if UserDefaults.standard.object(forKey: "FTWDemoSafetyK") != nil { box.house.safetyK = UserDefaults.standard.double(forKey: "FTWDemoSafetyK") } // For recordings of light and its decay: `-FTWDemoQuiet 8` keeps // the box quiet for 8 s in every 16, so Now goes from live to quiet // and back. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 6228584..3feb444 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -139,6 +139,7 @@ public enum NodeWords { "Opens the plan.": "Öppnar planen.", "Dial": "Tavla", "battery level": "batteriets nivå", + "%@ with the forecast": "%@ med prognosen", "Lanes": "Banor", "Show the plan as": "Visa planen som", "Swipe up or down to move an hour.": "Svep uppåt eller nedåt för att flytta en timme.", diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index fddce9d..ca2d0bb 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -44,6 +44,9 @@ public struct PlanStrip: Equatable, Sendable { public let batteryRuns: [Run] public let carRuns: [Run] public let priceRuns: [Run] + /// Stretches the market has not priced yet: drawn as hatched stubs, + /// never as a level. + public let pending: [Run] /// Local three-hour marks: "18", "21", "00". public let hours: [Tick] /// Quarters that give a light impact under a finger: now, either end, @@ -175,6 +178,7 @@ public struct PlanStrip: Equatable, Sendable { batteryRuns: runs(battery), carRuns: runs(car.map { $0 ? true : nil }), priceRuns: runs(levels), + pending: runs(levels.map { $0 == nil ? true : nil }), hours: hours, landmarks: landmarks, readouts: readouts, diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 9cdc3e1..1b576b2 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -230,4 +230,15 @@ import Testing // A plan with no levels says nothing about them. #expect(PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar).levels.isEmpty) } + + /// A quarter the market has not priced is a stub, never a level. + @Test func unpricedQuartersAreStubs() { + var entry = glance.night() + entry.prices = entry.prices.filter { $0.startMs < at(13, 4) } + let s = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + // 04:00 is quarter 32; the window runs to 08:00. + #expect(s.pending == [.init(first: 32, count: 16, value: true)]) + #expect(s.levels[31] != nil && s.levels[32] == nil) + #expect(strip(at: at(12, 20, 5)).pending.isEmpty) + } }