From 87e9e779e8a5ada14d21ace8f529ed48b90fdc13 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:35:51 +0200 Subject: [PATCH] iPhone: the plan says why in the same sentence (plan pass 1) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanStrip.swift | 3 + appleApp/FTW/UI/PlanView.swift | 59 ++++++- .../FTWKit/Sources/FTWShared/PlanGlance.swift | 150 ++++++++++++++---- .../FTWShared/Resources/en.lproj/Plan.strings | 30 ++-- .../FTWShared/Resources/sv.lproj/Plan.strings | 30 ++-- .../Tests/FTWSharedTests/PlanStripTests.swift | 54 ++++++- 6 files changed, 253 insertions(+), 73 deletions(-) diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 2a95f5c..68cbb98 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -142,6 +142,9 @@ private struct StripSentence: View { .font(.subheadline) .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) .lineLimit(lines, reservesSpace: !typeSize.isAccessibilitySize) + // The reason makes the line long; it shrinks a little before + // it would lose its end. + .minimumScaleFactor(0.85) .redacted(reason: placeholder ? .placeholder : []) .opacity(down ? 0 : 1) // Back only once the quarter's line has gone, so the two diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index d36b564..7948638 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -104,11 +104,9 @@ private struct PlanBody: View { VStack(alignment: .leading, spacing: 12) { PlanDialCard(dial: dial, grey: moment.isGrey) VStack(alignment: .leading, spacing: 6) { - Text(moment.summary) - .font(.subheadline) - .foregroundStyle(moment.isGrey ? Theme.ink3 : Theme.ink2) - .fixedSize(horizontal: false, vertical: true) - .redacted(reason: moment.placeholder ? .placeholder : []) + // The words are the dial's legend: each verb takes + // the colour its ticks have. + PlanStory(moment: moment) // Mono stops growing at accessibility1. Group { if let cheapest = dial.cheapestText { @@ -147,7 +145,10 @@ private struct PlanBody: View { } } PriceRow(prices: prices, minute: minute) - Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints, style: style) + // Under the dial the story already says what each row would. + if style == .lanes { + Events(moment: moment, entry: models.planShare.entry, loadpoints: loadpoints, style: style) + } CompactMode(mode: models.mode, choose: chooseMode) } @@ -166,6 +167,52 @@ private struct PlanBody: View { } } +/// The plan under the dial in up to three sentences, each verb in the +/// colour of its ticks: ink gives power, cyan stores, violet for the car. +/// Before the box has answered, the plan's own line in its shape. +private struct PlanStory: View { + let moment: PlanGlance.Moment + + var body: some View { + Group { + if moment.story.isEmpty { + Text(moment.summary) + .foregroundStyle(moment.isGrey ? Theme.ink3 : Theme.ink2) + .redacted(reason: moment.placeholder ? .placeholder : []) + } else { + moment.story.enumerated().reduce(Text(verbatim: "")) { text, item in + let (i, phrase) = item + return text + Text(verbatim: i == 0 ? "" : " ") + sentence(phrase) + } + } + } + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .combine) + } + + private func sentence(_ phrase: PlanGlance.Phrase) -> Text { + phrase.parts.enumerated().reduce(Text(verbatim: "")) { text, item in + let (i, part) = item + let verb = i % 2 == 1 + return text + Text(verbatim: part) + .foregroundStyle(verb && !moment.isGrey ? color(phrase.kind) : (moment.isGrey ? Theme.ink3 : Theme.ink2)) + .fontWeight(verb ? .semibold : .regular) + } + } + + /// The dial's colours: on the dial gold is the sun's, so giving power is + /// ink. + private func color(_ kind: PlanGlance.Kind) -> Color { + switch kind { + case .charge: return Theme.stores + case .discharge: return Theme.ink + case .car: return Theme.car + case .cheap: return Theme.ink2 + } + } +} + /// The dial worked out once per plan and minute, so a body that runs again /// for any other reason reads it. @MainActor diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index 4c63446..a5a63ee 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -247,6 +247,10 @@ public enum PlanGlance { /// what follows. "Battery gives power until 21:15, then stores /// 22:00." public let nextText: String + /// The plan under the dial in up to three sentences: the battery's + /// next two moves and the car's charge, each with its reason. The + /// verbs are marked so they can take their colour. + public let story: [Phrase] /// The zone the clock in this moment is read in. public let timeZone: TimeZone @@ -274,7 +278,7 @@ public enum PlanGlance { let calendar = given ?? .household(read?.value.timeZone) guard let read else { return Moment(atMs: t, state: .nothing, readAtMs: nil, events: [], backdrop: [], currency: "", summary: PlanWord.openToSee.text, - next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: PlanWord.openToSee.text, timeZone: calendar.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: PlanWord.openToSee.text, story: [], timeZone: calendar.timeZone) } let old = t - read.record.readAtMs >= staleAfterMs let state: State = read.value.boxStale || read.srcState == .stale ? .boxStale : (old ? .old : .plan) @@ -299,7 +303,7 @@ public enum PlanGlance { let m = moment(entry, state: .waiting, readAtMs: nil, atMs: nowMs, calendar: calendar ?? .household(entry.timeZone)) guard let note else { return m } return Moment(atMs: m.atMs, state: m.state, readAtMs: nil, events: m.events, backdrop: m.backdrop, currency: m.currency, summary: note, - next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: note, timeZone: m.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: note, story: [], timeZone: m.timeZone) } private static func moment(_ entry: PlanEntry, state: State, readAtMs: Double?, atMs t: Double, calendar: Calendar, inApp: Bool = false) -> Moment { @@ -333,7 +337,7 @@ public enum PlanGlance { nextText = summary } else { summary = line(list, from: t, clock: clock, ongoingIsNow: state == .plan) - nextText = sentence(list, from: t, clock: clock, ongoingIsNow: state == .plan) + nextText = sentence(list, entry: entry, from: t, clock: clock, ongoingIsNow: state == .plan) } } @@ -358,6 +362,7 @@ public enum PlanGlance { }, ageText: age, nextText: nextText, + story: state == .plan || state == .old ? story(list, entry: entry, from: t, clock: clock, ongoingIsNow: state == .plan) : [], timeZone: calendar.timeZone ) } @@ -387,33 +392,100 @@ public enum PlanGlance { return parts.joined(separator: " · ") } - /// Now's strip: the next thing the box will do, and the one after it. - /// "Battery gives power until 21:15, then stores 22:00." A thing under - /// way says until when; one to come says when it runs. The second says - /// only when it starts, and names its subject when that changes. A plan - /// that may no longer hold (`ongoingIsNow` false) never says "until". - static func sentence(_ events: [Event], from t: Double, clock: Clock, ongoingIsNow: Bool) -> String { + /// One sentence about the plan, with its verb marked: the words in + /// turns plain and marked, so a screen can colour what the battery or + /// the car does. "Battery *gives power* until 23:30, when power costs + /// up to 465 öre." + public struct Phrase: Equatable, Sendable { + public let kind: Kind + /// Even entries are plain, odd entries are the verb. + public let parts: [String] + public var plain: String { parts.joined() } + + init(kind: Kind, marked: String) { + self.kind = kind + parts = marked.components(separatedBy: "*") + } + } + + /// Now's strip: the next thing the box will do and why, then the one + /// after it and why. "Battery gives power until 23:30, when power + /// costs up to 465 öre. Then stores 03:45 for 297 öre." A thing under + /// way says until when; one to come says when it runs. The second names + /// its subject when that changes. A plan that may no longer hold + /// (`ongoingIsNow` false) never says "until". + static func sentence(_ events: [Event], entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool) -> String { let actions = events.filter { $0.kind != .cheap } guard let first = actions.first else { return PlanWord.nothingPlanned.text } - let start = ahead(first.startMs, asEnd: false, from: t, clock: clock) - let end = ahead(first.endMs, asEnd: true, from: t, clock: clock) + var text = clause(first, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow).plain + if let then = actions.dropFirst().first { + let when = ahead(then.startMs, asEnd: false, from: t, clock: clock) + let battery = first.kind == .charge || first.kind == .discharge + let lead: PlanWord + switch then.kind { + case .charge: lead = battery ? .thenStores : .thenBatteryStores + case .discharge: lead = battery ? .thenGives : .thenBatteryGives + case .car, .cheap: lead = .thenCar + } + text += " " + Phrase(kind: then.kind, marked: PlanWord.sentence.format(why(then, lead.format(when), entry: entry))).plain + } + return text + } + + /// The plan under the dial: the battery's next two moves and the car's + /// next charge, in the order they happen, a sentence each, each with + /// its reason. + static func story(_ events: [Event], entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool) -> [Phrase] { + let battery = events.filter { $0.kind == .charge || $0.kind == .discharge }.prefix(2) + let car = events.first { $0.kind == .car } + return (Array(battery) + [car].compactMap { $0 }) + .sorted { $0.startMs < $1.startMs } + .map { clause($0, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow) } + } + + /// One event as a sentence with its verb marked and its reason. + static func clause(_ e: Event, entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool) -> Phrase { + let start = ahead(e.startMs, asEnd: false, from: t, clock: clock) + let end = ahead(e.endMs, asEnd: true, from: t, clock: clock) // "17:15–23:30", or "tomorrow 02:00–05:00": the day once. - let span = "\(start)–\(clock.wall(first.endMs, asEnd: true).hm)" - let under = started(first, at: t) && ongoingIsNow - var lead: String - switch first.kind { + let span = "\(start)–\(clock.wall(e.endMs, asEnd: true).hm)" + let under = started(e, at: t) && ongoingIsNow + let lead: String + switch e.kind { case .charge: lead = under ? PlanWord.storesUntil.format(end) : PlanWord.storesAt.format(span) case .discharge: lead = under ? PlanWord.givesUntil.format(end) : PlanWord.givesAt.format(span) case .car, .cheap: lead = under ? PlanWord.carUntil.format(end) : PlanWord.carAt.format(span) } - if first.kind == .car, let pct = first.targetPct { lead = PlanWord.toPercent.format(lead, percent(pct)) } - guard let then = actions.dropFirst().first else { return PlanWord.sentence.format(lead) } - let when = ahead(then.startMs, asEnd: false, from: t, clock: clock) - let battery = first.kind == .charge || first.kind == .discharge - switch then.kind { - case .charge: return (battery ? PlanWord.thenStores : PlanWord.thenBatteryStores).format(lead, when) - case .discharge: return (battery ? PlanWord.thenGives : PlanWord.thenBatteryGives).format(lead, when) - case .car, .cheap: return PlanWord.thenCar.format(lead, when) + return Phrase(kind: e.kind, marked: PlanWord.sentence.format(why(e, lead, entry: entry))) + } + + /// The box's reason, said as what drives it: the price when power is + /// dear or cheap, the sun, selling, the grid limit, or the car's goal. + /// A reason with no price to show leaves the clause as it is. + static func why(_ e: Event, _ lead: String, entry: PlanEntry) -> String { + if e.kind == .car { + return e.targetPct.map { PlanWord.toPercent.format(lead, percent($0)) } ?? lead + } + let under = entry.prices.filter { $0.totalMinor.isFinite && $0.startMs < e.endMs && $0.startMs + $0.durationMs > e.startMs } + func price(_ minor: Double) -> String { + Words.decimal(PriceStrip.text(abs(minor), entry.currency)) + " " + PriceUnits.unit(entry.currency).label + } + switch (e.kind, e.reason) { + case (.discharge, "export_paid"?): + return PlanWord.whySell.format(lead) + case (.discharge, "peak_shaving"?): + return PlanWord.whyLimit.format(lead) + case (.discharge, _): + guard let top = under.map(\.totalMinor).max() else { return lead } + return PlanWord.whyDear.format(lead, price(top)) + case (.charge, "solar_surplus"?): + return PlanWord.whySun.format(lead) + case (.charge, _): + guard !under.isEmpty else { return lead } + let mean = under.map(\.totalMinor).reduce(0, +) / Double(under.count) + return mean < 0 ? PlanWord.whyPaid.format(lead) : PlanWord.whyCheap.format(lead, price(mean)) + default: + return lead } } @@ -489,20 +561,28 @@ enum PlanWord: String, CaseIterable { case cheapPower = "Cheapest power" case planned = "Planned %@" case planFrom = "Plan from %@ · open FTW" - // Now's strip: the next thing and the one after it. - case storesUntil = "Battery stores until %@" - case givesUntil = "Battery gives power until %@" - case carUntil = "Car charges until %@" - case storesAt = "Battery stores %@" - case givesAt = "Battery gives power %@" - case carAt = "Car charges %@" + // Now's strip and the story under the dial: the verb between marks + // (*) takes the colour of what it says. + case storesUntil = "Battery *stores* until %@" + case givesUntil = "Battery *gives power* until %@" + case carUntil = "Car *charges* until %@" + case storesAt = "Battery *stores* %@" + case givesAt = "Battery *gives power* %@" + case carAt = "Car *charges* %@" case toPercent = "%@, to %@" case sentence = "%@." - case thenStores = "%@, then stores %@." - case thenGives = "%@, then gives power %@." - case thenBatteryStores = "%@, then the battery stores %@." - case thenBatteryGives = "%@, then the battery gives power %@." - case thenCar = "%@, then the car charges %@." + case thenStores = "Then *stores* %@" + case thenGives = "Then *gives power* %@" + case thenBatteryStores = "Then the battery *stores* %@" + case thenBatteryGives = "Then the battery *gives power* %@" + case thenCar = "Then the car *charges* %@" + // Why: what drives the move. + case whyDear = "%@, when power costs up to %@" + case whyCheap = "%@ for %@" + case whyPaid = "%@, when you are paid to use power" + case whySun = "%@ from spare sun" + case whySell = "%@, as selling pays" + case whyLimit = "%@ to hold the grid limit" // The strip's header and what a finger reads on it. case next12h = "NEXT 12 H" case plannedAt = "PLANNED %@" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index 64d1b38..c9ee627 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -22,20 +22,26 @@ "Planned %@" = "Planned %@"; "Plan from %@ · open FTW" = "Plan from %@ · open FTW"; -/* Now's strip: the next thing and the one after it. */ -"Battery stores until %@" = "Battery stores until %@"; -"Battery gives power until %@" = "Battery gives power until %@"; -"Car charges until %@" = "Car charges until %@"; -"Battery stores %@" = "Battery stores %@"; -"Battery gives power %@" = "Battery gives power %@"; -"Car charges %@" = "Car charges %@"; +/* Now's strip and the story under the dial: the verb between marks (*) takes its colour. */ +"Battery *stores* until %@" = "Battery *stores* until %@"; +"Battery *gives power* until %@" = "Battery *gives power* until %@"; +"Car *charges* until %@" = "Car *charges* until %@"; +"Battery *stores* %@" = "Battery *stores* %@"; +"Battery *gives power* %@" = "Battery *gives power* %@"; +"Car *charges* %@" = "Car *charges* %@"; "%@, to %@" = "%@, to %@"; "%@." = "%@."; -"%@, then stores %@." = "%@, then stores %@."; -"%@, then gives power %@." = "%@, then gives power %@."; -"%@, then the battery stores %@." = "%@, then the battery stores %@."; -"%@, then the battery gives power %@." = "%@, then the battery gives power %@."; -"%@, then the car charges %@." = "%@, then the car charges %@."; +"Then *stores* %@" = "Then *stores* %@"; +"Then *gives power* %@" = "Then *gives power* %@"; +"Then the battery *stores* %@" = "Then the battery *stores* %@"; +"Then the battery *gives power* %@" = "Then the battery *gives power* %@"; +"Then the car *charges* %@" = "Then the car *charges* %@"; +"%@, when power costs up to %@" = "%@, when power costs up to %@"; +"%@ for %@" = "%@ for %@"; +"%@, when you are paid to use power" = "%@, when you are paid to use power"; +"%@ from spare sun" = "%@ from spare sun"; +"%@, as selling pays" = "%@, as selling pays"; +"%@ to hold the grid limit" = "%@ to hold the grid limit"; /* The strip's header and what a finger reads on it. */ "NEXT 12 H" = "NEXT 12 H"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index b454da7..e3d7e53 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -22,20 +22,26 @@ "Planned %@" = "Planerad %@"; "Plan from %@ · open FTW" = "Plan från %@ · öppna FTW"; -/* Now's strip: the next thing and the one after it. */ -"Battery stores until %@" = "Batteriet lagrar till %@"; -"Battery gives power until %@" = "Batteriet ger ström till %@"; -"Car charges until %@" = "Bilen laddar till %@"; -"Battery stores %@" = "Batteriet lagrar %@"; -"Battery gives power %@" = "Batteriet ger ström %@"; -"Car charges %@" = "Bilen laddar %@"; +/* Now's strip and the story under the dial: the verb between marks (*) takes its colour. */ +"Battery *stores* until %@" = "Batteriet *lagrar* till %@"; +"Battery *gives power* until %@" = "Batteriet *ger ström* till %@"; +"Car *charges* until %@" = "Bilen *laddar* till %@"; +"Battery *stores* %@" = "Batteriet *lagrar* %@"; +"Battery *gives power* %@" = "Batteriet *ger ström* %@"; +"Car *charges* %@" = "Bilen *laddar* %@"; "%@, to %@" = "%@, mål %@"; "%@." = "%@."; -"%@, then stores %@." = "%@, lagrar sedan %@."; -"%@, then gives power %@." = "%@, ger sedan ström %@."; -"%@, then the battery stores %@." = "%@, sedan lagrar batteriet %@."; -"%@, then the battery gives power %@." = "%@, sedan ger batteriet ström %@."; -"%@, then the car charges %@." = "%@, sedan laddar bilen %@."; +"Then *stores* %@" = "*Lagrar* sedan %@"; +"Then *gives power* %@" = "*Ger* sedan *ström* %@"; +"Then the battery *stores* %@" = "Sedan *lagrar* batteriet %@"; +"Then the battery *gives power* %@" = "Sedan *ger* batteriet *ström* %@"; +"Then the car *charges* %@" = "Sedan *laddar* bilen %@"; +"%@, when power costs up to %@" = "%@, när elen kostar upp till %@"; +"%@ for %@" = "%@ för %@"; +"%@, when you are paid to use power" = "%@, när du får betalt för att använda el"; +"%@ from spare sun" = "%@ med solens överskott"; +"%@, as selling pays" = "%@ när det lönar sig att sälja"; +"%@ to hold the grid limit" = "%@ för att hålla nätgränsen"; /* The strip's header and what a finger reads on it. */ "NEXT 12 H" = "NÄSTA 12 H"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 8761fe9..053ffdd 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -101,22 +101,60 @@ import Testing // MARK: The sentence under the strip - @Test func theSentenceSaysTheNextThingAndWhatFollows() { + /// The next thing and why, then the one after it and why: the price + /// that drives it, or the car's goal. + @Test func theSentenceSaysTheNextThingWhyAndWhatFollows() { func next(_ t: Double) -> String { PlanGlance.live(glance.night(), nowMs: t, calendar: calendar).nextText } - // Under way: until when, then the next move. - #expect(next(at(12, 20, 5)) == "Battery gives power until 21:15, then stores 22:00.") + // Under way: until when and why, then the next move and why. + #expect(next(at(12, 20, 5)) == "Battery gives power until 21:15, when power costs up to 160 öre. Then stores 22:00 for 70.0 öre.") // To come: when it runs; the next subject is named. - #expect(next(at(12, 21, 30)) == "Battery stores 22:00–23:30, then the car charges 01:00.") - #expect(next(at(12, 23, 45)) == "Car charges 01:00–04:45, to 80\u{00A0}%, then the battery gives power 06:00.") - #expect(next(at(13, 2)) == "Car charges until 04:45, to 80\u{00A0}%, then the battery gives power 06:00.") + #expect(next(at(12, 21, 30)) == "Battery stores 22:00–23:30 for 70.0 öre. Then the car charges 01:00, to 80\u{00A0}%.") + #expect(next(at(12, 23, 45)) == "Car charges 01:00–04:45, to 80\u{00A0}%. Then the battery gives power 06:00, when power costs up to 160 öre.") + #expect(next(at(13, 2)) == "Car charges until 04:45, to 80\u{00A0}%. Then the battery gives power 06:00, when power costs up to 160 öre.") // The last thing in the plan stands alone. - #expect(next(at(13, 7)) == "Battery gives power until 08:00.") + #expect(next(at(13, 7)) == "Battery gives power until 08:00, when power costs up to 160 öre.") } @Test func anOldPlanNeverSaysUntil() { let m = PlanGlance.kept(glance.night(), readAtMs: at(12, 18), nowMs: at(12, 20, 5), calendar: calendar) #expect(m.state == .old) - #expect(m.nextText == "Battery gives power 20:05–21:15, then stores 22:00.") + #expect(m.nextText == "Battery gives power 20:05–21:15, when power costs up to 160 öre. Then stores 22:00 for 70.0 öre.") + } + + /// Each reason says what drives the move; one with nothing to show + /// leaves the clause bare. + @Test func theReasonIsWhatDrivesTheMove() { + let entry = glance.night() + let clock = Clock(calendar: calendar) + func said(_ kind: PlanGlance.Kind, _ reason: String, from: Double = at(12, 22), to: Double = at(12, 23)) -> String { + let e = PlanGlance.Event(kind: kind, startMs: from, endMs: to, reason: reason) + return PlanGlance.clause(e, entry: entry, from: at(12, 20), clock: clock, ongoingIsNow: true).plain + } + #expect(said(.charge, "solar_surplus") == "Battery stores 22:00–23:00 from spare sun.") + #expect(said(.discharge, "export_paid") == "Battery gives power 22:00–23:00, as selling pays.") + #expect(said(.discharge, "peak_shaving") == "Battery gives power 22:00–23:00 to hold the grid limit.") + #expect(said(.charge, "cheap_import", from: at(13, 2), to: at(13, 4)) == "Battery stores 02:00–04:00 for 30.0 öre.") + // No price under it: no reason with a number. + #expect(said(.discharge, "expensive_import", from: at(14, 2), to: at(14, 3)) == "Battery gives power 02:00–03:00.") + var paid = entry + paid.prices = paid.prices.map { PriceSlot(startMs: $0.startMs, durationMs: $0.durationMs, spotMinor: -20, totalMinor: -5) } + let e = PlanGlance.Event(kind: .charge, startMs: at(12, 22), endMs: at(12, 23), reason: "cheap_import") + #expect(PlanGlance.clause(e, entry: paid, from: at(12, 20), clock: clock, ongoingIsNow: true).plain == "Battery stores 22:00–23:00, when you are paid to use power.") + } + + /// Under the dial: the battery's next two moves and the car's charge, in + /// order, each with its verb marked for its colour. + @Test func theStoryUnderTheDial() { + let m = PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar) + #expect(m.story.map(\.kind) == [.discharge, .charge, .car]) + #expect(m.story.map(\.plain) == [ + "Battery gives power until 21:15, when power costs up to 160 öre.", + "Battery stores 22:00–23:30 for 70.0 öre.", + "Car charges 01:00–04:45, to 80\u{00A0}%.", + ]) + #expect(m.story[0].parts == ["Battery ", "gives power", " until 21:15, when power costs up to 160 öre."]) + // Nothing to tell before the box has answered. + #expect(PlanGlance.waiting(glance.night(), nowMs: at(12, 20), calendar: calendar).story.isEmpty) } @Test func theSentenceBeforeAndWithoutAPlan() {