From 87e9e779e8a5ada14d21ace8f529ed48b90fdc13 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:35:51 +0200 Subject: [PATCH 01/14] 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 2a95f5c3..68cbb984 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 d36b564c..7948638e 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 4c634461..a5a63eed 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 64d1b38a..c9ee627a 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 b454da72..e3d7e537 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 8761fe92..053ffddd 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() { From b72ec48a442ce07f8289a399af8cfb614c770716 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 19:40:43 +0200 Subject: [PATCH 02/14] iPhone: the plan says where the battery ends up (plan pass 2) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanView.swift | 21 ++++++- .../Sources/FTWDemo/SimulatedHistory.swift | 2 +- .../Sources/FTWKit/Protocol/HistoryPlan.swift | 10 +++- .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWKit/State/PlanModel.swift | 5 +- .../Sources/FTWKit/State/PlanShare.swift | 14 ++++- .../Sources/FTWShared/Charts/PlanDial.swift | 13 +++- .../Sources/FTWShared/Charts/PlanStrip.swift | 8 ++- .../FTWKit/Sources/FTWShared/PlanGlance.swift | 60 ++++++++++++++++--- .../FTWShared/Resources/en.lproj/Plan.strings | 2 + .../FTWShared/Resources/sv.lproj/Plan.strings | 2 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 23 +++++++ .../Tests/FTWSharedTests/PlanDialTests.swift | 15 +++++ .../Tests/FTWSharedTests/PlanStripTests.swift | 46 ++++++++++++++ 14 files changed, 199 insertions(+), 23 deletions(-) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 7948638e..e47b1cc8 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -138,7 +138,7 @@ private struct PlanBody: View { PlanLanes(moment: moment) .equatable() .frame(height: 140) - Legend(kinds: Set(moment.events.map(\.kind))) + Legend(kinds: Set(moment.events.map(\.kind)), level: !moment.levels.isEmpty) } } .transition(.opacity) @@ -232,6 +232,7 @@ private final class DialMemo { /// What each colour on the lanes means. private struct Legend: View { let kinds: Set + var level = false var body: some View { ViewThatFits(in: .horizontal) { @@ -248,6 +249,17 @@ private struct Legend: View { if kinds.contains(.charge) { item(.charge, L("charging")) } if kinds.contains(.discharge) { item(.discharge, L("discharging")) } if kinds.contains(.cheap) { item(.cheap, L("Cheapest")) } + if level { + HStack(spacing: 4) { + Path { p in + p.move(to: CGPoint(x: 0, y: 3)) + p.addLine(to: CGPoint(x: 12, y: 3)) + } + .stroke(Theme.ink2, style: StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round, dash: [3, 3])) + .frame(width: 12, height: 6) + Text(L("battery level")).lineLimit(1) + } + } } private func item(_ kind: PlanGlance.Kind, _ text: String) -> some View { @@ -472,6 +484,13 @@ struct PlanLanes: View, Equatable { } } } + // Where the plan leads the battery: a ghost, dashed, because it + // is a plan and not a reading. 0 % at the foot, 100 % at the top. + ForEach(moment.levels.filter { $0.ms >= from && $0.ms <= to }, id: \.ms) { p in + LineMark(x: .value("Time", date(p.ms)), y: .value("Level", p.pct / 100), series: .value("Series", "level")) + .foregroundStyle(grey ? Theme.fgMuted : Theme.ink2) + .lineStyle(StrokeStyle(lineWidth: 1.25, lineCap: .round, lineJoin: .round, dash: [3, 4])) + } RuleMark(x: .value("Now", date(from))) .foregroundStyle(Theme.accentFill) .lineStyle(StrokeStyle(lineWidth: 1.5)) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 5be873cb..f72197a6 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -215,7 +215,7 @@ extension SimulatedBox { let plan = Plan( rev: house.planRev, uptimeMs: uptimeMs, - slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason) }, + slots: steps.map { PlanSlot(startMs: $0.startMs.rounded(), durationMs: $0.durationMs.rounded(), batteryW: $0.batteryW, gridW: $0.gridW, priceMinor: $0.priceMinor, reason: $0.reason, socPermille: ($0.soc * 1000).rounded()) }, stale: false, ceilingW: nil ) diff --git a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift index d7560b07..ddeea0d4 100644 --- a/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift +++ b/appleApp/FTWKit/Sources/FTWKit/Protocol/HistoryPlan.swift @@ -135,15 +135,19 @@ public struct PlanSlot: Equatable, Sendable, Identifiable, Codable { public var priceMinor: Double? /// A stable code, never prose. The app owns every word. public var reason: String + /// The battery's planned level at the end of the slot, in thousandths. + /// Nil from a box that does not send it. + public var socPermille: Double? public var id: Double { startMs } - public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String) { + public init(startMs: Double, durationMs: Double, batteryW: Double, gridW: Double, priceMinor: Double?, reason: String, socPermille: Double? = nil) { self.startMs = startMs self.durationMs = durationMs self.batteryW = batteryW self.gridW = gridW self.priceMinor = priceMinor self.reason = reason + self.socPermille = socPermille } public var endMs: Double { startMs + durationMs } @@ -180,7 +184,8 @@ public struct Plan: Equatable, Sendable, Codable { batteryW: s["batteryW"]?.double ?? 0, gridW: s["gridW"]?.double ?? 0, priceMinor: s["priceMinor"]?.double, - reason: s["reason"]?.string ?? "idle" + reason: s["reason"]?.string ?? "idle", + socPermille: s["socPermille"]?.double ) } stale = c["stale"]?.bool ?? false @@ -196,6 +201,7 @@ public struct Plan: Equatable, Sendable, Codable { ("batteryW", .ms(s.batteryW)), ("gridW", .ms(s.gridW)), ("reason", .text(s.reason)), ] slot.append(("priceMinor", s.priceMinor.map { .ms($0) } ?? .null)) + if let soc = s.socPermille { slot.append(("socPermille", .ms(soc))) } return .map(slot) })), ("stale", .bool(stale)), diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 652a2e22..6228584e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -138,6 +138,7 @@ public enum NodeWords { // The plan view "Opens the plan.": "Öppnar planen.", "Dial": "Tavla", + "battery level": "batteriets nivå", "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/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index 46f785fd..d3165c63 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -22,6 +22,8 @@ public final class PlanModel { public let pvW: Double public let loadW: Double public let priceMinor: Double? + /// The battery's planned level at the end of the slot, in percent. + public var levelPct: Double? = nil public var id: Double { startMs } } @@ -146,7 +148,8 @@ public final class PlanModel { let end = slotStart + len * 60_000 let pv = a["forecast_pv_w"]?.number ?? a["pv_w"]?.number ?? 0 let load = a["forecast_load_w"]?.number ?? a["load_w"]?.number ?? 0 - return Forecast(startMs: start, durationMs: max(0, end - start), pvW: abs(pv), loadW: max(0, load), priceMinor: a["price_ore"]?.number) + let soc = a["soc"]?.number.flatMap { $0.isFinite && $0 >= 0 && $0 <= 1 ? $0 * 100 : nil } + return Forecast(startMs: start, durationMs: max(0, end - start), pvW: abs(pv), loadW: max(0, load), priceMinor: a["price_ore"]?.number, levelPct: soc) } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 28666c38..955001a2 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -6,8 +6,15 @@ extension PlanEntry { /// car's charging windows with the level it charges toward, and the /// prices under them. Prices come from the price model, or else from the /// plan's own slots. - public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil, daylight: Daylight? = nil) -> PlanEntry { - let battery = plan.map { p in p.slots.map { Slot(startMs: $0.startMs, durationMs: $0.durationMs, batteryW: $0.batteryW, reason: $0.reason) } } + /// `levels` stands in for a box that sends no level on its plan: the + /// planner's own document's levels, by the slot they end. + public static func live(plan: Plan?, points: [Loadpoint], windows: [String: [Loadpoint.Window]], prices: Prices?, timeZone: String? = nil, daylight: Daylight? = nil, levels: [(startMs: Double, endMs: Double, pct: Double)] = []) -> PlanEntry { + func level(_ s: PlanSlot) -> Double? { + if let pm = s.socPermille, pm.isFinite, pm >= 0, pm <= 1000 { return pm / 10 } + let end = s.startMs + s.durationMs + return levels.first { $0.startMs < end && end <= $0.endMs + 1 }?.pct + } + let battery = plan.map { p in p.slots.map { Slot(startMs: $0.startMs, durationMs: $0.durationMs, batteryW: $0.batteryW, reason: $0.reason, levelPct: level($0)) } } var charges = [Charge]() for lp in points { for w in windows[lp.id] ?? lp.planWindows { @@ -66,7 +73,8 @@ public final class PlanShare { /// The plan as Now and the plan dial draw it, with the sun the box /// forecasts once the app has read the forecast. public var entry: PlanEntry { - PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight) + PlanEntry.live(plan: plan.plan, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight, + levels: plan.forecast.compactMap { f in f.levelPct.map { (f.startMs, f.startMs + f.durationMs, $0) } }) } /// The box's solar forecast as the dial's sky, from the box's latest diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index 07329a16..f4aadd84 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -36,6 +36,9 @@ public struct PlanDial: Equatable, Sendable { public let car: Bool /// The goal, on the car's last quarter of a window. public let carGoalPct: Double? + /// Where the plan leaves the battery by the quarter's end, in + /// percent. Nil when the box did not say. + public let levelPct: Double? } public let grid: SlotGrid @@ -90,7 +93,7 @@ public struct PlanDial: Equatable, Sendable { return next < items.count && items[next][keyPath: start] < b ? items[next] : nil } func price(_ q: Int) -> Double? { holding(slots, q, start: \.startMs, length: \.durationMs)?.totalMinor } - func batteryW(_ q: Int) -> Double? { holding(battery, q, start: \.startMs, length: \.durationMs)?.batteryW } + func planned(_ q: Int) -> PlanEntry.Slot? { holding(battery, q, start: \.startMs, length: \.durationMs) } let minors = (0.. PlanGlance.idleW ? .stores : ($0 < -PlanGlance.idleW ? .gives : .waits) } ?? .waits return Quarter( startMs: start, @@ -112,7 +116,8 @@ public struct PlanDial: Equatable, Sendable { battery: action, batteryW: w, car: charges.contains { $0.fromMs < end && $0.toMs > start }, - carGoalPct: goals[i] + carGoalPct: goals[i], + levelPct: slot?.levelPct ) } @@ -159,6 +164,7 @@ public struct PlanDial: Equatable, Sendable { case (.gives, let w?): parts.append(DialWord.gives.format(Words.decimal(PowerFormat.text(w)).uppercased())) case (.waits, _): parts.append(DialWord.waits.text) } + if let level = q.levelPct { parts.append(PlanGlance.percent(level)) } if let goal = q.carGoalPct { parts.append(DialWord.carTo.format(PlanGlance.percent(goal))) } else if q.car { @@ -256,6 +262,7 @@ public struct PlanDial: Equatable, Sendable { case (.gives, let w?): parts.append("\(WatchWord.batteryGives.text) \(Words.decimal(PowerFormat.text(w)))") case (.waits, _): parts.append(WatchWord.batteryWaits.text) } + if let level = q.levelPct { parts.append(PlanGlance.percent(level)) } if let goal = q.carGoalPct { parts.append(WatchWord.carGoal.format(PlanGlance.percent(goal))) } else if q.car { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index 5e68cae2..fddce9d5 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -49,8 +49,8 @@ public struct PlanStrip: Equatable, Sendable { /// Quarters that give a light impact under a finger: now, either end, /// the cheapest two hours' edges and each car goal. public let landmarks: Set - /// What a finger reads per quarter: "02:15 · 31 öre · stores · car - /// charges". + /// What a finger reads per quarter: "02:15 · 31 öre · stores · 58 % · + /// car charges". public let readouts: [String] /// "NEXT 12 H", "PLANNED 14:05" from the cache, "OLD PLAN · 14:05". public let kicker: String @@ -140,12 +140,14 @@ public struct PlanStrip: Equatable, Sendable { } else { parts.append(PlanWord.noPrice.text) } - if planned[i] != nil { + if let slot = planned[i] { switch battery[i] { case .stores?: parts.append(PlanWord.stores.text) case .gives?: parts.append(PlanWord.gives.text) case nil: parts.append(PlanWord.waits.text) } + // Where the plan leaves the battery by the quarter's end. + if let level = slot.levelPct { parts.append(PlanGlance.percent(level)) } } if let goal = goals[i] { parts.append(PlanWord.carGoal.format(PlanGlance.percent(goal))) diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index a5a63eed..4e2e07bd 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -16,12 +16,16 @@ public struct PlanEntry: CacheValue { public var batteryW: Double /// The box's reason code, never prose. public var reason: String + /// The level the plan leads the battery to by the end of the slot, + /// in percent. Nil when the box did not say. + public var levelPct: Double? - public init(startMs: Double, durationMs: Double, batteryW: Double, reason: String) { + public init(startMs: Double, durationMs: Double, batteryW: Double, reason: String, levelPct: Double? = nil) { self.startMs = startMs self.durationMs = durationMs self.batteryW = batteryW self.reason = reason + self.levelPct = levelPct } } @@ -143,9 +147,11 @@ public enum PlanGlance { public let energyWh: Double? /// The box's reason code for the battery's first slot in it. public let reason: String? + /// Where the battery's run leaves it, in percent, when the box said. + public let endLevelPct: Double? public var id: String { "\(kind.rawValue)-\(Int64(startMs))" } - public init(kind: Kind, startMs: Double, endMs: Double, peakW: Double? = nil, targetPct: Double? = nil, energyWh: Double? = nil, reason: String? = nil) { + public init(kind: Kind, startMs: Double, endMs: Double, peakW: Double? = nil, targetPct: Double? = nil, energyWh: Double? = nil, reason: String? = nil, endLevelPct: Double? = nil) { self.kind = kind self.startMs = startMs self.endMs = endMs @@ -153,6 +159,7 @@ public enum PlanGlance { self.targetPct = targetPct self.energyWh = energyWh self.reason = reason + self.endLevelPct = endLevelPct } } @@ -176,7 +183,7 @@ public enum PlanGlance { peak = max(peak, abs(slots[j].batteryW)) } if let kind, slots[i].startMs < end { - out.append(Event(kind: kind, startMs: max(t, slots[i].startMs), endMs: slots[j].startMs + slots[j].durationMs, peakW: peak, reason: slots[i].reason)) + out.append(Event(kind: kind, startMs: max(t, slots[i].startMs), endMs: slots[j].startMs + slots[j].durationMs, peakW: peak, reason: slots[i].reason, endLevelPct: slots[j].levelPct)) } i = j + 1 } @@ -251,6 +258,9 @@ public enum PlanGlance { /// 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 battery's planned level at the end of each slot ahead, for a + /// ghost line over the lanes. Empty when the box did not say. + public let levels: [LevelPoint] /// The zone the clock in this moment is read in. public let timeZone: TimeZone @@ -278,7 +288,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, story: [], timeZone: calendar.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: PlanWord.openToSee.text, story: [], levels: [], timeZone: calendar.timeZone) } let old = t - read.record.readAtMs >= staleAfterMs let state: State = read.value.boxStale || read.srcState == .stale ? .boxStale : (old ? .old : .plan) @@ -303,7 +313,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, story: [], timeZone: m.timeZone) + next: nil, nextWhen: nil, nextWhat: nil, nextUntil: nil, cheapText: nil, laterText: nil, ageText: nil, nextText: note, story: [], levels: m.levels, timeZone: m.timeZone) } private static func moment(_ entry: PlanEntry, state: State, readAtMs: Double?, atMs t: Double, calendar: Calendar, inApp: Bool = false) -> Moment { @@ -363,6 +373,8 @@ public enum PlanGlance { ageText: age, nextText: nextText, story: state == .plan || state == .old ? story(list, entry: entry, from: t, clock: clock, ongoingIsNow: state == .plan) : [], + levels: (entry.battery ?? []).filter { $0.startMs + $0.durationMs > t && $0.startMs < t + horizonMs } + .compactMap { s in s.levelPct.map { LevelPoint(ms: s.startMs + s.durationMs, pct: $0) } }, timeZone: calendar.timeZone ) } @@ -392,6 +404,12 @@ public enum PlanGlance { return parts.joined(separator: " · ") } + /// The battery's planned level at a moment, in percent. + public struct LevelPoint: Equatable, Sendable { + public let ms: Double + public let pct: Double + } + /// 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 @@ -417,7 +435,7 @@ public enum PlanGlance { 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 } - var text = clause(first, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow).plain + var text = clause(first, entry: entry, from: t, clock: clock, ongoingIsNow: ongoingIsNow, level: isNews(first, entry: entry, from: t)).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 @@ -443,8 +461,25 @@ public enum PlanGlance { .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 { + /// Whether where a battery run leaves the battery is news on a line + /// with no room for more: a run that empties it to the lowest point of + /// the hours ahead, or one that fills it. + static func isNews(_ e: Event, entry: PlanEntry, from t: Double) -> Bool { + guard let end = e.endLevelPct else { return false } + switch e.kind { + case .charge: + return end >= 98 + case .discharge: + let ahead = (entry.battery ?? []).filter { $0.startMs + $0.durationMs > t && $0.startMs < t + horizonMs }.compactMap(\.levelPct) + return ahead.min().map { end <= $0 + 2 } ?? false + case .car, .cheap: + return false + } + } + + /// One event as a sentence with its verb marked and its reason, and + /// for a battery run where it leaves the battery. + static func clause(_ e: Event, entry: PlanEntry, from t: Double, clock: Clock, ongoingIsNow: Bool, level: Bool = true) -> 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. @@ -456,7 +491,11 @@ public enum PlanGlance { 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) } - return Phrase(kind: e.kind, marked: PlanWord.sentence.format(why(e, lead, entry: entry))) + var text = why(e, lead, entry: entry) + if level, let pct = e.endLevelPct, e.kind == .charge || e.kind == .discharge { + text = (e.kind == .charge ? PlanWord.levelUp : PlanWord.levelDown).format(text, percent(pct)) + } + return Phrase(kind: e.kind, marked: PlanWord.sentence.format(text)) } /// The box's reason, said as what drives it: the price when power is @@ -583,6 +622,9 @@ enum PlanWord: String, CaseIterable { case whySun = "%@ from spare sun" case whySell = "%@, as selling pays" case whyLimit = "%@ to hold the grid limit" + // Where a battery run leaves the battery. + case levelDown = "%@, and leaves %@" + case levelUp = "%@, and reaches %@" // 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 c9ee627a..d8be3e3e 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -42,6 +42,8 @@ "%@ from spare sun" = "%@ from spare sun"; "%@, as selling pays" = "%@, as selling pays"; "%@ to hold the grid limit" = "%@ to hold the grid limit"; +"%@, and leaves %@" = "%@, and leaves %@"; +"%@, and reaches %@" = "%@, and reaches %@"; /* 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 e3d7e537..39444893 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -42,6 +42,8 @@ "%@ 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"; +"%@, and leaves %@" = "%@, och lämnar %@"; +"%@, and reaches %@" = "%@, och når %@"; /* The strip's header and what a finger reads on it. */ "NEXT 12 H" = "NÄSTA 12 H"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 125c6b33..98b2740c 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -500,9 +500,32 @@ import FTWShared #expect(plan.slots.count > 40) #expect((plan.slots.first?.startMs ?? .infinity) <= rig.scheduler.nowMs) #expect(plan.slots.allSatisfy { $0.priceMinor != nil && PlanText.reasons[$0.reason] != nil }) + // The box says where each slot leaves the battery, in thousandths. + #expect(plan.slots.allSatisfy { ($0.socPermille ?? -1) >= 0 && ($0.socPermille ?? 2_000) <= 1_000 }) #expect(rig.session.state.plan == plan) } + /// The level travels on the wire when the box sends it, and stays nil + /// from a box that does not. + @Test func theLevelRoundTripsOnTheWire() { + let slot = PlanSlot(startMs: 1_000, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import", socPermille: 624) + let plan = Plan(rev: 3, uptimeMs: 0, slots: [slot, PlanSlot(startMs: 901_000, durationMs: 900_000, batteryW: 0, gridW: 300, priceMinor: 90, reason: "idle")], stale: false, ceilingW: nil) + let back = Plan(plan.wire) + #expect(back.slots[0].socPermille == 624) + #expect(back.slots[1].socPermille == nil) + } + + /// The plan's entry takes the level from the wire, and from the + /// planner's own document for a box that sends none on its plan. + @Test func theEntryTakesTheLevelFromTheWireOrThePlanner() { + let a = PlanSlot(startMs: 0, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import", socPermille: 624) + let b = PlanSlot(startMs: 900_000, durationMs: 900_000, batteryW: -1_200, gridW: 0, priceMinor: 160, reason: "expensive_import") + let plan = Plan(rev: 1, uptimeMs: 0, slots: [a, b], stale: false, ceilingW: nil) + let entry = PlanEntry.live(plan: plan, points: [], windows: [:], prices: nil, levels: [(startMs: 900_000, endMs: 1_800_000, pct: 58)]) + #expect(entry.battery?.map(\.levelPct) == [62.4, 58]) + #expect(PlanEntry.live(plan: plan, points: [], windows: [:], prices: nil).battery?.map(\.levelPct) == [62.4, nil]) + } + @Test func theBatteryInOneSentence() { let h = 3_600_000.0 let t = 1_791_799_200_000.0 diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index f5268f9f..c563dc6e 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -119,6 +119,21 @@ import Testing #expect(d.quarters[0].minor == 160) } + /// The middle and the Crown say where the plan leaves the battery. + @Test func theMiddleSaysWhereTheBatteryIs() { + var plan = watch.plan() + plan.battery = plan.battery?.enumerated().map { i, s in + var s = s + s.levelPct = 50 + Double(i % 40) + return s + } + let d = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + let ten = d.grid.index(atMs: at(14, 22))! + #expect(d.quarters[ten].levelPct == 50 + Double(ten % 40)) + #expect(d.doing[ten] == "STORES · 3.0 KW · \(50 + ten % 40)\u{00A0}%") + #expect(d.what(ten) == "Battery stores 3.0 kW · \(50 + ten % 40)\u{00A0}%") + } + @Test func thePlannedLine() { let zone = TimeZone(identifier: WatchTests.zone)! #expect(PlanDial.planned(atMs: at(14, 16, 51), old: false, zone: zone) == "PLANNED 16:51 · REPLANS EVERY 15 MIN") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 053ffddd..9cdc3e17 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -184,4 +184,50 @@ import Testing #expect(s.readouts[0] == "20:00 · 160 öre · gives power") #expect(s.batteryRuns.first == .init(first: 0, count: 5, value: .gives)) } + + // MARK: Where the plan leaves the battery + + /// `night()` with levels: 80 % falling to 62 % by 21:15, rising to 100 % + /// by 23:30, waiting, then falling to 52 % by 08:00. + func withLevels() -> PlanEntry { + var entry = glance.night() + var level = 80.0 + entry.battery = entry.battery?.map { s in + var s = s + level = s.batteryW > 100 ? min(100, level + 7) : (s.batteryW < -100 ? max(20, level - (s.startMs >= at(13, 6) ? 6 : 3.6)) : level) + s.levelPct = level + return s + } + return entry + } + + @Test func aFingerReadsWhereTheBatteryIs() { + let entry = withLevels() + let s = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + #expect(s.readouts[0] == "20:00 · 160 öre · gives power · 76\u{00A0}%") + #expect(s.readouts[13] == "23:15 · 70.0 öre · stores · 100\u{00A0}%") + } + + /// Under the dial each battery move says where it leaves the battery; + /// on Now only when that is the news: the lowest point ahead, or full. + @Test func theLevelIsSaidWhereItMatters() { + let entry = withLevels() + let m = PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar) + #expect(m.story.map(\.plain) == [ + "Battery gives power until 21:15, when power costs up to 160 öre, and leaves 62\u{00A0}%.", + "Battery stores 22:00–23:30 for 70.0 öre, and reaches 100\u{00A0}%.", + "Car charges 01:00–04:45, to 80\u{00A0}%.", + ]) + // 62 % is not the lowest ahead (52 % by 08:00), so Now leaves it out. + #expect(m.nextText == "Battery gives power until 21:15, when power costs up to 160 öre. Then stores 22:00 for 70.0 öre.") + // Filling up is news. + #expect(PlanGlance.live(entry, nowMs: at(12, 22, 5), calendar: calendar).nextText.hasPrefix("Battery stores until 23:30 for 70.0 öre, and reaches 100\u{00A0}%.")) + // So is the run that empties it to the lowest point ahead. + #expect(PlanGlance.live(entry, nowMs: at(13, 6, 5), calendar: calendar).nextText == "Battery gives power until 08:00, when power costs up to 160 öre, and leaves 52\u{00A0}%.") + // The lanes' ghost line: one point at each slot's end ahead. + #expect(m.levels.first == PlanGlance.LevelPoint(ms: at(12, 20, 15), pct: 76.4)) + #expect(m.levels.count == 49) + // A plan with no levels says nothing about them. + #expect(PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar).levels.isEmpty) + } } From ee3727bd547f30542246f125a3ce4ad0b83c9be6 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:06:46 +0200 Subject: [PATCH 03/14] iPhone: the honest saving under the dial (plan pass 3) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanView.swift | 5 ++ .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 12 ++++ .../Sources/FTWKit/State/PlanModel.swift | 15 +++++ .../Sources/FTWShared/Charts/PlanDial.swift | 1 + .../FTWKit/Sources/FTWShared/PlanSaving.swift | 62 +++++++++++++++++++ .../FTWShared/Resources/en.lproj/Dial.strings | 1 + .../FTWShared/Resources/sv.lproj/Dial.strings | 1 + .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 13 ++++ .../FTWSharedTests/PlanSavingTests.swift | 44 +++++++++++++ 9 files changed, 154 insertions(+) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index e47b1cc8..5aa28d07 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -112,6 +112,11 @@ private struct PlanBody: View { if let cheapest = dial.cheapestText { Text(cheapest).foregroundStyle(Theme.ink2) } + // What the planner reckons the plan saves, only + // where it compares honestly. + if let saving = models.plan.saving?.text(currency: models.planShare.entry.currency, nowMs: minute, zone: dial.zone) { + Text(saving).foregroundStyle(Theme.ink2) + } Text(plannedLine(moment, zone: dial.zone)) .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.ink3) } diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 3264da55..df25efda 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -568,6 +568,18 @@ public final class SimulatedBox { return (200, ["scope": "system", "since_ms": .number(since), "until_ms": .number(until), "truncated": false, "points": .array(carEnergyRows(sinceMs: since, untilMs: until))]) case ("GET", "/api/mpc/plan"): var plan: JSON = ["actions": .array(mpcActions())] + // The planner's own reckoning: its cost and the same hours with + // no battery, imports at the price and exports at the spot. + let steps = house.plan(nowMs: now) + func cost(_ grid: (SimulatedHouse.Step) -> Double) -> Double { + steps.reduce(0) { sum, s in + let kWh = grid(s) / 1000 * s.durationMs / 3_600_000 + return sum + kWh * (kWh >= 0 ? s.priceMinor : s.priceMinor * 0.6) + } + } + plan = plan.setting("total_cost_ore", .number(cost { $0.gridW }.rounded())) + .setting("initial_soc", .number(house.socFraction)) + .setting("baselines", ["no_battery_ore": .number(cost { $0.gridW - $0.batteryW }.rounded())]) if let p = house.carPlan(now) { plan = plan.setting("loadpoint_shortfall_wh", ["garage": .number(p.shortfallWh)]) } return (200, ["plan": plan, "meta": ["replanning": false, "outdated": false]]) case ("POST", "/api/loadpoints/{id}/vehicle"): diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index d3165c63..88bc635e 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -48,6 +48,10 @@ public final class PlanModel { /// pushed. public private(set) var receivedAtMs: Double? public private(set) var problem: String? + /// What the planner reckons its plan saves against no battery, from its + /// own document, read with the forecast. Nil until then, and when the + /// planner gave no baseline. + public private(set) var saving: PlanSaving? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var ask: LiveAsk? @@ -137,10 +141,21 @@ public final class PlanModel { func loadForecast() async throws { let wire = try await site.callBox(.get, "/api/mpc/plan") let next = Stamped(Self.forecast(wire?["plan"]?["actions"]?.array ?? []), asOfMs: site.nowMs, origin: .relay) + let worth = Self.saving(wire?["plan"]) + if worth != saving { saving = worth } if let taken = Stamped.taking(next, over: forecastStamp) { forecastStamp = taken } if !forecastLoaded { forecastLoaded = true } } + /// The plan's cost, the same hours with no battery, and the levels at + /// either end, from the planner's document. + nonisolated static func saving(_ plan: JSON?) -> PlanSaving? { + guard let plan, let cost = plan["total_cost_ore"]?.number, let none = plan["baselines"]?["no_battery_ore"]?.number, + let start = plan["initial_soc"]?.number, let last = plan["actions"]?.array?.last, + let end = last["soc"]?.number, let slotStart = last["slot_start_ms"]?.number, let len = last["slot_len_min"]?.number else { return nil } + return PlanSaving(costMinor: cost, noBatteryMinor: none, startLevel: start, endLevel: end, endMs: slotStart + len * 60_000) + } + static func forecast(_ actions: [JSON]) -> [Forecast] { actions.compactMap { a in guard let start = a["execution_start_ms"]?.number ?? a["slot_start_ms"]?.number, diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index f4aadd84..89cb7557 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -287,6 +287,7 @@ enum DialWord: String, CaseIterable { case noPrice = "NO PRICE YET" case planned = "PLANNED %@ · REPLANS EVERY 15 MIN" case plannedOld = "PLAN FROM %@ · OLD" + case saving = "ABOUT %@ LESS THAN NO BATTERY · TO %@" case title = "Plan dial" case summary = "The next 24 hours round a dial: the price, what the battery does and when the car charges." diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift b/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift new file mode 100644 index 00000000..a10daffd --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PlanSaving.swift @@ -0,0 +1,62 @@ +import Foundation + +/// What the plan is worth, as the box's planner reckons it: its own cost for +/// the hours it covers against the cost of the same hours with no battery. +/// Said only when the plan ends about where it starts, because a plan that +/// ends emptier has spent stored energy and the plain difference would +/// flatter it. Never against self-consumption, whose baseline drains the +/// battery and so reads cheaper than it is. +public struct PlanSaving: Equatable, Sendable, Codable { + /// The plan's cost and the no-battery cost, minor units. + public var costMinor: Double + public var noBatteryMinor: Double + /// The battery's level when the plan starts and where it leaves it, 0 to 1. + public var startLevel: Double + public var endLevel: Double + /// Where the plan's hours end. + public var endMs: Double + + /// How far the end may sit from the start and still compare honestly. + public static let levelSlack = 0.05 + + public init(costMinor: Double, noBatteryMinor: Double, startLevel: Double, endLevel: Double, endMs: Double) { + self.costMinor = costMinor + self.noBatteryMinor = noBatteryMinor + self.startLevel = startLevel + self.endLevel = endLevel + self.endMs = endMs + } + + /// The saving in minor units, or nil when it cannot be said honestly: + /// the levels at either end differ, a number is missing, or there is no + /// saving to speak of. + public var savedMinor: Double? { + guard [costMinor, noBatteryMinor, startLevel, endLevel].allSatisfy(\.isFinite), + abs(endLevel - startLevel) <= Self.levelSlack + 1e-9 else { return nil } + let saved = noBatteryMinor - costMinor + return saved >= 100 ? saved : nil + } + + /// "ABOUT 28 KR LESS THAN NO BATTERY · TO THU 24:00", or nil. + public func text(currency: String, nowMs: Double, zone: TimeZone) -> String? { + guard let saved = savedMinor else { return nil } + let amount = "\(Int((saved / 100).rounded())) \(Self.major(currency))" + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = zone + let until = Clock(calendar: calendar).at(endMs, asEnd: true, from: nowMs).uppercased(with: Words.locale) + return DialWord.saving.format(amount, until) + } + + /// The major unit a hundred minor units make. + static func major(_ currency: String) -> String { + switch currency.uppercased() { + case "SEK", "NOK", "DKK": return "KR" + case "EUR": return "€" + case "PLN": return "ZŁ" + case "CZK": return "KČ" + case "HUF": return "FT" + case "RON": return "LEI" + default: return currency.uppercased() + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings index 0aab3778..ec653ca7 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Dial.strings @@ -10,3 +10,4 @@ "PLAN FROM %@ · OLD" = "PLAN FROM %@ · OLD"; "Plan dial" = "Plan dial"; "The next 24 hours round a dial: the price, what the battery does and when the car charges." = "The next 24 hours round a dial: the price, what the battery does and when the car charges."; +"ABOUT %@ LESS THAN NO BATTERY · TO %@" = "ABOUT %@ LESS THAN NO BATTERY · TO %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings index aa7e343b..854a64a5 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Dial.strings @@ -10,3 +10,4 @@ "PLAN FROM %@ · OLD" = "PLAN FRÅN %@ · GAMMAL"; "Plan dial" = "Plantavla"; "The next 24 hours round a dial: the price, what the battery does and when the car charges." = "De kommande 24 timmarna runt en tavla: priset, vad batteriet gör och när bilen laddar."; +"ABOUT %@ LESS THAN NO BATTERY · TO %@" = "CIRKA %@ BILLIGARE ÄN UTAN BATTERI · TILL %@"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 98b2740c..60007277 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -489,6 +489,19 @@ import FTWShared // MARK: The plan @Suite struct PlanTests { + /// What the plan saves comes from the planner's own document: its cost, + /// the no-battery baseline, and the levels at either end. + @Test func theSavingComesFromThePlannersDocument() { + let plan: JSON = [ + "total_cost_ore": 4350, "initial_soc": 1, + "baselines": ["no_battery_ore": 7168], + "actions": .array([["slot_start_ms": 1_000, "slot_len_min": 15, "soc": .number(0.98)]]), + ] + #expect(PlanModel.saving(plan) == PlanSaving(costMinor: 4350, noBatteryMinor: 7168, startLevel: 1, endLevel: 0.98, endMs: 901_000)) + // A plan without a baseline says nothing. + #expect(PlanModel.saving(["total_cost_ore": 4350, "initial_soc": 1]) == nil) + } + @MainActor @Test func thePlanArrivesAndLandsOnTheSession() async throws { let rig = Rig() diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift new file mode 100644 index 00000000..9f949e8b --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanSavingTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +@testable import FTWShared + +/// What the plan saves, said only where it compares honestly: the plan +/// ends about where it starts, there is a saving to speak of, and the +/// baseline is no battery at all. +@Suite struct PlanSavingTests { + let zone = TimeZone(identifier: "Europe/Stockholm")! + var calendar: Calendar { + var c = Calendar(identifier: .gregorian) + c.timeZone = zone + return c + } + + func at(_ day: Int, _ hour: Int) -> Double { + calendar.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour))!.timeIntervalSince1970 * 1000 + } + + /// The home box on 14 Oct: 43.50 kr against 71.69 kr with no battery, + /// full at both ends, to Thursday 24:00. + @Test func theHomeBoxsPlanSays28Kronor() { + let s = PlanSaving(costMinor: 4_350.49, noBatteryMinor: 7_168.53, startLevel: 1, endLevel: 1, endMs: at(16, 0)) + #expect(s.savedMinor.map { ($0 / 100).rounded() } == 28) + #expect(s.text(currency: "SEK", nowMs: at(14, 16), zone: zone) == "ABOUT 28 KR LESS THAN NO BATTERY · TO THU 24:00") + // Within the day, no day is named. + let soon = PlanSaving(costMinor: 4_350.49, noBatteryMinor: 7_168.53, startLevel: 1, endLevel: 1, endMs: at(14, 22)) + #expect(soon.text(currency: "EUR", nowMs: at(14, 16), zone: zone) == "ABOUT 28 € LESS THAN NO BATTERY · TO 22:00") + } + + /// A plan that ends emptier has spent stored energy; the plain + /// difference would flatter it, so nothing is said. + @Test func aPlanThatEndsEmptierSaysNothing() { + #expect(PlanSaving(costMinor: 1_954, noBatteryMinor: 7_168, startLevel: 1, endLevel: 0.32, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: 4_350, noBatteryMinor: 7_168, startLevel: 0.5, endLevel: 0.55, endMs: at(16, 0)).savedMinor != nil) + #expect(PlanSaving(costMinor: 4_350, noBatteryMinor: 7_168, startLevel: 0.5, endLevel: 0.56, endMs: at(16, 0)).savedMinor == nil) + } + + @Test func noSavingToSpeakOfSaysNothing() { + #expect(PlanSaving(costMinor: 7_100, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: 8_000, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + #expect(PlanSaving(costMinor: .nan, noBatteryMinor: 7_168, startLevel: 1, endLevel: 1, endMs: at(16, 0)).savedMinor == nil) + } +} From e7d9d5932938bc0ff98f71c9856225e375125896 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:32:35 +0200 Subject: [PATCH 04/14] Demo: leave the battery near its start by the end of the day So the plan's saving compares honestly and the plan view shows it. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index f72197a6..15e8ac75 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -132,6 +132,9 @@ extension SimulatedHouse { let mean = prices.reduce(0, +) / Double(max(1, prices.count)) let car = carPlan(nowMs) var soc = socFraction + // The demo leaves the battery near where it found it by the end of + // its day, so what its plan is worth compares honestly. + let endFloor = socFraction - 0.02 var out = [Step]() while t < end { let start = max(t, nowMs) @@ -144,6 +147,7 @@ extension SimulatedHouse { var b = decide(price: price, mean: mean, pv: pv, base: base, ev: ev, soc: soc, mode: modeKey) if b > 0, soc >= 0.98 { b = 0 } if b < 0, soc <= Self.minSoc { b = 0 } + if b < 0, t >= end - 8 * 3_600_000, soc <= endFloor { b = 0 } b = b.rounded() soc = max(0, min(1, soc + b * len / 3_600_000 * (b > 0 ? 0.95 : 1 / 0.95) / batteryCapacityWh)) let grid = (base + ev + b + pv).rounded() From 8bd5b62051c55ee13c599d3ed917213758360a71 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:12:30 +0200 Subject: [PATCH 05/14] iPhone: the plan shows what it knows and what it guesses (plan pass 4) Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/PlanView.swift | 14 ++++++++++++++ appleApp/FTWCharts/PlanDialFace.swift | 9 +++++++-- appleApp/FTWCharts/PlanStripDrawing.swift | 19 +++++++++++++++++++ .../Sources/FTWKit/State/NodeWords.swift | 1 + .../Sources/FTWShared/Charts/PlanStrip.swift | 4 ++++ .../Tests/FTWSharedTests/PlanStripTests.swift | 11 +++++++++++ 6 files changed, 56 insertions(+), 2 deletions(-) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 5aa28d07..274be160 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 d3e04e2b..cfca0c21 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 16ea1d31..e2045348 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/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 6228584e..3feb4441 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 fddce9d5..ca2d0bbc 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 9cdc3e17..1b576b23 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) + } } From ca21613436912372e66f386a45ff0156376db8c0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:40:46 +0200 Subject: [PATCH 06/14] Demo: flags to hold tomorrow's prices back and start on another style For pictures of the plan's unpriced stubs and its style line. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index df25efda..e51505d2 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. From 513cd12a1dfb118022b0038bf458206115400979 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:24:14 +0200 Subject: [PATCH 07/14] iPhone: the plan knows what a change moved (plan pass 5, logic) The plan on screen when a change leaves is kept beside the box's next plan, so the strip, the dial and a sentence can show only the quarters that moved. The style scale steps by fractions of one turn. Co-Authored-By: Claude Opus 5.5 --- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 2 + .../Sources/FTWKit/State/EnergyNode.swift | 1 + .../Sources/FTWKit/State/ModeModel.swift | 15 +++++++ .../Sources/FTWKit/State/PlanModel.swift | 34 ++++++++++++++++ .../Sources/FTWKit/State/PlanShare.swift | 8 ++++ .../Sources/FTWShared/Charts/PlanDial.swift | 7 ++++ .../Sources/FTWShared/Charts/PlanStrip.swift | 10 +++++ .../FTWKit/Sources/FTWShared/PlanGlance.swift | 39 +++++++++++++++++++ .../FTWShared/Resources/en.lproj/Plan.strings | 3 ++ .../FTWShared/Resources/sv.lproj/Plan.strings | 3 ++ .../FTWKit/Tests/FTWKitTests/NodeTests.swift | 35 +++++++++++++++++ .../Tests/FTWSharedTests/PlanDialTests.swift | 15 +++++++ .../Tests/FTWSharedTests/PlanStripTests.swift | 38 ++++++++++++++++++ 13 files changed, 210 insertions(+) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index e51505d2..34c3bfae 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -362,6 +362,8 @@ public final class SimulatedBox { house.modeKey = house.batteryExport == "allowed" ? "planner_arbitrage" : "planner_passive_arbitrage" } controlRev += 1 + // New prefs make a new plan, as Core replans on them. + house.planRev += 1 result(cmdId, "applied", observed: house.safetyK) sendBulk(Envelope(t: "plan", b: sessionPlan().wire)) case Contract.opLoadpointHold: diff --git a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift index ec7fa37b..9dbd1e9b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/EnergyNode.swift @@ -192,5 +192,6 @@ public final class NodeModels { planShare = PlanShare(site: site, plan: plan, loadpoints: loadpoints, prices: prices, cache: cache) // A change the box applied is followed by its new plan. mode.applied = { [plan] in plan.follow() } + mode.willSend = { [plan] in plan.expectChange() } } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index b65b0779..b4ab6306 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -34,6 +34,18 @@ public final class ModeModel { Style(key: "very_bold", name: "Very bold", k: 0, help: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong."), ] + /// The stop nearest a place along the scale, 0 at very careful and 1 + /// at very bold. + public nonisolated static func stop(at fraction: Double) -> Int { + guard fraction.isFinite else { return 2 } + return min(styles.count - 1, max(0, Int((fraction * Double(styles.count - 1)).rounded()))) + } + + /// Where a stop sits along the scale, 0 to 1. + public nonisolated static func fraction(of stop: Int) -> Double { + Double(min(styles.count - 1, max(0, stop))) / Double(styles.count - 1) + } + /// The style nearest a margin, the way the box page picks one. public nonisolated static func style(k: Double) -> Style { styles.min { abs($0.k - k) < abs($1.k - k) } ?? styles[2] @@ -100,6 +112,8 @@ public final class ModeModel { /// Runs after the box applied a change, so the plan can be read again. @ObservationIgnored public var applied: (@MainActor () -> Void)? + /// Runs as a change leaves for the box, before any answer. + @ObservationIgnored public var willSend: (@MainActor () -> Void)? @ObservationIgnored private unowned let site: SiteModel @ObservationIgnored private var clearing: Cancellable? @ObservationIgnored private var ask: LiveAsk? @@ -277,6 +291,7 @@ public final class ModeModel { guard !sending else { return } clearing?.cancel() change = Change(ask: ask) + willSend?() if !connected { change?.waiting = true let up = await stream(within: Self.streamWaitMs) diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift index 88bc635e..782acaaf 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanModel.swift @@ -31,6 +31,8 @@ public final class PlanModel { nonisolated static let reaskMs: Double = 15 * 60_000 nonisolated static let followMs: Double = 3_000 nonisolated static let followTries = 6 + /// How long plans after a change still count as that change. + nonisolated static let moveHoldMs: Double = 2 * 60_000 /// The plan on screen: the box's latest word, or the plan an earlier /// launch kept, dated, until the box answers. The same plan again @@ -48,6 +50,13 @@ public final class PlanModel { /// pushed. public private(set) var receivedAtMs: Double? public private(set) var problem: String? + /// The plan before a change of the person's own and when the new one + /// came, so a screen can say what moved. Gone with the next plan. + public struct Move: Equatable, Sendable { + public let before: Plan + public let atMs: Double + } + public private(set) var move: Move? /// What the planner reckons its plan saves against no battery, from its /// own document, read with the forecast. Nil until then, and when the /// planner gave no baseline. @@ -60,6 +69,9 @@ public final class PlanModel { @ObservationIgnored private var forecastActive = 0 @ObservationIgnored private var want = 0 @ObservationIgnored private var following: Cancellable? + /// The plan on screen when the person sent a change, and when, until + /// the box replans. + @ObservationIgnored private var awaiting: (plan: Plan, atMs: Double)? /// The plan on the session as last taken, so a pushed one is taken once. @ObservationIgnored private var sessionPlan: Plan? /// Runs each time the box has sent its plan, the same or a new one, so @@ -91,7 +103,22 @@ public final class PlanModel { } private func take(_ p: Plan) { + let previous = plan if let next = Stamped.taking(Stamped(p, asOfMs: site.nowMs, origin: .relay), over: stamped) { stamped = next } + guard let previous, p.rev != previous.rev else { return } + // The plan a change of the person's own brought is set beside the + // one before it. Plans that follow within a couple of minutes are + // the same change settling; a later one is the box's own replan. + if let before = awaiting { + awaiting = nil + if site.nowMs - before.atMs < Self.moveHoldMs { + move = Move(before: before.plan, atMs: site.nowMs) + return + } + } + if let move, site.nowMs - move.atMs >= Self.moveHoldMs { + self.move = nil + } } public func activate(forecast: Bool = false) { @@ -168,6 +195,13 @@ public final class PlanModel { } } + /// The person sent a change that moves the plan: the plan now on screen + /// is the one to compare the next with. Its plan may come before the + /// box's receipt does. + public func expectChange() { + if let plan { awaiting = (plan, site.nowMs) } + } + /// The box acknowledges a change before its planner has replanned, so /// the plan is asked for again a few times, three seconds apart, until /// its revision moves. diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 955001a2..395069ae 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -105,6 +105,14 @@ public final class PlanShare { return (entry, PlanGlance.waiting(entry, nowMs: nowMs, note: plan.problem)) } + /// "New plan 16:53: stores 22:45 instead of 03:45.", after a change of + /// the person's own, until the next plan. + public var moved: String? { + guard let move = plan.move else { return nil } + let before = PlanEntry.live(plan: move.before, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) + return PlanGlance.moved(from: before, to: entry, atMs: move.atMs) + } + /// What would make the record different. Cheap enough for every tick. var key: String { let w = loadpoints.points.map { lp in "\(lp.id):\((loadpoints.windows[lp.id] ?? lp.planWindows).map { "\($0.fromMs)-\($0.toMs)" }.joined(separator: ",")):\(lp.targetSocPct ?? lp.schedule?.socPct ?? -1)" } diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index 89cb7557..ce56793a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -192,6 +192,13 @@ public struct PlanDial: Equatable, Sendable { return lo == 0 ? nil : lo - 1 } + /// The quarters whose battery or car a new plan changed, on the same + /// turn. A dial that moved on by a quarter compares nothing. + public func moved(from old: PlanDial) -> [Int] { + guard old.grid == grid, old.quarters.count == quarters.count else { return [] } + return quarters.indices.filter { old.quarters[$0].battery != quarters[$0].battery || old.quarters[$0].car != quarters[$0].car } + } + // MARK: Where things sit /// Radians clockwise from the top for a moment on the household clock: diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index ca2d0bbc..25f1d8bb 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -188,6 +188,16 @@ public struct PlanStrip: Equatable, Sendable { ) } + /// The quarters whose battery or car a new plan changed, on the same + /// quarters. A strip that moved on by a quarter compares nothing: that + /// is the clock, not a new plan. + public func moved(from old: PlanStrip) -> [Int] { + guard old.grid == grid else { return [] } + return (0.. Int { diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index 4e2e07bd..c0733b7f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -528,6 +528,41 @@ public enum PlanGlance { } } + /// What a new plan moved, in one line: "New plan 16:53: stores 22:45 + /// instead of 03:45." The first move that changed, by when it starts: + /// the battery's charging, its giving power or the car's charge, moved, + /// new or gone. Nil when nothing a person would see moved. + public static func moved(from old: PlanEntry, to new: PlanEntry, atMs t: Double, calendar given: Calendar? = nil) -> String? { + let calendar = given ?? .household(new.timeZone) + let clock = Clock(calendar: calendar) + let before = events(old, fromMs: t, toMs: t + horizonMs).filter { $0.kind != .cheap } + let after = events(new, fromMs: t, toMs: t + horizonMs).filter { $0.kind != .cheap } + let made = ahead(t, asEnd: false, from: t, clock: clock) + func word(_ k: Kind) -> String { + switch k { + case .charge: return PlanWord.stores.text + case .discharge: return PlanWord.gives.text + case .car, .cheap: return PlanWord.carChargesWord.text + } + } + func at(_ e: Event) -> String { ahead(e.startMs, asEnd: false, from: t, clock: clock) } + var lines: [(ms: Double, text: String)] = [] + for kind in [Kind.charge, .discharge, .car] { + let a = before.first { $0.kind == kind }, b = after.first { $0.kind == kind } + switch (a, b) { + case let (a?, b?) where abs(a.startMs - b.startMs) >= 60_000: + lines.append((min(a.startMs, b.startMs), PlanWord.movedInstead.format(made, word(kind), at(b), at(a)))) + case let (nil, b?): + lines.append((b.startMs, PlanWord.movedNew.format(made, word(kind), at(b)))) + case let (a?, nil): + lines.append((a.startMs, PlanWord.movedGone.format(made, word(kind), at(a)))) + default: + break + } + } + return lines.min { $0.ms < $1.ms }?.text + } + /// "02–05", "02:15–04:47", "now–05". Tomorrow is named only when the /// clock alone would read as later today, as the price widget does: at /// 20:00 "02–04" can only be tonight, at 14:00 "15–17" cannot. @@ -622,6 +657,10 @@ enum PlanWord: String, CaseIterable { case whySun = "%@ from spare sun" case whySell = "%@, as selling pays" case whyLimit = "%@ to hold the grid limit" + // What a new plan moved. + case movedInstead = "New plan %@: %@ %@ instead of %@." + case movedNew = "New plan %@: %@ %@." + case movedGone = "New plan %@: no longer %@ %@." // Where a battery run leaves the battery. case levelDown = "%@, and leaves %@" case levelUp = "%@, and reaches %@" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings index d8be3e3e..b3084c24 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/en.lproj/Plan.strings @@ -42,6 +42,9 @@ "%@ from spare sun" = "%@ from spare sun"; "%@, as selling pays" = "%@, as selling pays"; "%@ to hold the grid limit" = "%@ to hold the grid limit"; +"New plan %@: %@ %@ instead of %@." = "New plan %@: %@ %@ instead of %@."; +"New plan %@: %@ %@." = "New plan %@: %@ %@."; +"New plan %@: no longer %@ %@." = "New plan %@: no longer %@ %@."; "%@, and leaves %@" = "%@, and leaves %@"; "%@, and reaches %@" = "%@, and reaches %@"; diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index 39444893..e11cd658 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -42,6 +42,9 @@ "%@ 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"; +"New plan %@: %@ %@ instead of %@." = "Ny plan %@: %@ %@ i stället för %@."; +"New plan %@: %@ %@." = "Ny plan %@: %@ %@."; +"New plan %@: no longer %@ %@." = "Ny plan %@: %@ inte längre %@."; "%@, and leaves %@" = "%@, och lämnar %@"; "%@, and reaches %@" = "%@, och når %@"; diff --git a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift index 60007277..b2edd9a9 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/NodeTests.swift @@ -606,6 +606,41 @@ import FTWShared } } + /// A change of the person's own sets the new plan beside the one + /// before it, until the next plan; the stops of the style's scale sit + /// at even steps. + @MainActor + @Test func aChangeShowsWhatThePlanMoved() async throws { + try await AppWords.$language.withValue(.en) { + let rig = DemoRig() + rig.app.startDemo() + let home = try #require(rig.app.home) + let mode = home.nodes.mode, plan = home.nodes.plan + mode.activate() + plan.activate() + await rig.run(1_500) + let before = try #require(plan.plan) + #expect(plan.move == nil) + let change = Task { await mode.setStyle(ModeModel.styles[0]) } + await rig.run(2_000) + await change.value + await rig.run(8_000) + let move = try #require(plan.move) + #expect(move.before == before) + #expect(plan.plan?.rev != before.rev) + // Asked again past the hold, the box's next plan is its own. + await rig.run(PlanModel.moveHoldMs + 1_000) + mode.deactivate() + plan.follow() + await rig.run(1_000) + #expect(plan.move == nil || plan.move == move) + } + #expect(ModeModel.stop(at: 0) == 0 && ModeModel.stop(at: 1) == 4) + #expect(ModeModel.stop(at: 0.6) == 2 && ModeModel.stop(at: 0.63) == 3) + #expect(ModeModel.stop(at: -1) == 0 && ModeModel.stop(at: .nan) == 2) + #expect(ModeModel.fraction(of: 2) == 0.5 && ModeModel.fraction(of: 9) == 1) + } + @MainActor @Test func aRefusalSnapsBack() async throws { try await AppWords.$language.withValue(.en) { diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index c563dc6e..c1544f6d 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -134,6 +134,21 @@ import Testing #expect(d.what(ten) == "Battery stores 3.0 kW · \(50 + ten % 40)\u{00A0}%") } + /// Only the quarters a new plan changed cross-fade on the dial. + @Test func onlyTheMovedTicksChange() { + var plan = watch.plan() + let a = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + plan.battery = plan.battery?.map { s in + var s = s + if s.startMs >= at(14, 22) && s.startMs < at(14, 23) { s.batteryW = 40 } + return s + } + let b = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) + let ten = a.grid.index(atMs: at(14, 22))! + #expect(b.moved(from: a) == Array(ten..<(ten + 4))) + #expect(PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 20)).moved(from: a).isEmpty) + } + @Test func thePlannedLine() { let zone = TimeZone(identifier: WatchTests.zone)! #expect(PlanDial.planned(atMs: at(14, 16, 51), old: false, zone: zone) == "PLANNED 16:51 · REPLANS EVERY 15 MIN") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 1b576b23..2caee88d 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -241,4 +241,42 @@ import Testing #expect(s.levels[31] != nil && s.levels[32] == nil) #expect(strip(at: at(12, 20, 5)).pending.isEmpty) } + + // MARK: What a new plan moved + + /// `night()` with the charging moved from 22:00 to 02:00. + func movedCharge() -> PlanEntry { + var entry = glance.night() + entry.battery = entry.battery?.map { s in + var s = s + if s.startMs >= at(12, 22) && s.startMs < at(12, 23, 30) { s.batteryW = 40 } + if s.startMs >= at(13, 2) && s.startMs < at(13, 3, 30) { s.batteryW = 3_000 } + return s + } + return entry + } + + @Test func aNewPlanSaysWhatItMoved() { + let old = glance.night(), new = movedCharge() + #expect(PlanGlance.moved(from: old, to: new, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: stores 02:00 instead of 22:00.") + // Nothing a person would see moved: nothing to say. + #expect(PlanGlance.moved(from: old, to: old, atMs: at(12, 20, 5), calendar: calendar) == nil) + // A move that is new, or gone. + var none = old + none.charges = [] + #expect(PlanGlance.moved(from: none, to: old, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: car charges 01:00.") + #expect(PlanGlance.moved(from: old, to: none, atMs: at(12, 20, 5), calendar: calendar) == "New plan 20:05: no longer car charges 01:00.") + } + + /// Only the quarters a new plan changed cross-fade; a strip that moved + /// on by a quarter is the clock, not a new plan. + @Test func onlyTheMovedQuartersChange() { + let a = PlanStrip.build(glance.night(), moment: PlanGlance.live(glance.night(), nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + let entry = movedCharge() + let b = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + #expect(b.moved(from: a) == Array(8..<14) + Array(24..<30)) + #expect(a.moved(from: a).isEmpty) + let later = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 20), calendar: calendar), hasCar: true) + #expect(later.moved(from: a).isEmpty) + } } From 0a35e0c11da2ac61eda82bb0ef8626e7cd16fff0 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 21:41:00 +0200 Subject: [PATCH 08/14] iPhone: the planning style as a scale, and the levers under the plan (plan pass 5) The style is one control with five stops: a tick per stop, one command when the finger lets go, the receipt in the status line. It sits under the plan with the car's goal and the mode, and in the mode sheet that Now's button now opens. After a change, only the quarters that moved cross-fade on the strip and the dial, and one line says what moved. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + appleApp/FTW/UI/ModeMenu.swift | 90 +----- appleApp/FTW/UI/ModeSheet.swift | 35 +-- appleApp/FTW/UI/NodeView.swift | 2 +- appleApp/FTW/UI/NowView.swift | 7 +- appleApp/FTW/UI/PlanDialCard.swift | 9 + appleApp/FTW/UI/PlanFade.swift | 106 +++++++ appleApp/FTW/UI/PlanStrip.swift | 11 +- appleApp/FTW/UI/PlanView.swift | 144 +++++++-- appleApp/FTW/UI/RootView.swift | 4 +- appleApp/FTW/UI/StyleScale.swift | 294 ++++++++++++++++++ .../Sources/FTWDemo/SimulatedHistory.swift | 10 +- .../Sources/FTWKit/State/ModeModel.swift | 12 +- .../Sources/FTWKit/State/NodeWords.swift | 6 + .../Sources/FTWKit/State/PlanShare.swift | 18 +- appleApp/FTWUITests/NowFitsTests.swift | 75 ++++- 16 files changed, 665 insertions(+), 159 deletions(-) create mode 100644 appleApp/FTW/UI/PlanFade.swift create mode 100644 appleApp/FTW/UI/StyleScale.swift diff --git a/CLAUDE.md b/CLAUDE.md index 35d95ca4..438333c3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -279,6 +279,7 @@ never their graphics or brands. | A plain "what happens next" timeline with times | Flighty | Now's plan sentence, plan view, plan widget | | Price as bars by level, with the cheap window marked | Tibber, Octopus | Price chart: ink bars by thirds and a cheapest bracket, never red and green | | Calm lists, one prominent action, a quiet second one | Things, Apple Home | Pairing, mode sheet | +| A control with a few stops: a tick at each, one command when the finger lets go | Camera's zoom control | The planning style scale, in the mode sheet and under the plan | | Liquid Glass only where the system puts it (bars, sheets, buttons) | iOS 26 | Toolbars, sheets, glass buttons; no custom glass panels | | A trace that burns at its head and fades when the signal stops | Oscilloscopes, CRT terminals | Beams on Now, the live trace, the status dot | | One mono status line, and an inverse-video tag for what is selected | Terminal tools | The status line under each title, the scrub tag | diff --git a/appleApp/FTW/UI/ModeMenu.swift b/appleApp/FTW/UI/ModeMenu.swift index 68941539..bbd5164c 100644 --- a/appleApp/FTW/UI/ModeMenu.swift +++ b/appleApp/FTW/UI/ModeMenu.swift @@ -2,25 +2,26 @@ import FTWKit import SwiftUI /// How the home is run, in the bar beside the title: `Balanced ⌄`. A glass -/// menu with the plan, its planning style and whether the battery may sell, -/// and the manual modes. The title sits at the leading edge, so a longer -/// name moves only the button's own edge; the button sizes to its name -/// rather than holding the longest one's width, which would leave the status -/// line too little room on a small phone. +/// button that opens the mode sheet, sized to its content: the planning +/// style as a scale with five stops, whether the battery may sell, and the +/// manual modes. The title sits at the leading edge, so a longer name moves +/// only the button's own edge; the button sizes to its name rather than +/// holding the longest one's width, which would leave the status line too +/// little room on a small phone. /// /// The choice shows at once; the status line follows the box's receipt, /// and the outcome alone plays a haptic. Drawn from the cache, it stays /// inert until the box has spoken in this session. A phone that may not -/// change the mode sees the name and no menu. +/// change the mode sees the name and no button. struct ModeMenu: View { let mode: ModeModel + var plan: PlanShare? + @State private var open = false var body: some View { Group { if mode.mayControl { - Menu { - choices - } label: { + Button { open = true } label: { label(chevron: true) } .disabled(!mode.canControl) @@ -32,8 +33,13 @@ struct ModeMenu: View { .accessibilityValue(name ?? "") .accessibilityHint(mode.mayControl ? L("Change mode") : "") .accessibilityIdentifier("now.mode") - // A command answers once, when the box says what it did. + // A command answers once, when the box says what it did, also when + // the sheet was closed first. With the sheet open the two play as + // one. .sensoryFeedback(trigger: mode.outcome?.tone) { _, tone in ModeSheet.feedback(tone) } + .sheet(isPresented: $open) { + ModeSheet(mode: mode, plan: plan) + } } /// The name on the button: the planning style while the plan runs, else @@ -61,68 +67,4 @@ struct ModeMenu: View { // Chrome: the bar holds its shape at every text size. .dynamicTypeSize(...DynamicTypeSize.xLarge) } - - @ViewBuilder private var choices: some View { - Section(L("How your home is run")) { - choice(L("Plan"), help: ModeWords.help(mode.info(mode.prefs?.mappedMode ?? ModeModel.planFallback)), selected: ModeWords.isPlan(mode.shown ?? ""), id: "plan") { - await mode.usePlan() - } - Picker(selection: styleBinding) { - ForEach(ModeModel.styles) { style in - Text(L(style.name)).tag(style.key) - } - } label: { - Label(L("Planning style"), systemImage: "slider.horizontal.3") - Text(mode.shownStyle.map { L($0.name) } ?? "") - } - .pickerStyle(.menu) - .disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? "")) - Toggle(isOn: exportBinding) { - Label(L("Let the battery sell to the grid when it pays"), systemImage: "arrow.up.forward") - } - .disabled(mode.prefs == nil || !ModeWords.isPlan(mode.shown ?? "")) - } - if !mode.manualModes.isEmpty { - Section(L("Manual")) { - ForEach(mode.manualModes) { m in - choice(ModeWords.name(m), help: ModeWords.help(m), selected: mode.shown == m.key, id: m.key) { - await mode.set(m.key) - } - } - } - } - } - - private func choice(_ title: String, help: String, selected: Bool, id: String, _ run: @escaping () async -> Void) -> some View { - Button { - guard !selected, !mode.sending else { return } - Task { await run() } - } label: { - if selected { - Label(title, systemImage: "checkmark") - } else { - Text(title) - } - if !help.isEmpty { Text(help) } - } - .accessibilityAddTraits(selected ? .isSelected : []) - .accessibilityIdentifier("mode.menu.\(id)") - } - - private var styleBinding: Binding { - Binding( - get: { mode.shownStyle?.key ?? ModeModel.styles[2].key }, - set: { key in - guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return } - Task { await mode.setStyle(style) } - } - ) - } - - private var exportBinding: Binding { - Binding( - get: { mode.shownExport ?? false }, - set: { on in Task { await mode.setExport(on) } } - ) - } } diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift index 9de8635e..53bb1eb3 100644 --- a/appleApp/FTW/UI/ModeSheet.swift +++ b/appleApp/FTW/UI/ModeSheet.swift @@ -6,12 +6,16 @@ import SwiftUI /// at once; there is no Save. A tap answers with a haptic, the row waits /// with a turning mark while the box's mode keeps its tick, and the receipt /// sits in a bar of fixed height at the foot, so no row moves under the -/// finger. A mode the box applied closes the sheet. +/// finger. A mode the box applied closes the sheet. The style is a scale +/// with five stops, sent when the finger lets go; the sheet stays, and says +/// what the new plan moved. struct ModeSheet: View { let mode: ModeModel /// Opened as the home's settings, from the title's menu: the same /// choices, with this phone's own settings under them. var app: AppModel? + /// The plan, to say what a change of style moved. + var plan: PlanShare? @Environment(\.dismiss) private var dismiss /// The sheet's height for its content, measured once laid out. @State private var fitted: CGFloat = 520 @@ -93,27 +97,16 @@ struct ModeSheet: View { await mode.usePlan() } // The plan's own settings stay in place whatever runs, so the - // list never changes length under the finger. - Picker(L("Planning style"), selection: styleBinding) { - ForEach(ModeModel.styles) { style in - Text(L(style.name)).tag(style.key) - } - } - .pickerStyle(.menu) - .disabled(!canSet || mode.prefs == nil || !onPlan) + // list never changes length under the finger. The scale rests + // by itself while a manual mode runs. + StyleScale(mode: mode, moved: plan?.moved) + .padding(.vertical, 6) Toggle(L("Let the battery sell to the grid when it pays"), isOn: exportBinding) .disabled(!canSet || mode.prefs == nil || !onPlan) // On is a state: lime, where the ink tint would hide the knob. .tint(Theme.live) } header: { Text(L("How your home is run")) - } footer: { - // The style's own help whatever runs: a footer that changed - // with the mode would move the rows below it under the finger. - if let style = mode.shownStyle { - Text(L(style.help)) - .contentTransition(.opacity) - } } } @@ -188,16 +181,6 @@ struct ModeSheet: View { .accessibilityIdentifier("mode.choice.\(id)") } - private var styleBinding: Binding { - Binding( - get: { mode.shownStyle?.key ?? ModeModel.styles[2].key }, - set: { key in - guard let style = ModeModel.styles.first(where: { $0.key == key }) else { return } - Task { await mode.setStyle(style) } - } - ) - } - private var exportBinding: Binding { Binding( get: { mode.shownExport ?? false }, diff --git a/appleApp/FTW/UI/NodeView.swift b/appleApp/FTW/UI/NodeView.swift index 3598ce1c..3ceb05db 100644 --- a/appleApp/FTW/UI/NodeView.swift +++ b/appleApp/FTW/UI/NodeView.swift @@ -111,7 +111,7 @@ struct NodeView: View { if down { trace.hold(atMs: Date().timeIntervalSince1970 * 1000) } else { trace.release() } } .sheet(isPresented: $choosingMode) { - ModeSheet(mode: models.mode) + ModeSheet(mode: models.mode, plan: models.planShare) } .fullScreenCover(isPresented: $enlarged) { ChartFullScreen(site: site, node: node, home: home, timebase: $timebase, series: $series, timebases: timebases) diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 90dbfeb1..13b2128c 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -315,13 +315,16 @@ private struct PlanSection: View { let window = prices.prices let current = window.flatMap { w in w.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } } let shown = models.planShare.shown(nowMs: minute) + let hasCar = !loadpoints.points.isEmpty + let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar) NavigationLink(value: HomeRoute.plan) { PlanStripView( - strip: PlanStrip.build(shown.entry, moment: shown.moment, hasCar: !loadpoints.points.isEmpty), + strip: strip, sentence: shown.moment.nextText, placeholder: shown.moment.placeholder, priceNow: current.map { PriceStrip.text($0.totalMinor, window?.currency) }, - priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)" + priceUnit: "\(PriceUnits.unit(window?.currency).label.uppercased()) \(PlanGlance.nowWord)", + fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar) ) .equatable() } diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift index b60fd5a9..76384223 100644 --- a/appleApp/FTW/UI/PlanDialCard.swift +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -20,6 +20,8 @@ struct PlanDialCard: View { let dial: PlanDial /// The plan is old or the box could not plan. let grey: Bool + /// The dial before the person's last change, while its plan stands. + var fade: DialFade? @State private var scrub = ScrubModel() /// The quarter the finger was last on, so it cannot wind past the seam. @@ -33,6 +35,13 @@ struct PlanDialCard: View { ZStack { PlanDialFace(dial: dial, grey: grey) .equatable() + if let fade { + MovedFade(fresh: fade.fresh, mask: QuarterWedges(dial: fade.old, quarters: fade.moved)) { + PlanDialFace(dial: fade.old, grey: grey) + .equatable() + } + .id(fade.atMs) + } DialHand(dial: dial, model: scrub) DialCentre(dial: dial, model: scrub, spoken: spoken) } diff --git a/appleApp/FTW/UI/PlanFade.swift b/appleApp/FTW/UI/PlanFade.swift new file mode 100644 index 00000000..23a2c921 --- /dev/null +++ b/appleApp/FTW/UI/PlanFade.swift @@ -0,0 +1,106 @@ +import FTWKit +import SwiftUI + +/// The quarters a change moved, as the old plan drew them, over the new +/// plan: on the ground's colour, masked to those quarters, and faded out in +/// 0.2 s. Only the moved quarters cross-fade; the rest never change. Drawn +/// once, as the new plan arrives; a view opened later shows the new plan +/// at once. +struct MovedFade: View { + let mask: Mask + let content: Content + @State private var shown: Bool + + init(fresh: Bool, mask: Mask, @ViewBuilder content: () -> Content) { + self.mask = mask + self.content = content() + _shown = State(initialValue: fresh) + } + + var body: some View { + ZStack { + Theme.ground + content + } + .mask(mask) + .opacity(shown ? 1 : 0) + .allowsHitTesting(false) + .accessibilityHidden(true) + .onAppear { + guard shown else { return } + withAnimation(.easeOut(duration: 0.2)) { shown = false } + } + } +} + +/// The strip before a change, and the quarters the change moved. +struct StripFade: Equatable { + let old: PlanStrip + let moved: [Int] + let atMs: Double + let fresh: Bool + + /// Nil when the change moved nothing the strip draws. + static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, strip: PlanStrip, nowMs: Double, hasCar: Bool) -> StripFade? { + guard let before else { return nil } + let old = PlanStrip.build(before.entry, moment: PlanGlance.live(before.entry, nowMs: nowMs), hasCar: hasCar) + let moved = strip.moved(from: old) + return moved.isEmpty ? nil : StripFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh) + } +} + +/// The dial before a change, and the quarters the change moved. +struct DialFade: Equatable { + let old: PlanDial + let moved: [Int] + let atMs: Double + let fresh: Bool + + static func make(_ before: (entry: PlanEntry, atMs: Double, fresh: Bool)?, dial: PlanDial, nowMs: Double) -> DialFade? { + guard let before else { return nil } + let old = PlanDial(plan: before.entry, prices: nil, nowMs: nowMs) + let moved = dial.moved(from: old) + return moved.isEmpty ? nil : DialFade(old: old, moved: moved, atMs: before.atMs, fresh: before.fresh) + } +} + +/// The strip's columns for some quarters, top to bottom. +struct QuarterColumns: Shape { + let grid: SlotGrid + let quarters: [Int] + + func path(in rect: CGRect) -> Path { + var p = Path() + for i in quarters { + let x0 = grid.x(atMs: grid.startMs(i), width: rect.width) + let x1 = grid.x(atMs: grid.endMs(i), width: rect.width) + p.addRect(CGRect(x: rect.minX + x0 - 0.5, y: rect.minY, width: x1 - x0 + 1, height: rect.height)) + } + return p + } +} + +/// The dial's wedges for some quarters, from the middle out past the rim. +struct QuarterWedges: Shape { + let dial: PlanDial + let quarters: [Int] + + func path(in rect: CGRect) -> Path { + let c = CGPoint(x: rect.midX, y: rect.midY) + let r = max(rect.width, rect.height) + func at(_ a: Double) -> CGPoint { CGPoint(x: c.x + r * sin(a), y: c.y - r * cos(a)) } + var p = Path() + for i in quarters { + let a0 = dial.angle(dial.grid.startMs(i)) + var a1 = dial.angle(dial.grid.endMs(i)) + if a1 < a0 { a1 += 2 * .pi } + // A hair wider, so two wedges side by side leave no seam. + p.move(to: c) + p.addLine(to: at(a0 - 0.004)) + p.addLine(to: at((a0 + a1) / 2)) + p.addLine(to: at(a1 + 0.004)) + p.closeSubpath() + } + return p + } +} diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 68cbb984..702ec76b 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -21,6 +21,8 @@ struct PlanStripView: View, Equatable { let priceNow: String? /// "ÖRE NU", after the price. let priceUnit: String + /// The strip before the person's last change, while its plan stands. + var fade: StripFade? @State private var scrub = ScrubModel() @State private var width: CGFloat = 0 @@ -30,7 +32,7 @@ struct PlanStripView: View, Equatable { @ScaledMetric(relativeTo: .subheadline) private var figure: CGFloat = 13 nonisolated static func == (a: Self, b: Self) -> Bool { - a.strip == b.strip && a.sentence == b.sentence && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.priceUnit == b.priceUnit + a.strip == b.strip && a.sentence == b.sentence && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.priceUnit == b.priceUnit && a.fade == b.fade } /// Mono stops growing at accessibility1. @@ -43,6 +45,13 @@ struct PlanStripView: View, Equatable { ZStack { ChartSurface(drawing: PlanStripDrawing(strip: strip, layout: layout)) .equatable() + if let fade { + MovedFade(fresh: fade.fresh, mask: QuarterColumns(grid: fade.old.grid, quarters: fade.moved)) { + ChartSurface(drawing: PlanStripDrawing(strip: fade.old, layout: layout)) + .equatable() + } + .id(fade.atMs) + } PlanStripOverlay(strip: strip, model: scrub, layout: layout) } .frame(height: layout.height) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 274be160..0cf9ca14 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -18,14 +18,18 @@ struct PlanView: View { private var models: NodeModels { home.nodes } var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 16) { + PlanBody(site: home.site, models: models, loadpoints: home.loadpoints, prices: home.prices, style: style, chooseMode: { choosingMode = true }) { levers in + withAnimation(.easeInOut(duration: 0.35)) { proxy.scrollTo(levers ? PlanBody.leversID : PlanBody.topID, anchor: levers ? .bottom : .top) } + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: 640, alignment: .topLeading) + .frame(maxWidth: .infinity) } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .frame(maxWidth: 640, alignment: .topLeading) - .frame(maxWidth: .infinity) } .scrollBounceBehavior(.basedOnSize) .background(Theme.ground) @@ -59,7 +63,7 @@ struct PlanView: View { home.prices.deactivate() } .sheet(isPresented: $choosingMode) { - ModeSheet(mode: models.mode) + ModeSheet(mode: models.mode, plan: models.planShare) } } } @@ -91,22 +95,30 @@ private struct PlanBody: View { let prices: PriceModel let style: PlanStyle let chooseMode: () -> Void + /// Scrolls to the levers, or back to the plan, for the style scale's + /// recording. + let reveal: (Bool) -> Void @State private var dials = DialMemo() + static let topID = "plan.top" + static let leversID = "plan.levers" + var body: some View { // The plan marks the minute: it redraws once a minute, not a second. let minute = site.minuteMs let moment = models.planShare.moment(nowMs: minute) + let moved = models.planShare.moved Group { switch style { case .dial: let dial = dials.dial(models.planShare.entry, nowMs: minute) VStack(alignment: .leading, spacing: 12) { - PlanDialCard(dial: dial, grey: moment.isGrey) + PlanDialCard(dial: dial, grey: moment.isGrey, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute)) VStack(alignment: .leading, spacing: 6) { // The words are the dial's legend: each verb takes // the colour its ticks have. PlanStory(moment: moment) + MovedLine(text: moved) // Mono stops growing at accessibility1. Group { if let cheapest = dial.cheapestText { @@ -145,6 +157,7 @@ private struct PlanBody: View { Text(stateLine(moment)) .font(.footnote) .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) + MovedLine(text: moved) } VStack(alignment: .leading, spacing: 6) { PlanLanes(moment: moment) @@ -156,12 +169,14 @@ private struct PlanBody: View { .transition(.opacity) } } + .id(Self.topID) PriceRow(prices: prices, minute: minute) // 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) + Levers(mode: models.mode, loadpoints: loadpoints, minute: minute, choose: chooseMode, reveal: reveal) + .id(Self.leversID) } /// "PLANNED 16:51 · REPLANS EVERY 15 MIN", or that the box could not. @@ -416,34 +431,101 @@ private struct PriceRow: View { } } -/// The mode in one row, for the plan it decides. -private struct CompactMode: View { +/// "New plan 16:53: stores 22:45 instead of 03:45.", after a change of the +/// person's own, until the next plan. +private struct MovedLine: View { + let text: String? + + var body: some View { + Group { + if let text { + Text(text) + .font(.subheadline.weight(.medium)) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + .accessibilityIdentifier("plan.moved") + } + } + .animation(.easeOut(duration: 0.2), value: text) + } +} + +/// What the person can change, at the foot of the plan: the car's goal, +/// the mode, and while the plan runs its style as a scale with five stops. +private struct Levers: View { let mode: ModeModel + let loadpoints: LoadpointsModel + let minute: Double let choose: () -> Void + let reveal: (Bool) -> Void var body: some View { - if let label = mode.label { - Button(action: choose) { - HStack { - Kicker(L("Mode")) - Spacer(minLength: 8) - Text(label) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(mode.mayControl ? Theme.accent : Theme.fg) - if mode.mayControl { - Image(systemName: "chevron.up.chevron.down") - .font(.caption2.weight(.bold)) - .foregroundStyle(Theme.accent) - } + VStack(alignment: .leading, spacing: 0) { + if let lp = loadpoints.points.first { + NavigationLink(value: HomeRoute.car(lp.id)) { + row(L("Car"), value: goal(lp), tint: Theme.ink, symbol: "chevron.right") } - .padding(.vertical, 10) - .frame(minHeight: 44) - .contentShape(Rectangle()) + .buttonStyle(.plain) + .accessibilityIdentifier("plan.car") + Divider().overlay(Theme.hairline) + } + if let label { + Button(action: choose) { + row(L("Mode"), value: label, tint: mode.mayControl ? Theme.accent : Theme.ink, symbol: mode.mayControl ? "chevron.up.chevron.down" : nil) + } + .buttonStyle(.plain) + .disabled(!mode.canControl) + .accessibilityIdentifier("plan.mode") + } + if showsScale { + #if DEBUG + StyleScale(mode: mode, drives: true, reveal: reveal) + .padding(.top, 6) + .padding(.bottom, 12) + #else + StyleScale(mode: mode) + .padding(.top, 6) + .padding(.bottom, 12) + #endif + } + } + } + + /// The scale stands in for the style while the plan runs and this + /// phone may change it; else the row names it. + private var showsScale: Bool { mode.mayControl && mode.shownStyle != nil && ModeWords.isPlan(mode.shown ?? "") } + + /// "Plan" when the scale below says the style, else "Plan · Balanced" + /// or a manual mode's name. + private var label: String? { + guard let key = mode.shown else { return mode.label } + return showsScale && ModeWords.isPlan(key) ? L("Plan") : mode.label + } + + /// "80 % by 07:00 on weekdays", the goal the plan charges for. + private func goal(_ lp: Loadpoint) -> String { + guard let s = lp.schedule else { return L("No goal") } + return EVText.goal(s, at: Date(timeIntervalSince1970: minute / 1000), zone: loadpoints.zone) + } + + private func row(_ kicker: String, value: String, tint: Color, symbol: String?) -> some View { + HStack(alignment: .firstTextBaseline) { + Kicker(kicker) + Spacer(minLength: 8) + Text(value) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(tint) + .multilineTextAlignment(.trailing) + if let symbol { + Image(systemName: symbol) + .font(.caption2.weight(.bold)) + .foregroundStyle(tint == Theme.ink ? Theme.ink3 : tint) } - .buttonStyle(.plain) - .disabled(!mode.canControl) - .accessibilityIdentifier("plan.mode") } + .padding(.vertical, 10) + .frame(minHeight: 44) + .contentShape(Rectangle()) } } diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 283377ea..686526c2 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -82,7 +82,7 @@ struct HomeView: View { TitleMenu(app: app, sheet: $sheet) } ToolbarItem(placement: .topBarTrailing) { - ModeMenu(mode: home.nodes.mode) + ModeMenu(mode: home.nodes.mode, plan: home.nodes.planShare) } } .navigationDestination(for: HomeRoute.self) { route in @@ -107,7 +107,7 @@ struct HomeView: View { } .sheet(item: $sheet) { which in switch which { - case .settings: ModeSheet(mode: home.nodes.mode, app: app) + case .settings: ModeSheet(mode: home.nodes.mode, app: app, plan: home.nodes.planShare) case .about(let start): AboutView(app: app, start: start) } } diff --git a/appleApp/FTW/UI/StyleScale.swift b/appleApp/FTW/UI/StyleScale.swift new file mode 100644 index 00000000..9b62f7f5 --- /dev/null +++ b/appleApp/FTW/UI/StyleScale.swift @@ -0,0 +1,294 @@ +import FTWKit +import SwiftUI + +/// The planning style as one control with five stops, very careful at the +/// left and very bold at the right. A finger moves the thumb from stop to +/// stop with a `.selection` tick at each; the style's name and help under +/// it follow the thumb; one command goes on release, and only when the stop +/// changed. The status line carries the receipt. +/// +/// Drawn from the cache it shows the box's style and rests until the box +/// has spoken; before the box has said a style it has no thumb. It rests +/// too while a manual mode runs and while a change is on its way. +/// VoiceOver steps it a style at a time, reading the name and the help, +/// and each step goes to the box. With Reduce Motion the thumb jumps. +/// +/// Two shapes share the logic: a straight scale, used, and a short arc in +/// the dial's ticks (`-FTWStyleControl arc`), kept to compare the two. +struct StyleScale: View { + let mode: ModeModel + /// "New plan 16:53: …", after a change, until the next plan. + var moved: String? + var look: Look = .chosen + #if DEBUG + /// With `-FTWStyleScrub YES`, this scale moves itself a few stops and + /// lets go, for recordings. `reveal` scrolls it into view first, and + /// back to the plan after. + var drives = false + var reveal: ((Bool) -> Void)? + #endif + + enum Look: String { + case line, arc + + static var chosen: Look { + #if DEBUG + if let raw = UserDefaults.standard.string(forKey: "FTWStyleControl"), let look = Look(rawValue: raw) { return look } + #endif + return .line + } + } + + /// The stop under the finger. + @State private var held: Int? + /// The stop let go on, until the box has answered. + @State private var asked: Int? + @State private var size: CGSize = .zero + @GestureState private var dragging = false + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @Environment(\.dynamicTypeSize) private var typeSize + @ScaledMetric(relativeTo: .caption2) private var kicker: CGFloat = 10.5 + + private static let styles = ModeModel.styles + private static let thumb: CGFloat = 28 + + /// The box's style, or the one asked for while it is on its way. + private var boxStop: Int? { mode.shownStyle.flatMap { s in Self.styles.firstIndex(of: s) } } + private var stop: Int? { held ?? asked ?? boxStop } + + /// A change can go: the box has spoken, the plan runs, and nothing else + /// is on its way. + private var live: Bool { + mode.canControl && mode.connected && !mode.sending && mode.prefs != nil && ModeWords.isPlan(mode.shown ?? "") + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + control + // Its own element, so VoiceOver reads what moved. + if let moved { + Text(moved) + .font(.footnote.weight(.medium)) + .foregroundStyle(Theme.ink) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + .accessibilityIdentifier("style.moved") + } + } + .animation(.easeOut(duration: 0.2), value: moved) + } + + private var control: some View { + VStack(alignment: .leading, spacing: 8) { + ends + track + .frame(height: look == .line ? 32 : 84) + .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 } + .contentShape(Rectangle()) + .gesture(drag, isEnabled: live) + .onChange(of: dragging) { _, now in if !now { release() } } + caption + } + .opacity(live || mode.sending ? 1 : 0.55) + .sensoryFeedback(trigger: held) { old, new in + guard let new, new != (old ?? boxStop) else { return nil } + return .selection + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(L("Planning style")) + .accessibilityValue(stop.map { "\(L(Self.styles[$0].name)). \(L(Self.styles[$0].help))" } ?? "") + .accessibilityHint(live ? L("Swipe up or down to change the style.") : "") + .accessibilityAdjustableAction { direction in + guard live, let from = stop else { return } + let to = direction == .increment ? from + 1 : from - 1 + guard Self.styles.indices.contains(to) else { return } + send(to) + } + .accessibilityIdentifier("style.scale") + #if DEBUG + .task { await drive() } + #endif + } + + /// "CAREFUL" at one end and "BOLD" at the other, in the kickers' mono. + private var ends: some View { + HStack { + Text(verbatim: "← \(L("Careful").uppercased())") + Spacer(minLength: 8) + Text(verbatim: "\(L("Bold").uppercased()) →") + } + .font(.system(size: min(kicker, 15), weight: .medium, design: .monospaced)) + .tracking(0.5) + .foregroundStyle(Theme.ink3) + .lineLimit(1) + } + + private var track: some View { + ZStack(alignment: .topLeading) { + Canvas { context, size in + draw(&context, size) + } + if let stop, size.width > 0 { + let p = point(Self.styles.count == 1 ? 0.5 : ModeModel.fraction(of: stop), in: size) + Circle() + .fill(Theme.ink) + .shadow(color: .black.opacity(0.25), radius: 3, y: 1) + .frame(width: Self.thumb, height: Self.thumb) + .position(p) + .animation(reduceMotion ? nil : Theme.spring, value: stop) + .transition(.opacity) + } + } + } + + /// The groove and its five stops; the stop the thumb is on is ink. + private func draw(_ context: inout GraphicsContext, _ size: CGSize) { + let n = Self.styles.count + var groove = Path() + switch look { + case .line: + groove.move(to: point(0, in: size)) + groove.addLine(to: point(1, in: size)) + case .arc: + groove.move(to: point(0, in: size)) + for i in 1...40 { groove.addLine(to: point(Double(i) / 40, in: size)) } + } + context.stroke(groove, with: .color(Theme.ink3.opacity(0.35)), style: StrokeStyle(lineWidth: 4, lineCap: .round, lineJoin: .round)) + for i in 0.. some View { + Text("\(Text(L(s.name)).fontWeight(.semibold).foregroundStyle(Theme.ink)) · \(Text(L(s.brief)).foregroundStyle(Theme.ink2))") + .font(.subheadline) + .foregroundStyle(Theme.ink2) + .lineLimit(typeSize.isAccessibilitySize ? nil : 1) + .minimumScaleFactor(0.8) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: The finger + + private var drag: some Gesture { + DragGesture(minimumDistance: 0) + .updating($dragging) { _, state, _ in state = true } + .onChanged { held = ModeModel.stop(at: fraction(at: $0.location)) } + .onEnded { _ in release() } + } + + /// One command, on release, when the stop changed. + private func release() { + guard let h = held else { return } + held = nil + guard h != boxStop else { return } + send(h) + } + + private func send(_ i: Int) { + asked = i + Task { + await mode.setStyle(Self.styles[i]) + asked = nil + } + } + + // MARK: Where things sit + + /// The arc spans this far either side of its top, in radians. + private static let sweep = 0.62 + + /// A place along the scale, 0 to 1, as a point in the track. `radius` + /// moves it across the arc, outward when positive. + private func point(_ f: Double, in size: CGSize, radius: CGFloat = 0) -> CGPoint { + let inset = Self.thumb / 2 + 2 + switch look { + case .line: + return CGPoint(x: inset + (size.width - 2 * inset) * f, y: size.height / 2) + case .arc: + let (centre, r) = arc(size) + let a = -Self.sweep + 2 * Self.sweep * f + return CGPoint(x: centre.x + (r + radius) * sin(a), y: centre.y - (r + radius) * cos(a)) + } + } + + /// The place along the scale nearest a point. + private func fraction(at p: CGPoint) -> Double { + let inset = Self.thumb / 2 + 2 + switch look { + case .line: + guard size.width > 2 * inset else { return 0.5 } + return min(1, max(0, (p.x - inset) / (size.width - 2 * inset))) + case .arc: + let (centre, _) = arc(size) + let a = atan2(p.x - centre.x, centre.y - p.y) + return min(1, max(0, (a + Self.sweep) / (2 * Self.sweep))) + } + } + + /// The arc's centre and radius: its ends meet the track's sides and its + /// top touches the track's top. + private func arc(_ size: CGSize) -> (CGPoint, CGFloat) { + let inset = Self.thumb / 2 + 2 + let r = (size.width / 2 - inset) / sin(Self.sweep) + return (CGPoint(x: size.width / 2, y: inset + r), r) + } + + #if DEBUG + private func drive() async { + guard drives, UserDefaults.standard.bool(forKey: "FTWStyleScrub") else { return } + while !live { + try? await Task.sleep(for: .milliseconds(200)) + if Task.isCancelled { return } + } + try? await Task.sleep(for: .seconds(1.5)) + for path in [[3, 4], [3, 2, 1], [2]] { + reveal?(true) + try? await Task.sleep(for: .seconds(1.2)) + for s in path { + held = s + try? await Task.sleep(for: .milliseconds(450)) + } + release() + // Back to the dial before the box's new plan lands, so its + // moved quarters fade in view. + try? await Task.sleep(for: .milliseconds(250)) + reveal?(false) + try? await Task.sleep(for: .seconds(4)) + while !live { + try? await Task.sleep(for: .milliseconds(200)) + if Task.isCancelled { return } + } + } + } + #endif +} diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift index 15e8ac75..7bbdf349 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedHistory.swift @@ -88,10 +88,12 @@ extension SimulatedHouse { /// What the battery does for one slot, in the plan's way: cheap hours /// fill it, spare sun goes in, dear hours cover the home. The margin the - /// household chose keeps more in it. + /// household chose keeps more in it: a careful plan buys in more hours + /// and gives power in fewer, so a change of style moves quarters. func decide(price: Double, mean: Double, pv: Double, base: Double, ev: Double, soc: Double, mode: String) -> Double { let net = base + ev + pv - let keep = 0.2 + 0.25 * min(1, safetyK) + let margin = min(1, safetyK) + let keep = 0.2 + 0.25 * margin switch mode { case "idle": return 0 case "charge": return soc < 0.995 ? batteryMaxW : 0 @@ -99,9 +101,9 @@ extension SimulatedHouse { case "peak_shaving": return net > 6_000 ? -(net - 6_000) : (pv < -500 ? min(batteryMaxW, -net) : 0) default: if net < -200 { return min(batteryMaxW, -net) } - if price < mean * 0.7, soc < 0.9 { return 3_000 } + if price < mean * (0.6 + 0.2 * margin), soc < 0.9 { return 3_000 } if mode == "planner_arbitrage", price > mean * 1.35, soc > keep + 0.15 { return -batteryMaxW } - if price > mean * 1.1, soc > keep { return -min(batteryMaxW, max(0, net)) } + if price > mean * (1.02 + 0.3 * margin), soc > keep { return -min(batteryMaxW, max(0, net)) } return 0 } } diff --git a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift index b4ab6306..281e6433 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/ModeModel.swift @@ -23,15 +23,17 @@ public final class ModeModel { public let name: String public let k: Double public let help: String + /// The help in one line, under the style scale's thumb. + public let brief: String public var id: String { key } } public nonisolated static let styles: [Style] = [ - Style(key: "very_careful", name: "Very careful", k: 1, help: "Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery."), - Style(key: "careful", name: "Careful", k: 0.6, help: "Plans for less sun and more use than forecast."), - Style(key: "balanced", name: "Balanced", k: 0.3, help: "Plans for a little less sun and a little more use than forecast. A good start for most homes."), - Style(key: "bold", name: "Bold", k: 0.15, help: "Plans close to the forecast."), - Style(key: "very_bold", name: "Very bold", k: 0, help: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong."), + Style(key: "very_careful", name: "Very careful", k: 1, help: "Plans for a poor day: much less sun and more use than forecast. Keeps the most in the battery.", brief: "Plans for a poor day."), + Style(key: "careful", name: "Careful", k: 0.6, help: "Plans for less sun and more use than forecast.", brief: "Plans for less sun."), + Style(key: "balanced", name: "Balanced", k: 0.3, help: "Plans for a little less sun and a little more use than forecast. A good start for most homes.", brief: "Plans with a little margin."), + Style(key: "bold", name: "Bold", k: 0.15, help: "Plans close to the forecast.", brief: "Plans close to the forecast."), + Style(key: "very_bold", name: "Very bold", k: 0, help: "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.", brief: "Plans on the forecast as it is."), ] /// The stop nearest a place along the scale, 0 at very careful and 1 diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 3feb4441..5942f80b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -167,6 +167,10 @@ public enum NodeWords { "Plans for less sun and more use than forecast.": "Planerar för mindre sol och mer förbrukning än prognosen.", "Plans for a little less sun and a little more use than forecast. A good start for most homes.": "Planerar för lite mindre sol och lite mer förbrukning än prognosen. En bra start för de flesta hem.", "Plans close to the forecast.": "Planerar nära prognosen.", + "Plans for a poor day.": "Planerar för en dålig dag.", + "Plans for less sun.": "Planerar för mindre sol.", + "Plans with a little margin.": "Planerar med lite marginal.", + "Plans on the forecast as it is.": "Planerar efter prognosen som den är.", "Plans on the forecast as it is. Earns the most when it is right and costs more when it is wrong.": "Planerar efter prognosen som den är. Tjänar mest när den stämmer och kostar mer när den slår fel.", "Let the battery sell to the grid when it pays": "Låt batteriet sälja till elnätet när det lönar sig", "A manual mode runs until you choose another. FTW does not plan meanwhile.": "Ett manuellt läge gäller tills du väljer ett annat. FTW planerar inte under tiden.", @@ -212,6 +216,8 @@ public enum NodeWords { "Mode: %@": "Läge: %@", "Change": "Byt", "Change mode": "Byt läge", + "Swipe up or down to change the style.": "Svep uppåt eller nedåt för att byta stil.", + "No goal": "Inget mål", "How your home is run": "Så styrs ditt hem", "Manual": "Manuellt", "Planning style": "Planeringsstil", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift index 395069ae..445b07fa 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/PlanShare.swift @@ -105,12 +105,24 @@ public final class PlanShare { return (entry, PlanGlance.waiting(entry, nowMs: nowMs, note: plan.problem)) } + /// The plan as it stood before the person's own change, drawn as the + /// plan is, and when the change's plan came. `fresh` while that was + /// moments ago, so a view drawing it now draws its arrival. Nil once + /// the box replans of its own accord. + public var before: (entry: PlanEntry, atMs: Double, fresh: Bool)? { + guard let move = plan.move else { return nil } + let entry = PlanEntry.live(plan: move.before, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier, daylight: daylight) + return (entry, move.atMs, site.scheduler.nowMs - move.atMs < Self.arrivalMs) + } + + /// How long a new plan counts as arriving. + nonisolated static let arrivalMs: Double = 1_500 + /// "New plan 16:53: stores 22:45 instead of 03:45.", after a change of /// the person's own, until the next plan. public var moved: String? { - guard let move = plan.move else { return nil } - let before = PlanEntry.live(plan: move.before, points: loadpoints.points, windows: loadpoints.windows, prices: prices.prices, timeZone: loadpoints.zone.identifier) - return PlanGlance.moved(from: before, to: entry, atMs: move.atMs) + guard let before else { return nil } + return PlanGlance.moved(from: before.entry, to: entry, atMs: before.atMs) } /// What would make the record different. Cheap enough for every tick. diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index e4590f6b..ea1ef860 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -168,26 +168,29 @@ final class NowFitsTests: XCTestCase { XCTAssertTrue(app.navigationBars["Are we in control?"].waitForNonExistence(timeout: 5)) } - /// A mode from the menu beside the title: the button names it at once, - /// the status line walks the box's receipt, and then returns. + /// A mode from the button beside the title: its sheet closes once the + /// box applied it, the button names it, the status line walks the box's + /// receipt, and then returns. @MainActor func testTheModeMenuChangesTheModeAndTheLineFollowsTheBox() throws { let app = launchDemo() let button = app.buttons["now.mode"] let line = app.buttons["status.line"] XCTAssertTrue(button.waitForExistence(timeout: 10)) - // The menu rests until the box has spoken. + // The button rests until the box has spoken. Thread.sleep(forTimeInterval: 3) XCTAssertEqual(button.value as? String, "Balanced") let frame = button.frame button.tap() - let own = app.buttons["Own use"] + let own = app.buttons["mode.choice.self_consumption"] XCTAssertTrue(own.waitForExistence(timeout: 5)) shoot(app, "mode-menu") + reveal(own, in: app) own.tap() let confirmed = NSPredicate(format: "value CONTAINS 'CONFIRMED'") expectation(for: confirmed, evaluatedWith: line) waitForExpectations(timeout: 10) + XCTAssertTrue(own.waitForNonExistence(timeout: 5), "the sheet stayed open after the box applied the mode") shoot(app, "mode-confirmed") XCTAssertEqual(button.value as? String, "Own use") XCTAssertEqual(button.frame.maxX, frame.maxX, accuracy: 0.5, "the mode button moved its trailing edge") @@ -196,10 +199,55 @@ final class NowFitsTests: XCTestCase { waitForExpectations(timeout: 10) // Back to the plan, for the next test. button.tap() - let plan = app.buttons["Plan"] + let plan = app.buttons["mode.choice.plan"] XCTAssertTrue(plan.waitForExistence(timeout: 5)) plan.tap() - Thread.sleep(forTimeInterval: 2) + XCTAssertTrue(plan.waitForNonExistence(timeout: 10)) + } + + /// The planning style from Now's button: a finger drags the scale two + /// stops and lets go, one command goes, the box confirms it, the sheet + /// stays and says what the new plan moved. A second drag goes back. + @MainActor + func testTheStyleScaleSendsOnceWhenTheFingerLetsGo() throws { + let app = launchDemo() + let button = app.buttons["now.mode"] + let line = app.buttons["status.line"] + XCTAssertTrue(button.waitForExistence(timeout: 10)) + Thread.sleep(forTimeInterval: 3) + XCTAssertEqual(button.value as? String, "Balanced") + button.tap() + let scale = app.descendants(matching: .any).matching(identifier: "style.scale").firstMatch + XCTAssertTrue(scale.waitForExistence(timeout: 5)) + XCTAssertTrue((scale.value as? String ?? "").hasPrefix("Balanced."), "\(String(describing: scale.value))") + shoot(app, "style-open") + // The track sits under the ends' line; its stops are spread across + // the width inside the thumb's half. + let f = scale.frame + let y = f.minY + 14 + 8 + 16 + func stop(_ i: Int) -> XCUICoordinate { + let x = f.minX + 16 + (f.width - 32) * CGFloat(i) / 4 + return app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: x, dy: y)) + } + stop(2).press(forDuration: 0.2, thenDragTo: stop(4), withVelocity: 300, thenHoldForDuration: 0.3) + let confirmed = NSPredicate(format: "value CONTAINS 'CONFIRMED'") + expectation(for: confirmed, evaluatedWith: line) + waitForExpectations(timeout: 10) + XCTAssertTrue((scale.value as? String ?? "").hasPrefix("Very bold."), "\(String(describing: scale.value))") + // The sheet stays for the other settings, and says what moved. + XCTAssertTrue(scale.exists) + XCTAssertTrue(app.staticTexts["style.moved"].waitForExistence(timeout: 5), "no line says what the new plan moved") + shoot(app, "style-moved") + // VoiceOver reads it as one element with the style's name and help. + XCTAssertEqual(scale.label, "Planning style") + // Back to where it was, for the next test. + Thread.sleep(forTimeInterval: 1) + stop(4).press(forDuration: 0.2, thenDragTo: stop(2), withVelocity: 300, thenHoldForDuration: 0.3) + expectation(for: NSPredicate(format: "value BEGINSWITH 'Balanced.'"), evaluatedWith: scale) + waitForExpectations(timeout: 10) + app.buttons["mode.close"].tap() + expectation(for: NSPredicate(format: "value == 'Balanced'"), evaluatedWith: button) + waitForExpectations(timeout: 10) } /// At the largest accessibility size the drawing gives way to a row @@ -271,10 +319,7 @@ final class NowFitsTests: XCTestCase { // Every choice the sheet opened with is on screen: the sheet fits // its content, or is full height and scrolls. let window = app.windows.firstMatch.frame - if !window.contains(idle.frame) { - app.swipeUp() - Thread.sleep(forTimeInterval: 1) - } + reveal(idle, in: app) XCTAssertTrue(window.contains(idle.frame), "\(idle.frame) is outside \(window)") let top = idle.frame.minY idle.tap() @@ -327,6 +372,16 @@ final class NowFitsTests: XCTestCase { } @MainActor + /// Scrolls the mode sheet until a row clears the receipt bar at its + /// foot: on a small phone the sheet is full height and scrolls. + private func reveal(_ row: XCUIElement, in app: XCUIApplication) { + let bar = app.descendants(matching: .any)["mode.receipt"].firstMatch + for _ in 0..<3 where !app.windows.firstMatch.frame.contains(row.frame) || (bar.exists && row.frame.maxY > bar.frame.minY) { + app.collectionViews.firstMatch.swipeUp() + Thread.sleep(forTimeInterval: 1) + } + } + private func shoot(_ app: XCUIApplication, _ name: String) { let shot = XCTAttachment(screenshot: app.screenshot()) shot.name = name From 2778094b9946347b2ce71782015de1dde8ca2cdb Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:07:25 +0200 Subject: [PATCH 09/14] iPhone: the root view presents the mode sheet A sheet held by the mode button in the bar did not open a second time after it closed itself. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/ModeMenu.swift | 10 ++++------ appleApp/FTW/UI/RootView.swift | 6 ++++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/appleApp/FTW/UI/ModeMenu.swift b/appleApp/FTW/UI/ModeMenu.swift index bbd5164c..70f202a7 100644 --- a/appleApp/FTW/UI/ModeMenu.swift +++ b/appleApp/FTW/UI/ModeMenu.swift @@ -15,13 +15,14 @@ import SwiftUI /// change the mode sees the name and no button. struct ModeMenu: View { let mode: ModeModel - var plan: PlanShare? - @State private var open = false + /// Opens the mode sheet. The root view presents it: a sheet held by a + /// view in the bar does not open again after it closed itself. + let open: () -> Void var body: some View { Group { if mode.mayControl { - Button { open = true } label: { + Button(action: open) { label(chevron: true) } .disabled(!mode.canControl) @@ -37,9 +38,6 @@ struct ModeMenu: View { // the sheet was closed first. With the sheet open the two play as // one. .sensoryFeedback(trigger: mode.outcome?.tone) { _, tone in ModeSheet.feedback(tone) } - .sheet(isPresented: $open) { - ModeSheet(mode: mode, plan: plan) - } } /// The name on the button: the planning style while the plan runs, else diff --git a/appleApp/FTW/UI/RootView.swift b/appleApp/FTW/UI/RootView.swift index 686526c2..eb997527 100644 --- a/appleApp/FTW/UI/RootView.swift +++ b/appleApp/FTW/UI/RootView.swift @@ -82,7 +82,7 @@ struct HomeView: View { TitleMenu(app: app, sheet: $sheet) } ToolbarItem(placement: .topBarTrailing) { - ModeMenu(mode: home.nodes.mode, plan: home.nodes.planShare) + ModeMenu(mode: home.nodes.mode) { sheet = .mode } } } .navigationDestination(for: HomeRoute.self) { route in @@ -108,6 +108,7 @@ struct HomeView: View { .sheet(item: $sheet) { which in switch which { case .settings: ModeSheet(mode: home.nodes.mode, app: app, plan: home.nodes.planShare) + case .mode: ModeSheet(mode: home.nodes.mode, plan: home.nodes.planShare) case .about(let start): AboutView(app: app, start: start) } } @@ -127,9 +128,10 @@ struct HomeView: View { } } -/// What the title's menu opens over Now. +/// What the title's menu and the mode button open over Now. enum HomeSheet: Hashable, Identifiable { case settings + case mode case about(AboutView.Start) var id: Self { self } } From 637e30d315a6c5d15f41818fb12bb92c5974df22 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:12:22 +0200 Subject: [PATCH 10/14] UI tests: scroll a mode row clear of the receipt bar by its place Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWUITests/NowFitsTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index ea1ef860..41022db7 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -373,10 +373,11 @@ final class NowFitsTests: XCTestCase { @MainActor /// Scrolls the mode sheet until a row clears the receipt bar at its - /// foot: on a small phone the sheet is full height and scrolls. + /// foot: on a small phone the sheet is full height and scrolls. The bar + /// and the home indicator take less than 100 pt. private func reveal(_ row: XCUIElement, in app: XCUIApplication) { - let bar = app.descendants(matching: .any)["mode.receipt"].firstMatch - for _ in 0..<3 where !app.windows.firstMatch.frame.contains(row.frame) || (bar.exists && row.frame.maxY > bar.frame.minY) { + let window = app.windows.firstMatch.frame + for _ in 0..<3 where row.frame.maxY > window.maxY - 100 { app.collectionViews.firstMatch.swipeUp() Thread.sleep(forTimeInterval: 1) } From 5734da28447a75cd0edcc003055fc5487ee19a99 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:18:15 +0200 Subject: [PATCH 11/14] iPhone: the mode sheet closes only for a change made while it is open Opened again just after a mode was applied, it saw that change and closed itself. The tests now scroll the sheet in short, slow drags. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/ModeSheet.swift | 12 +++++++++++- appleApp/FTWUITests/NowFitsTests.swift | 15 +++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/appleApp/FTW/UI/ModeSheet.swift b/appleApp/FTW/UI/ModeSheet.swift index 53bb1eb3..9264d760 100644 --- a/appleApp/FTW/UI/ModeSheet.swift +++ b/appleApp/FTW/UI/ModeSheet.swift @@ -16,7 +16,17 @@ struct ModeSheet: View { var app: AppModel? /// The plan, to say what a change of style moved. var plan: PlanShare? + /// The change the sheet opened on, which is not its own to close for: + /// a sheet opened again just after a mode was applied stays open. + @State private var openedOn: ModeModel.Change? @Environment(\.dismiss) private var dismiss + + init(mode: ModeModel, app: AppModel? = nil, plan: PlanShare? = nil) { + self.mode = mode + self.app = app + self.plan = plan + _openedOn = State(initialValue: mode.change) + } /// The sheet's height for its content, measured once laid out. @State private var fitted: CGFloat = 520 @State private var detent: PresentationDetent = .height(520) @@ -69,7 +79,7 @@ struct ModeSheet: View { /// a moment later the sheet goes. A style or a sale stays, since the /// reader may change the other too. private func closeOnceApplied() async { - guard let change = mode.change, change.result == .applied, case .mode = change.ask else { return } + guard let change = mode.change, change != openedOn, change.result == .applied, case .mode = change.ask else { return } try? await Task.sleep(for: .milliseconds(900)) guard !Task.isCancelled else { return } dismiss() diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index 41022db7..b629ba03 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -372,14 +372,17 @@ final class NowFitsTests: XCTestCase { } @MainActor - /// Scrolls the mode sheet until a row clears the receipt bar at its - /// foot: on a small phone the sheet is full height and scrolls. The bar - /// and the home indicator take less than 100 pt. + /// Scrolls the mode sheet a little at a time until a row clears the + /// receipt bar at its foot: on a small phone the sheet is full height + /// and scrolls. The bar and the home indicator take less than 100 pt. + /// Short, slow drags keep the rows above in place. private func reveal(_ row: XCUIElement, in app: XCUIApplication) { let window = app.windows.firstMatch.frame - for _ in 0..<3 where row.frame.maxY > window.maxY - 100 { - app.collectionViews.firstMatch.swipeUp() - Thread.sleep(forTimeInterval: 1) + for _ in 0..<4 where row.frame.maxY > window.maxY - 100 { + let list = app.collectionViews.firstMatch + list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) + .press(forDuration: 0.1, thenDragTo: list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)), withVelocity: .slow, thenHoldForDuration: 0.3) + Thread.sleep(forTimeInterval: 0.6) } } From 7344a4def850a291b5a0c94b234e8305c30ee260 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:23:23 +0200 Subject: [PATCH 12/14] UI tests: scroll a mode row only when its middle is under the bar Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWUITests/NowFitsTests.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index b629ba03..4120932f 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -372,13 +372,14 @@ final class NowFitsTests: XCTestCase { } @MainActor - /// Scrolls the mode sheet a little at a time until a row clears the - /// receipt bar at its foot: on a small phone the sheet is full height - /// and scrolls. The bar and the home indicator take less than 100 pt. - /// Short, slow drags keep the rows above in place. + /// Scrolls the mode sheet a little at a time until the middle of a row, + /// where a tap lands, clears the receipt bar at its foot: on a small + /// phone the sheet is full height and scrolls. The bar and the home + /// indicator take less than 100 pt. Short, slow drags keep the rows + /// above in place. private func reveal(_ row: XCUIElement, in app: XCUIApplication) { let window = app.windows.firstMatch.frame - for _ in 0..<4 where row.frame.maxY > window.maxY - 100 { + for _ in 0..<4 where row.frame.midY > window.maxY - 100 { let list = app.collectionViews.firstMatch list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8)) .press(forDuration: 0.1, thenDragTo: list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.6)), withVelocity: .slow, thenHoldForDuration: 0.3) From 8c0a0c653c04ce2a59b08a18363306c64fa6d9e1 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:29:32 +0200 Subject: [PATCH 13/14] UI tests: the mode sheet test looks while a slower demo box waits The demo answered after 0.9 s, and XCTest's wait for the app to settle after the tap took a second, so the look raced the answer. A debug flag, -FTWDemoResultDelayMs, lets this test hold the answer for 2.5 s. Co-Authored-By: Claude Opus 5.5 --- appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift | 3 +++ appleApp/FTWUITests/NowFitsTests.swift | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index 34c3bfae..1df877e6 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -810,6 +810,9 @@ extension SimulatedBox { box.resultDelayMs = 900 box.house.chargerRampMs = 4_000 #if DEBUG + // For UI tests that look while a command waits: `-FTWDemoResultDelayMs + // 2500` answers later than XCTest's wait for the app to settle. + if UserDefaults.standard.object(forKey: "FTWDemoResultDelayMs") != nil { box.resultDelayMs = UserDefaults.standard.double(forKey: "FTWDemoResultDelayMs") } // For screenshots: `-FTWDemoCar unplugged` and the like. if let name = UserDefaults.standard.string(forKey: "FTWDemoCar"), let car = DemoCar(rawValue: name) { car.apply(to: box.house, nowMs: scheduler.nowMs) diff --git a/appleApp/FTWUITests/NowFitsTests.swift b/appleApp/FTWUITests/NowFitsTests.swift index 4120932f..ead7bbb4 100644 --- a/appleApp/FTWUITests/NowFitsTests.swift +++ b/appleApp/FTWUITests/NowFitsTests.swift @@ -305,7 +305,11 @@ final class NowFitsTests: XCTestCase { /// the finger, and closes once the box applied the mode. @MainActor func testTheModeSheetFitsWaitsInPlaceAndClosesOnceApplied() throws { - let app = launchDemo() + // The box answers after 2.5 s, so the look while it waits comes well + // before: XCTest's wait for the app to settle after a tap can take a + // second, and the turning mark keeps it busy that long. + let app = launchDemo(more: ["-FTWDemoResultDelayMs", "2500"]) + XCTAssertTrue(app.buttons["now.node.home"].waitForExistence(timeout: 15), "Now never drew the home") let button = app.buttons["now.mode"] // The choices rest until the stream is up. openSettings(app) @@ -323,7 +327,7 @@ final class NowFitsTests: XCTestCase { XCTAssertTrue(window.contains(idle.frame), "\(idle.frame) is outside \(window)") let top = idle.frame.minY idle.tap() - // The demo box answers after 0.9 s; look well before that. + // Look well before the box answers. Thread.sleep(forTimeInterval: 0.15) XCTAssertTrue(plan.isSelected, "the box's mode lost its tick before the box answered") XCTAssertEqual(idle.frame.minY, top, accuracy: 1, "the row moved under the finger") From 906206ea5b9e152a4f797abd50986871debda7fa Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Wed, 14 Oct 2026 22:30:01 +0200 Subject: [PATCH 14/14] iPhone: the scale's words fade out before the next fade in Co-Authored-By: Claude Opus 5.5 --- appleApp/FTW/UI/StyleScale.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/appleApp/FTW/UI/StyleScale.swift b/appleApp/FTW/UI/StyleScale.swift index 9b62f7f5..2b5c018b 100644 --- a/appleApp/FTW/UI/StyleScale.swift +++ b/appleApp/FTW/UI/StyleScale.swift @@ -173,18 +173,22 @@ struct StyleScale: View { } /// The style's name and its help in one line, which change as the thumb - /// moves. The room they take is the largest of the five, so nothing - /// below moves. VoiceOver reads the whole help. + /// moves: the old words fade out before the new fade in, so the two + /// never sit on top of each other. The room they take is the largest of + /// the five, so nothing below moves. VoiceOver reads the whole help. private var caption: some View { ZStack(alignment: .topLeading) { ForEach(Self.styles) { s in describe(s).hidden() } if let stop { describe(Self.styles[stop]) .id(stop) - .transition(.opacity) + .transition(.asymmetric( + insertion: .opacity.animation(.easeIn(duration: 0.1).delay(0.08)), + removal: .opacity.animation(.easeOut(duration: 0.08)) + )) } } - .animation(.easeOut(duration: 0.15), value: stop) + .animation(.default, value: stop) } private func describe(_ s: ModeModel.Style) -> some View {