From c7cc2c5d549b12ec1f47bc2446fa0b8c81906877 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 15:11:23 +0100 Subject: [PATCH 1/4] feat(ios): the price stands on zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Erik, 1 November 2026: Now's strip made 450 against 550 öre look five times dearer. It put the window's cheapest quarter at the floor and the dearest at the top. Every price bar now reads one PriceScale: zero at the foot and a round top just over the dearest (1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten). Now's strip draws the bars from zero, marks half the top and the top with figures in a gutter on the right, and carries the price now on as a dashed line. A price below zero hangs under the zero line. The dial's ticks, the lock-screen glyphs, the plan widget and the watch's bars take the same scale in eight steps. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 12 ++- appleApp/FTW/UI/PlanFade.swift | 7 +- appleApp/FTW/UI/PlanStrip.swift | 8 +- appleApp/FTWCharts/PlanStripDrawing.swift | 90 ++++++++++++++----- .../Sources/FTWShared/Charts/PlanDial.swift | 17 ++-- .../Sources/FTWShared/Charts/PlanStrip.swift | 54 +++++------ .../Sources/FTWShared/Charts/PriceScale.swift | 73 +++++++++++++++ .../Sources/FTWShared/PriceBlocks.swift | 11 +-- .../Tests/FTWSharedTests/PlanStripTests.swift | 27 +++--- .../FTWSharedTests/PriceGlanceTests.swift | 21 +++-- .../FTWSharedTests/PriceScaleTests.swift | 53 +++++++++++ .../Tests/FTWSharedTests/WatchTests.swift | 16 ++-- appleApp/FTWWatchShared/BlockBars.swift | 8 +- appleApp/FTWWatchShared/DialStrip.swift | 2 +- appleApp/FTWWidgets/Plan/PlanViews.swift | 4 +- 15 files changed, 296 insertions(+), 107 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/PriceScale.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceScaleTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index 729c288c..794cd065 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -294,6 +294,13 @@ The rules below put these into practice. (violet). The home is ink. `live` and `alarm` are states, never levels. A price is ink by its level, never red and green. No orange anywhere; the system tint is ink. +- **A price stands on zero.** Erik, 1 Nov 2026: the strip that put the + window's cheapest at its floor made 450 against 550 öre look five times + dearer. Every price bar, tick and glyph reads one `PriceScale`: zero at + the foot and a round top just over the dearest (1, 1.5, 2, 2.5, 3, 4, 5, + 6 or 8 times a power of ten), with grid lines and figures at half the + top and the top where there is room. Now's strip carries the price now + on as a dashed line. A price below zero hangs under the zero line. - **Gold on a chart is the sun's.** The sun's curve and the battery giving power are both warm, and a tester read one as the other, so the battery runs in cyan both ways wherever a trace is drawn, as the grid runs in @@ -315,7 +322,7 @@ The rules below put these into practice. | Detail | Where | The reading it carries | |---|---|---| | Mono status line, `● LIVE · FTW IN CONTROL` | Under every title | Whether this is now, and who controls the home | - | 8-level block price bars (▁▂▃▄▅▆▇█) | Now's strip, dial ticks, lock-screen widget | Price per slot at a glance | + | 8-step block price bars (▁▂▃▄▅▆▇█), from zero | Dial ticks, lock-screen widget, watch | Price per slot at a glance | | Lock-on: corner brackets and an inverse-video tag | Under a finger, on a pressed node | What the finger is on, and its value | | 10-cell meter | A grid phase against its fuse | Share in tenths, readable at 4 pt | | Receipt log `SENT ▸ ACCEPTED ▸ MEASURED ▸ CONFIRMED` | Car commands | How far the box has confirmed a command | @@ -379,7 +386,8 @@ FTW's own form, from Fredrik's film (srcfl/sourceful-films, 1. One turn is the next 24 hours. Midnight sits at the bottom and noon at the top, so the day's light fills the upper half. It never reads as a 12-hour clock. -2. One tick per quarter hour, as long as the price in eight levels. Its +2. One tick per quarter hour, as long as the price in eight steps from + zero (`PriceScale`). Its colour is what the plan does with the battery: `Theme.stores` stores, `Theme.ink` gives power, `Theme.ink3` waits. On the dial, gold is the sun's and never the battery's. diff --git a/appleApp/FTW/UI/PlanFade.swift b/appleApp/FTW/UI/PlanFade.swift index 83d5ef80..2196c0d0 100644 --- a/appleApp/FTW/UI/PlanFade.swift +++ b/appleApp/FTW/UI/PlanFade.swift @@ -68,12 +68,15 @@ struct DialFade: Equatable { struct QuarterColumns: Shape { let grid: SlotGrid let quarters: [Int] + /// Room on the right the quarters do not run across: the scale's. + var trailing: CGFloat = 0 func path(in rect: CGRect) -> Path { var p = Path() + let width = max(0, rect.width - trailing) 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) + let x0 = grid.x(atMs: grid.startMs(i), width: width) + let x1 = grid.x(atMs: grid.endMs(i), width: width) p.addRect(CGRect(x: rect.minX + x0 - 0.5, y: rect.minY, width: x1 - x0 + 1, height: rect.height)) } return p diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 3f5084ae..06ecd3fe 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -3,8 +3,8 @@ import SwiftUI /// The plan on Now: the next twelve hours in one strip, on the ground with /// no card. A header with the price now, the battery's and the car's lanes, -/// the price in eight levels, the hours, and one sentence about what comes -/// next. Its height never depends on what the plan says; the price row +/// the price from zero with its scale, the hours, and one sentence about +/// what comes next. Its height never depends on what the plan says; the price row /// takes the height Now gives it, so on a tall phone the bars read as a /// chart. /// @@ -80,7 +80,7 @@ struct PlanStripView: View, Equatable { ChartSurface(drawing: PlanStripDrawing(strip: strip, layout: layout)) .equatable() if let fade { - MovedFade(fresh: fade.fresh, mask: QuarterColumns(grid: fade.old.grid, quarters: fade.moved)) { + MovedFade(fresh: fade.fresh, mask: QuarterColumns(grid: fade.old.grid, quarters: fade.moved, trailing: layout.gutter)) { ChartSurface(drawing: PlanStripDrawing(strip: fade.old, layout: layout)) .equatable() } @@ -136,7 +136,7 @@ struct PlanStripView: View, Equatable { } private func follow(_ x: CGFloat) { - guard let i = strip.grid.index(atX: x, width: width) else { return } + guard let i = strip.grid.index(atX: x, width: layout.plotWidth(width)) else { return } scrub.move(to: i, landmark: strip.landmarks.contains(i)) } diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index d0965ddc..bc571eac 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -3,9 +3,10 @@ import SwiftUI /// Where the parts of Now's plan strip sit, from the top of its Canvas: /// room for the lock-on's brackets, two 6 pt lanes 4 pt apart (battery, -/// then car), the price bars in eight levels on a floor of 20 pt or more, -/// and the hours under them. The surface and the finger's overlay share -/// it, so a lit quarter and its bar meet. +/// then car), the price bars from zero in a row of 20 pt or more, and the +/// hours under them. The price's scale takes a gutter on the right, so the +/// quarters run across the rest. The surface and the finger's overlay +/// share it, so a lit quarter and its bar meet. struct PlanStripLayout: Equatable { static let lane: CGFloat = 6 static let batteryY: CGFloat = 3 @@ -22,13 +23,18 @@ struct PlanStripLayout: Equatable { var priceHeight: CGFloat = Self.leastPrice var priceBottom: CGFloat { Self.priceTop + priceHeight } - var levelStep: CGFloat { priceHeight / CGFloat(PlanStrip.levels) } var hoursTop: CGFloat { priceBottom + 3 } + /// Room on the right for the scale's figures, "600", in the axis's + /// mono at nine tenths of its size. + var gutter: CGFloat { (axis * 2.4).rounded(.up) + 4 } + /// The width the quarters run across. + func plotWidth(_ width: CGFloat) -> CGFloat { max(0, width - gutter) } var height: CGFloat { hoursTop + (axis * 1.25).rounded(.up) } /// The tallest the strip's drawing grows to. var tallest: CGFloat { height + Self.mostPrice - priceHeight } - /// What a finger lights: the lanes and the bars, not the hours. - func plot(width: CGFloat) -> CGRect { CGRect(x: 0, y: 0, width: width, height: priceBottom) } + /// What a finger lights: the lanes and the bars, not the hours or the + /// scale. + func plot(width: CGFloat) -> CGRect { CGRect(x: 0, y: 0, width: plotWidth(width), height: priceBottom) } /// The same parts in a drawing `height` tall: the price row takes what /// the lanes and the hours leave. @@ -47,13 +53,31 @@ struct PlanStripLayout: Equatable { return (x0, max(x0, x1)) } - func barHeight(_ level: Int) -> CGFloat { CGFloat(level + 1) * levelStep } + /// Where a price sits in the row, from its scale. + func priceY(_ minor: Double, _ scale: PriceScale) -> CGFloat { + priceBottom - CGFloat(scale.fraction(minor)) * priceHeight + } + + /// A price's bar from the zero line: rounded at its far end, and never + /// thinner than a point, so a price near zero still shows. + func bar(_ minor: Double, _ scale: PriceScale, x0: CGFloat, x1: CGFloat) -> Path { + let zero = priceY(0, scale), end = priceY(minor, scale) + let up = end <= zero + let h = max(1, abs(zero - end)) + let rect = CGRect(x: x0, y: up ? zero - h : zero, width: x1 - x0, height: h) + let r = min(1.25, rect.width / 2) + let radii = up + ? RectangleCornerRadii(topLeading: r, bottomLeading: 0, bottomTrailing: 0, topTrailing: r) + : RectangleCornerRadii(topLeading: 0, bottomLeading: r, bottomTrailing: r, topTrailing: 0) + return Path(roundedRect: rect, cornerRadii: radii, style: .circular) + } } /// Now's plan strip, in one Canvas: the battery's lane (gold gives, cyan /// stores), the car's (violet, a 2 pt tick at its goal), an idle hairline -/// where a lane has nothing, 48 price bars in eight levels by ink third, -/// the now rule across lanes and bars, and the hours. Equal neighbours are +/// where a lane has nothing, 48 price bars from zero by ink third under +/// two grid lines with their figures, a dashed line at the price now, the +/// now rule across lanes and bars, and the hours. Equal neighbours are /// one shape, so no run shows a seam. The lanes are a plan, so they are a /// ghost: their colour at `Theme.plan`, never the full strength that says /// live. A plan that is old, that the box could not make, or from a box @@ -67,7 +91,7 @@ struct PlanStripDrawing: ChartDrawing { let scale = max(g.environment.displayScale, 1) let px = 1 / scale func snap(_ v: CGFloat) -> CGFloat { (v * scale).rounded() / scale } - let width = size.width + let width = layout.plotWidth(size.width) let grid = strip.grid let grey = strip.grey let ghost = Theme.plan(g.environment.colorSchemeContrast) @@ -105,15 +129,26 @@ struct PlanStripDrawing: ChartDrawing { } g.fill(car, with: .color(grey ? oldInk : Theme.car.opacity(ghost))) - // The price: a bar per stretch of equal level, ink by third. + // The scale: a hairline at half the top and at the top, each with + // its figure in the gutter, and the zero line when a price falls + // under it. + let priceScale = strip.scale + var lines = Path() + for v in priceScale.lines + (priceScale.bottom < 0 ? [0] : []) { + lines.addRect(CGRect(x: 0, y: snap(layout.priceY(v, priceScale)), width: width, height: px)) + } + g.fill(lines, with: .color(Theme.ink.opacity(0.1))) + for v in priceScale.lines { + let text = g.resolve(ChartText.label(priceScale.label(v), size: layout.axis * 0.9)) + g.draw(text, at: CGPoint(x: width + 4, y: layout.priceY(v, priceScale)), anchor: .leading) + } + + // The price: a bar per stretch of equal price, ink by third. var thirds: [PriceChart.Third: Path] = [:] for run in strip.priceRuns { let s = PlanStripLayout.span(first: run.first, last: run.last, grid: grid, width: width, scale: scale) - let h = layout.barHeight(run.value) - let rect = CGRect(x: s.x0, y: layout.priceBottom - h, width: s.x1 - s.x0, height: h) - let r = min(1.25, rect.width / 2) - let bar = Path(roundedRect: rect, cornerRadii: RectangleCornerRadii(topLeading: r, bottomLeading: 0, bottomTrailing: 0, topTrailing: r), style: .circular) - thirds[PlanStrip.third(run.value), default: Path()].addPath(bar) + let third = strip.thirds[run.first] ?? .middle + thirds[third, default: Path()].addPath(layout.bar(run.value, priceScale, x0: s.x0, x1: s.x1)) } for (third, path) in thirds { g.fill(path, with: .color(grey ? Theme.ink3.opacity(0.6) : Theme.ink.opacity(Self.ink(third)))) @@ -121,10 +156,11 @@ struct PlanStripDrawing: ChartDrawing { // 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 = layout.levelStep * 2 + let stub = layout.priceHeight / 4 + let base = layout.priceY(0, priceScale) 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: layout.priceBottom - stub, width: s.x1 - s.x0, height: stub) + let rect = CGRect(x: s.x0, y: base - stub, width: s.x1 - s.x0, height: stub) stubs.addRect(rect) var x = rect.minX - stub while x < rect.maxX { @@ -138,8 +174,16 @@ struct PlanStripDrawing: ChartDrawing { clipped.stroke(hatch, with: .color(Theme.ink3), style: StrokeStyle(lineWidth: px, lineCap: .butt, lineJoin: .round)) } - // Now, one pixel across the lanes and the bars. + // Now, one pixel across the lanes and the bars, and the price now + // carried on as a dashed line, so a bar over it is dearer than now. let nowX = snap(grid.x(atMs: strip.nowMs, width: width)) + if let now = strip.nowMinor { + var dash = Path() + let y = snap(layout.priceY(now, priceScale)) + px / 2 + dash.move(to: CGPoint(x: nowX, y: y)) + dash.addLine(to: CGPoint(x: width, y: y)) + g.stroke(dash, with: .color(grey ? Theme.ink3 : Theme.ink2), style: StrokeStyle(lineWidth: 1, lineCap: .butt, lineJoin: .round, dash: [2, 2])) + } g.fill(Path(CGRect(x: nowX, y: PlanStripLayout.batteryY, width: px, height: layout.priceBottom - PlanStripLayout.batteryY)), with: .color(grey ? Theme.ink3 : Theme.ink)) // The hours, never cut at either edge. @@ -196,16 +240,14 @@ struct PlanStripOverlay: View { let scale = max(g.environment.displayScale, 1) let px = 1 / scale let plot = layout.plot(width: size.width) - let s = PlanStripLayout.span(first: i, last: i, grid: strip.grid, width: size.width, scale: scale) + let s = PlanStripLayout.span(first: i, last: i, grid: strip.grid, width: plot.width, scale: scale) let column = CGRect(x: s.x0, y: plot.minY, width: max(px, s.x1 - s.x0), height: plot.height) g.fill(Path(CGRect(x: 0, y: plot.minY, width: column.minX, height: plot.height)), with: .color(Theme.ground.opacity(0.5))) g.fill(Path(CGRect(x: column.maxX, y: plot.minY, width: max(0, plot.maxX - column.maxX), height: plot.height)), with: .color(Theme.ground.opacity(0.5))) g.fill(Path(column), with: .color(Theme.ink.opacity(0.16))) - if let level = strip.levels[i] { - let h = layout.barHeight(level) - let bar = CGRect(x: column.minX, y: layout.priceBottom - h, width: column.width, height: h) - g.fill(Path(roundedRect: bar, cornerRadius: min(1.25, bar.width / 2), style: .circular), with: .color(Theme.ink)) + if let minor = strip.minor[i] { + g.fill(layout.bar(minor, strip.scale, x0: column.minX, x1: column.maxX), with: .color(Theme.ink)) } LockOn.rule(at: (column.midX * scale).rounded() / scale + px / 2, plot: plot, px: px, in: &g) let frame = CGRect(x: column.minX - 3, y: 0.75, width: column.width + 6, height: layout.priceBottom + 1.5) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index f1480a8c..c445a5a4 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -8,6 +8,9 @@ import Foundation /// inside marks the car's charging. The sun's arc on the rim comes from the /// box's own forecast, never from astronomy. /// +/// The ticks stand on the same scale as every other price bar: eight steps +/// from zero to a round top (`PriceScale`). +/// /// Built on the chart engine (`SlotGrid`, `ZoneClock`) and worked out once /// per plan and minute, so a finger or the Crown only reads a table. public struct PlanDial: Equatable, Sendable { @@ -28,7 +31,7 @@ public struct PlanDial: Equatable, Sendable { /// Minor units per kWh, the consumer total. Nil before the market /// has priced it: the dial draws a stub, never a level. public let minor: Double? - /// 0 to 7 within this face's prices. + /// 0 to 7, from zero to the round top over this face's prices. public let level: Int? public let battery: Battery /// The battery's planned power, site sign. Nil without a plan. @@ -100,8 +103,7 @@ public struct PlanDial: Equatable, Sendable { func planned(_ q: Int) -> PlanEntry.Slot? { holding(battery, q, start: \.startMs, length: \.durationMs) } let minors = (0.. first && c.fromMs < grid.toMs && plan?.chargesAgain(after: c) != true { @@ -116,7 +118,7 @@ public struct PlanDial: Equatable, Sendable { return Quarter( startMs: start, minor: minors[i], - level: minors[i].map { Self.level($0, lo: lo, hi: hi) }, + level: minors[i].map { scale.step($0) - 1 }, battery: action, batteryW: w, car: charges.contains { $0.fromMs < end && $0.toMs > start }, @@ -182,13 +184,6 @@ public struct PlanDial: Equatable, Sendable { } } - /// The price in eight levels, the dearest at 7. A flat day sits in the - /// middle rather than at either end. - static func level(_ v: Double, lo: Double, hi: Double) -> Int { - guard hi - lo >= 0.5 else { return levels / 2 - 1 } - return min(levels - 1, max(0, Int(((v - lo) / (hi - lo) * Double(levels)).rounded(.down)))) - } - /// The index of the last element starting at or before `t`, by one /// binary search over sorted starts. static func last(_ items: [T], atOrBefore t: Double, start: KeyPath) -> Int? { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index b08306f7..ea2de5af 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -2,18 +2,15 @@ import Foundation /// Now's plan strip: the next twelve hours as 48 quarters from the one /// running now. Per quarter, what the battery does, whether the car -/// charges and the price in eight levels; the runs the lanes and bars draw, +/// charges and the price on a scale from zero; the runs the lanes and bars draw, /// the hour labels, the quarters a finger bumps on and the line it reads. /// Worked out once per minute or per plan, so a finger costs a subscript. /// A view only maps it to its size. public struct PlanStrip: Equatable, Sendable { public static let quarterMs: Double = 15 * 60_000 public static let count = 48 - /// The block-glyph scale: ▁▂▃▄▅▆▇█. - public static let levels = 8 - /// The price row's height: eight levels of 2.5 pt at its least, and - /// the most it grows to when Now has room. Now's layout and the strip's - /// drawing read the same two. + /// The price row's height at its least, and the most it grows to when + /// Now has room. Now's layout and the strip's drawing read the same two. public static let leastPrice: CGFloat = 20 public static let mostPrice: CGFloat = 72 @@ -43,12 +40,21 @@ public struct PlanStrip: Equatable, Sendable { /// The quarter in which a car's charge ends, and the level it charges /// toward. public let goals: [Int: Double] - /// The price per quarter, 0 for the cheapest in the window to 7 for the - /// dearest; nil before the market has priced it. - public let levels: [Int?] + /// The price per quarter in minor units; nil before the market has + /// priced it. + public let minor: [Double?] + /// Its ink, by thirds of the window's range: the dearer, the stronger. + public let thirds: [PriceChart.Third?] + /// The bars stand on zero and reach a round top, so a bar's height is + /// its price. + public let scale: PriceScale + /// The price of the quarter running now, for the dashed line that + /// carries it across the hours to come. + public let nowMinor: Double? public let batteryRuns: [Run] public let carRuns: [Run] - public let priceRuns: [Run] + /// Stretches of equal price, drawn as one bar. + public let priceRuns: [Run] /// Stretches the market has not priced yet: drawn as hatched stubs, /// never as a level. public let pending: [Run] @@ -76,12 +82,6 @@ public struct PlanStrip: Equatable, Sendable { /// The home has a charger, so the car's lane keeps its idle line. public let hasCar: Bool - /// The ink a price bar takes, from its level: the same height always - /// has the same ink. Levels 0–2 are the cheap third, 5–7 the dear. - public static func third(_ level: Int) -> PriceChart.Third { - level <= 2 ? .cheap : level <= 4 ? .middle : .dear - } - /// The strip at the moment's minute, from the plan the moment was read /// from. public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool) -> PlanStrip { @@ -126,7 +126,7 @@ public struct PlanStrip: Equatable, Sendable { let known = minor.compactMap { $0 } let lo = known.min() ?? 0, hi = known.max() ?? 0 - let levels = minor.map { $0.map { level($0, lo: lo, hi: hi) } } + let thirds = minor.map { $0.map { third($0, lo: lo, hi: hi) } } var landmarks: Set = [0, count - 1] let inside = prices.filter { $0.startMs + $0.durationMs > nowMs && $0.startMs < grid.toMs } @@ -188,11 +188,14 @@ public struct PlanStrip: Equatable, Sendable { battery: battery, car: car, goals: goals, - levels: levels, + minor: minor, + thirds: thirds, + scale: PriceScale(known, currency: currency), + nowMinor: minor.first ?? nil, batteryRuns: runs(battery), carRuns: runs(car.map { $0 ? true : nil }), - priceRuns: runs(levels), - pending: runs(levels.map { $0 == nil ? true : nil }), + priceRuns: runs(minor), + pending: runs(minor.map { $0 == nil ? true : nil }), hours: hours, landmarks: landmarks, readouts: readouts, @@ -215,11 +218,12 @@ public struct PlanStrip: Equatable, Sendable { private var count: Int { battery.count } - /// The price in eight levels within the window, the dearest at 7. A flat - /// window sits in the middle rather than at either end. - static func level(_ v: Double, lo: Double, hi: Double) -> Int { - guard hi - lo >= 0.5 else { return levels / 2 - 1 } - return min(levels - 1, max(0, Int(((v - lo) / (hi - lo) * Double(levels)).rounded(.down)))) + /// Which third of the window's range a price falls in. A flat window + /// is the middle, not the cheap end. + static func third(_ v: Double, lo: Double, hi: Double) -> PriceChart.Third { + guard hi - lo >= 0.5 else { return .middle } + let third = (hi - lo) / 3 + return v < lo + third ? .cheap : v >= lo + 2 * third ? .dear : .middle } /// Stretches of equal values; nil ends a stretch and starts none. diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceScale.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceScale.swift new file mode 100644 index 00000000..51a138f4 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceScale.swift @@ -0,0 +1,73 @@ +import Foundation + +/// A price axis that stands on zero, as elklocka.se draws one: every bar +/// starts at 0 and the top is a round figure just over the dearest price, +/// so 440 against 550 öre looks a fifth dearer, not five times. Erik read +/// the old strip, which put the window's cheapest at the floor, as "five +/// times dearer tonight" (1 Nov 2026). A price below zero hangs under the +/// zero line, on a round figure of its own. +/// +/// Now's strip, the plan's price chart, the watch and the widgets all +/// scale their bars with this, so the same price always has the same +/// height on one screen. +public struct PriceScale: Equatable, Sendable { + /// The round price the top of the plot stands for, in minor units. + public let top: Double + /// Zero, or a round price under it when the window holds one below zero. + public let bottom: Double + /// The prices the grid lines mark: half the top and the top. + public let lines: [Double] + public let currency: String + + /// The scale for `values`, in minor units per kWh. With nothing priced + /// it still has a top, so a strip keeps its shape. The currency names + /// the figures; the round top is the same in any unit a power of ten + /// apart. + public init(_ values: [Double], currency: String = "") { + let finite = values.filter(\.isFinite) + let unit = PriceUnits.unit(currency).scale + // Round in the unit drawn (öre, cent, Kč), then back to minor. + let hi = max(finite.max() ?? 0, 0) * unit + let lo = min(finite.min() ?? 0, 0) * unit + let top = Self.round(max(hi, unit)) / unit + self.top = top + bottom = lo < 0 ? -Self.round(-lo) / unit : 0 + lines = [top / 2, top] + self.currency = currency + } + + /// Where `minor` sits: 0 at the bottom, 1 at the top. + public func fraction(_ minor: Double) -> Double { + guard top > bottom else { return 0 } + return min(1, max(0, (minor - bottom) / (top - bottom))) + } + + /// Where zero sits: 0 unless a price falls below it. + public var zero: Double { fraction(0) } + + /// The block-glyph step, 1 to 8 (▁ to █), from zero to the top. Zero + /// and anything under it is the lowest step. + public func step(_ minor: Double) -> Int { + guard top > 0, minor.isFinite else { return 1 } + return max(1, min(8, Int((max(0, minor) / top * 8 - 1e-9).rounded(.up)))) + } + + /// A grid line's label in the unit drawn, with no trailing zero: + /// "300", "2,5". + public func label(_ minor: Double) -> String { + let shown = PriceUnits.display(minor, currency) + let decimals = (0...2).first { abs(shown * pow(10, Double($0)) - (shown * pow(10, Double($0))).rounded()) < 1e-6 } ?? 2 + return Words.decimal(PowerFormat.fixed(shown, decimals)) + } + + /// The first of 1, 1.5, 2, 2.5, 3, 4, 5, 6 or 8 times a power of ten at + /// or over `v`: 556 gives 600, 182 gives 200, 142 gives 150. + static func round(_ v: Double) -> Double { + guard v.isFinite, v > 0 else { return 1 } + let decade = pow(10, floor(log10(v))) + for m in [1, 1.5, 2, 2.5, 3, 4, 5, 6, 8, 10] where m * decade >= v * (1 - 1e-9) { + return m * decade + } + return 10 * decade + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift b/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift index 5a5fa8c6..a4d1186a 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PriceBlocks.swift @@ -1,7 +1,7 @@ import Foundation -/// The block-glyph scale, ▁▂▃▄▅▆▇█: a price as one of eight levels between -/// the lowest and the highest in view, and its ink by thirds of that range. +/// The block-glyph scale, ▁▂▃▄▅▆▇█: a price as one of eight steps from zero +/// to a round top (`PriceScale`), and its ink by thirds of the range in view. /// The small price widget draws it as bars and the lock screen writes it in /// SF Mono, from the same hours, so the two always agree. public enum PriceBlocks { @@ -39,10 +39,11 @@ public enum PriceBlocks { return (0.. [Int] { - let lo = values.min() ?? 0, hi = values.max() ?? 0 - return values.map { v in hi > lo ? 1 + min(7, Int(((v - lo) / (hi - lo) * 8).rounded(.down))) : 4 } + let scale = PriceScale(values) + return values.map(scale.step) } /// The cheap, middle or dear third of the range; middle when flat. diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 52941609..68af9131 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -24,7 +24,7 @@ import Testing #expect(s.grid.firstMs == at(12, 20)) #expect(s.grid.toMs == at(13, 8)) #expect(s.nowMs == at(12, 20, 5)) - #expect(s.readouts.count == 48 && s.levels.count == 48 && s.battery.count == 48 && s.car.count == 48) + #expect(s.readouts.count == 48 && s.minor.count == 48 && s.battery.count == 48 && s.car.count == 48) } /// Each lane draws one shape per stretch of equal quarters, so a run @@ -52,18 +52,21 @@ import Testing #expect(s.goals == [34: 80]) } - /// The price in eight levels across the window, and equal levels as one - /// bar: 160 öre is the top, 30 the bottom, 70 sits at level 2. - @Test func thePriceIsEightLevelsInRuns() { + /// The price stands on zero under a round top, and equal prices are one + /// bar: 160 öre reaches four fifths of a 200 öre top, so 30 öre is a + /// fifth of 160, as it is, not the floor against the roof. + @Test func thePriceStandsOnZeroInRuns() { let s = strip(at: at(12, 20, 5)) - #expect(s.priceRuns.map(\.value) == [7, 2, 0, 2, 7]) + #expect(s.priceRuns.map(\.value) == [160, 70, 30, 70, 160]) #expect(s.priceRuns.map(\.count) == [4, 20, 8, 8, 8]) - // The same height always takes the same ink. - #expect(PlanStrip.third(0) == .cheap && PlanStrip.third(2) == .cheap) - #expect(PlanStrip.third(3) == .middle && PlanStrip.third(4) == .middle) - #expect(PlanStrip.third(5) == .dear && PlanStrip.third(7) == .dear) - // A flat window sits in the middle. - #expect(PlanStrip.level(70, lo: 70, hi: 70) == 3) + #expect(s.scale.top == 200 && s.scale.bottom == 0 && s.scale.lines == [100, 200]) + #expect(s.scale.fraction(160) == 0.8 && s.scale.fraction(30) == 0.15) + // The price now carries on as the dashed line. + #expect(s.nowMinor == 160) + // Ink by thirds of the window's range: 30 cheap, 70 cheap, 160 dear. + #expect(s.thirds[0] == .dear && s.thirds[5] == .cheap && s.thirds[24] == .cheap) + // A flat window is the middle third, not the cheap one. + #expect(PlanStrip.third(70, lo: 70, hi: 70) == .middle) } /// Under a finger the line and the header say the quarter's price in @@ -284,7 +287,7 @@ import Testing 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(s.minor[31] != nil && s.minor[32] == nil) #expect(strip(at: at(12, 20, 5)).pending.isEmpty) } diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift index 7b5a72b4..0e91c699 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceGlanceTests.swift @@ -261,11 +261,12 @@ import Testing // MARK: The block strip /// Twelve hours from the one that holds now, each the mean of its - /// slots, as one of eight levels and a third of the range: the small + /// slots, as one of eight steps from zero and a third of the range: the small /// widget's bars and the lock screen's glyphs. @Test func theStripIsTwelveHoursOfBlocks() { let quarter = Self.hourMs / 4 - // Quarters: hour h costs 10 × h, so the strip climbs one step a hour. + // Quarters: hour h costs 10 × h, so the strip climbs from 60 öre to + // 170 under a 200 öre top. let r = read(slots(from: at(12, 0), count: 96, dtMs: quarter) { i in Double(10 * (i / 4)) }, readAtMs: at(12, 13, 5)) let m = PriceGlance.moment(r, atMs: at(12, 6, 40), calendar: calendar) #expect(m.hours.count == 12) @@ -273,22 +274,26 @@ import Testing #expect(m.hours.first?.current == true) #expect(m.hours.dropFirst().allSatisfy { !$0.current }) #expect(m.hours.map(\.minor) == (6..<18).map { Double(10 * $0) }) - #expect(m.hours.first?.level == 1 && m.hours.last?.level == 8) + #expect(m.hours.first?.level == 3 && m.hours.last?.level == 7) #expect(m.hours.first?.third == .cheap && m.hours.last?.third == .dear) #expect(m.glyphs.count == 12) - #expect(m.glyphs.first == "▁" && m.glyphs.last == "█") + #expect(m.glyphs.first == "▃" && m.glyphs.last == "▇") // Where the market has not priced, the strip ends. let late = PriceGlance.moment(r, atMs: at(12, 17), calendar: calendar) #expect(late.hours.count == 7) #expect(late.glyphs.count == 7) } - /// A flat day is the middle of the scale, not its floor. - @Test func aFlatDayIsTheMiddleBlock() { - #expect(PriceBlocks.levels([50, 50, 50]) == [4, 4, 4]) + /// The glyphs stand on zero, so their heights keep the prices' + /// proportions: 438 and 556 öre are two steps apart, not the floor and + /// the roof. A flat day stands at its price under a round top. + @Test func theBlocksStandOnZero() { + #expect(PriceBlocks.levels([438, 556]) == [6, 8]) + #expect(PriceBlocks.levels([50, 50, 50]) == [8, 8, 8]) + #expect(PriceBlocks.levels([47, 47]) == [8, 8]) #expect(PriceBlocks.thirds([50, 50]) == [.middle, .middle]) #expect(PriceBlocks.levels([0, 100]) == [1, 8]) - #expect(PriceBlocks.levels([-10, 0, 10]) == [1, 5, 8]) + #expect(PriceBlocks.levels([-10, 0, 10]) == [1, 1, 8]) } /// Every widget says when the app fetched what it shows. diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceScaleTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceScaleTests.swift new file mode 100644 index 00000000..e81e255e --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceScaleTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price axis from zero: a round top just over the dearest, so a bar's +/// height is its price. Erik's morning, 438 to 556 öre, is the case that +/// started it. +@Suite struct PriceScaleTests { + @Test func theTopIsTheFirstRoundFigureOverTheDearest() { + #expect(PriceScale.round(556) == 600) + #expect(PriceScale.round(182) == 200) + #expect(PriceScale.round(142) == 150) + #expect(PriceScale.round(50) == 50) + #expect(PriceScale.round(1_250) == 1_500) + #expect(PriceScale.round(2.3) == 2.5) + } + + /// 438 öre stands at 73 % of the strip and 556 at 93 %: a fifth apart, + /// as the prices are, not the floor against the top. + @Test func ericsMorningReadsAsAFifthDearer() { + let s = PriceScale([462, 438, 556, 528], currency: "SEK") + #expect(s.top == 600 && s.bottom == 0) + #expect(s.lines == [300, 600]) + #expect(abs(s.fraction(438) - 0.73) < 0.001) + #expect(abs(s.fraction(556) - 0.9267) < 0.001) + #expect(s.zero == 0) + #expect(s.label(300) == "300") + } + + /// A price below zero hangs under the zero line, on a round figure. + @Test func aPriceBelowZeroHangsUnderTheLine() { + let s = PriceScale([-12, 40, 96], currency: "SEK") + #expect(s.top == 100 && s.bottom == -15) + #expect(abs(s.zero - 15.0 / 115) < 1e-9) + #expect(s.fraction(-12) < s.zero) + } + + /// Nothing priced, or a window of zeros, still has a top, so a strip + /// keeps its shape and draws nothing taller than the floor. + @Test func anEmptyWindowKeepsItsShape() { + let empty = PriceScale([], currency: "SEK") + #expect(empty.top == 1 && empty.bottom == 0) + #expect(PriceScale([0, 0], currency: "SEK").fraction(0) == 0) + } + + /// The top rounds in the unit drawn: crowns for a Czech home, where a + /// minor unit is a haléř. + @Test func theTopRoundsInTheUnitDrawn() { + let s = PriceScale([230], currency: "CZK") + #expect(s.top == 250) + #expect(s.label(250) == "2,5" || s.label(250) == "2.5") + } +} diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index 06c8caf3..f46505d7 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -324,8 +324,9 @@ import Testing @Test func eachTickHoldsItsPriceLevelAndWhatTheBatteryDoes() { let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) func q(_ day: Int, _ h: Int, _ m: Int = 0) -> PlanDial.Quarter { dial.quarters[dial.grid.index(atMs: at(day, h, m))!] } - #expect(q(14, 18).level == 7) - #expect(q(15, 2).level == 0) + // On a 200 öre top: 160 öre is the seventh step, 30 the second. + #expect(q(14, 18).level == 6) + #expect(q(15, 2).level == 1) #expect(q(14, 13).level == 2) #expect(q(14, 22).battery == .stores) #expect(q(14, 17).battery == .gives) @@ -354,10 +355,13 @@ import Testing #expect(dial.landmarks == [0, cheap.lowerBound, cheap.upperBound, dial.grid.index(atMs: at(15, 4, 30))!]) } - @Test func aFlatDaySitsInTheMiddle() { - #expect(PlanDial.level(50, lo: 50, hi: 50) == 3) - #expect(PlanDial.level(50, lo: 0, hi: 100) == 4) - #expect(PlanDial.level(100, lo: 0, hi: 100) == 7) + /// A tick stands on zero like every other price bar: half the top is + /// the fourth step, and a price under zero the lowest. + @Test func aTickStandsOnZero() { + let scale = PriceScale([0, 100]) + #expect(scale.step(50) - 1 == 3) + #expect(scale.step(100) - 1 == 7) + #expect(scale.step(-5) - 1 == 0) } // MARK: The car, cheap power and the complication diff --git a/appleApp/FTWWatchShared/BlockBars.swift b/appleApp/FTWWatchShared/BlockBars.swift index 6ac2d1ba..d267a0c7 100644 --- a/appleApp/FTWWatchShared/BlockBars.swift +++ b/appleApp/FTWWatchShared/BlockBars.swift @@ -3,7 +3,7 @@ import SwiftUI import WidgetKit /// The hours ahead as the block-glyph scale (▁▂▃▄▅▆▇█): one bar per slot, -/// its height the price in eight levels. Ink, stronger for the slot that +/// its height the price in eight steps from zero (`PriceScale`). Ink, stronger for the slot that /// holds now; never red and green, because a price is a level, not a /// state. Two still paths, so a complication draws it as cheaply as the /// app. @@ -29,15 +29,13 @@ struct BlockBars: View { } private func path(_ size: CGSize, strong wanted: Bool) -> Path { - let values = bars.map(\.minor) - let lo = values.min() ?? 0, hi = values.max() ?? 0 + let scale = PriceScale(bars.map(\.minor)) let gap: CGFloat = bars.count > 30 ? 0.5 : 1 let w = (size.width - gap * CGFloat(max(bars.count - 1, 0))) / CGFloat(max(bars.count, 1)) let step = size.height / CGFloat(PlanDial.levels) var p = Path() for (i, b) in bars.enumerated() where strong(b) == wanted { - let level = hi - lo < 0.5 ? PlanDial.levels / 2 - 1 : min(PlanDial.levels - 1, Int((b.minor - lo) / (hi - lo) * Double(PlanDial.levels))) - let h = step * CGFloat(level + 1) + let h = step * CGFloat(scale.step(b.minor)) p.addRect(CGRect(x: CGFloat(i) * (w + gap), y: size.height - h, width: max(0.6, w), height: h)) } return p diff --git a/appleApp/FTWWatchShared/DialStrip.swift b/appleApp/FTWWatchShared/DialStrip.swift index 97ca5d56..c94b2390 100644 --- a/appleApp/FTWWatchShared/DialStrip.swift +++ b/appleApp/FTWWatchShared/DialStrip.swift @@ -3,7 +3,7 @@ import SwiftUI import WidgetKit /// The dial unrolled: the next hours from now on the left, one bar per -/// quarter, its height the price in eight levels and its colour what the +/// quarter, its height the price in eight steps from zero and its colour what the /// battery does (cyan stores, ink gives, ink3 waits), the car's charging as /// dots under it. A quarter with no price yet is a stub. Still paths, one /// fill each, for a complication. A plan, so in full colour its hues are a diff --git a/appleApp/FTWWidgets/Plan/PlanViews.swift b/appleApp/FTWWidgets/Plan/PlanViews.swift index 8ffc744e..1fcb6480 100644 --- a/appleApp/FTWWidgets/Plan/PlanViews.swift +++ b/appleApp/FTWWidgets/Plan/PlanViews.swift @@ -290,8 +290,8 @@ struct PlanLanes: View { } /// The prices under the plan as Now's strip draws them: a bar per slot, - /// slot width less 1 px, its height one of eight levels and its ink by - /// thirds of the window. No red and no green. + /// slot width less 1 px, its height one of eight steps from zero and its + /// ink by thirds of the window. No red and no green. private func prices(x: (Double) -> CGFloat, top: CGFloat, width: CGFloat, px: CGFloat, full: Bool) -> some View { let bars = moment.backdrop let minor = bars.map(\.minor) From 389c79ccdc63941301c9f27f01d26317898713d4 Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 15:17:41 +0100 Subject: [PATCH 2/4] feat(ios): Now chooses a pass and shows its figures Fredrik picked proposal 5 for Now on 1 November 2026. Each run of the battery and each car window on the strip is a pass (PlanPass, FTWShared, tested). A tap chooses one: it stands in full colour over a faint column of its colour, the bars under it take that colour, and three figures under the strip say when it runs, what it does to the level and the price it meets. The strip opens on the battery's run under way or next. A run on spare sun says it buys nothing. With no pass, the sentence stays; under a finger, the quarter's line still takes its place. The colours are Phosphor's, not the proposal's: gold gives power, cyan stores, violet for the car. Nothing glows, since a plan is not live. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- appleApp/FTW/UI/PassFigures.swift | 50 +++++++ appleApp/FTW/UI/PlanStrip.swift | 97 +++++++++++-- appleApp/FTWCharts/PlanStripDrawing.swift | 99 ++++++++++++-- .../Sources/FTWShared/Charts/PlanPass.swift | 129 ++++++++++++++++++ .../Sources/FTWShared/Charts/PlanStrip.swift | 4 + .../FTWKit/Sources/FTWShared/PlanGlance.swift | 17 +++ .../FTWShared/Resources/sv.lproj/Plan.strings | 18 +++ .../Tests/FTWSharedTests/PlanStripTests.swift | 37 +++++ 9 files changed, 437 insertions(+), 22 deletions(-) create mode 100644 appleApp/FTW/UI/PassFigures.swift create mode 100644 appleApp/FTWKit/Sources/FTWShared/Charts/PlanPass.swift diff --git a/CLAUDE.md b/CLAUDE.md index 794cd065..af640647 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -411,7 +411,13 @@ draw that one face. The plan view opens on the dial; the lanes are one tap away in the toolbar, and the phone keeps the viewer's choice. A drag round the ring costs one lookup per event, and only a change of quarter redraws, and then only the hand and the middle. Now keeps its straight -strip, with gold for the battery giving power. +strip, with gold for the battery giving power. Each battery run and car +window on it is a pass (`PlanPass`, proposal 5, picked by Fredrik on +1 Nov 2026): a tap chooses one, which then stands in full colour over a +faint column of its colour, with the bars under it in that colour, and +three figures under the strip say when it runs, what it does to the +level and the price it meets. The strip opens on the battery's run under +way or next; with no pass, the sentence stays. ## Non-negotiable invariants diff --git a/appleApp/FTW/UI/PassFigures.swift b/appleApp/FTW/UI/PassFigures.swift new file mode 100644 index 00000000..0bbde43c --- /dev/null +++ b/appleApp/FTW/UI/PassFigures.swift @@ -0,0 +1,50 @@ +import FTWKit +import SwiftUI + +/// The three figures for the pass a tap chose, under Now's strip and under +/// the plan: when it runs, what it does to the level, and the price it +/// meets. Each a mono title, a figure and a line, with space between them +/// and no borders. The first title takes the pass's colour; a plan that is +/// old reads in ink3. It swaps with the pass, with no animation, so a tap +/// answers at once. +struct PassFigures: View, Equatable { + let pass: PlanPass + var grey = false + @ScaledMetric(relativeTo: .title3) private var figure: CGFloat = 21 + + nonisolated static func == (a: Self, b: Self) -> Bool { a.pass == b.pass && a.grey == b.grey } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + ForEach(Array(pass.figures.enumerated()), id: \.offset) { i, f in + VStack(alignment: i == 2 ? .trailing : .leading, spacing: 1) { + Text(f.title) + .font(Theme.mono()) + .tracking(0.5) + .foregroundStyle(grey ? Theme.ink3 : tint(f.tint)) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + Text(f.value) + .font(Theme.figure(figure)) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink) + .minimumScaleFactor(0.7) + Text(f.sub) + .font(.caption) + .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: i == 2 ? .trailing : .leading) + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(pass.spoken) + } + + private func tint(_ t: PlanPass.Figure.Tint) -> Color { + switch t { + case .gives: return Theme.gives + case .stores: return Theme.stores + case .car: return Theme.car + case .ink: return Theme.ink2 + } + } +} diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 06ecd3fe..1cf80a25 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -8,8 +8,13 @@ import SwiftUI /// takes the height Now gives it, so on a tall phone the bars read as a /// chart. /// -/// The price in the header opens the price; a tap anywhere else opens the -/// plan, as the chevron by `NEXT 12 H` says. A drag of 4 pt or more reads +/// Under the strip, the three figures of one pass (proposal 5, picked by +/// Fredrik on 1 Nov 2026): the battery's run under way or next, until a +/// tap on another run or car window chooses it. The chosen pass is drawn +/// in full colour with the bars under it in its colour. +/// +/// The price in the header opens the price; a tap on a pass chooses it; a +/// tap anywhere else opens the plan, as the chevron by `NEXT 12 H` says. A drag of 4 pt or more reads /// the quarters one at a time: the quarter under the finger lights, the /// header names its time and price, the sentence goes at once and what /// happens in that quarter fades in, `.selection` ticks per quarter and a @@ -36,6 +41,10 @@ struct PlanStripView: View, Equatable { @State private var scrub = ScrubModel() @State private var width: CGFloat = 0 + /// The pass a tap chose, by id, so it holds while the plan stands. + @State private var chosenID: String? + /// Where the strip's drawing sits, for the passes' tap targets. + @State private var canvas: CGRect = .zero @GestureState private var dragging = false @ScaledMetric(relativeTo: .caption2) private var kicker: CGFloat = 10.5 @ScaledMetric(relativeTo: .caption2) private var axis: CGFloat = 10 @@ -50,6 +59,12 @@ struct PlanStripView: View, Equatable { private var layout: PlanStripLayout { PlanStripLayout(axis: min(axis, 15)) } private var mono: Font { .system(size: min(kicker, 15), weight: .medium, design: .monospaced) } + /// The pass the figures show: the one a tap chose while the plan still + /// has it, else the one the strip opens on. + private var chosen: PlanPass? { + strip.passes.first { $0.id == chosenID } ?? PlanPass.opening(strip.passes) + } + var body: some View { ZStack(alignment: .topTrailing) { NavigationLink(value: HomeRoute.plan) { @@ -71,17 +86,26 @@ struct PlanStripView: View, Equatable { .accessibilityIdentifier("now.price") } } + .overlay(alignment: .topLeading) { + PassTargets(strip: strip, layout: layout, chosenID: $chosenID) + .frame(width: canvas.width, height: canvas.height, alignment: .topLeading) + .offset(x: canvas.minX, y: canvas.minY) + } + .coordinateSpace(.named(Self.space)) + .sensoryFeedback(.selection, trigger: chosenID) } + private static let space = "now.strip" + private var plan: some View { VStack(alignment: .leading, spacing: 8) { header ZStack { - ChartSurface(drawing: PlanStripDrawing(strip: strip, layout: layout)) + ChartSurface(drawing: PlanStripDrawing(strip: strip, layout: layout, chosen: chosen)) .equatable() if let fade { MovedFade(fresh: fade.fresh, mask: QuarterColumns(grid: fade.old.grid, quarters: fade.moved, trailing: layout.gutter)) { - ChartSurface(drawing: PlanStripDrawing(strip: fade.old, layout: layout)) + ChartSurface(drawing: PlanStripDrawing(strip: fade.old, layout: layout, chosen: fade.old.passes.first { $0.id == chosen?.id })) .equatable() } .id(fade.atMs) @@ -90,7 +114,8 @@ struct PlanStripView: View, Equatable { } // The price row takes the height Now gives the strip. .frame(minHeight: layout.height, maxHeight: layout.tallest) - StripSentence(sentence: sentence, shorter: shorter, placeholder: placeholder, grey: strip.grey, readouts: strip.readouts, model: scrub) + .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.space)) } action: { canvas = $0 } + StripSentence(sentence: sentence, shorter: shorter, placeholder: placeholder, grey: strip.grey, pass: chosen, readouts: strip.readouts, model: scrub) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -188,8 +213,9 @@ private struct StripPrice: View { } } -/// The strip's two reserved lines: the next thing the box will do, or, -/// under a finger, what happens in that quarter. When the sentence needs a +/// Under the strip: the chosen pass's figures, or with no pass the next +/// thing the box will do in two reserved lines; under a finger, what +/// happens in that quarter. When the sentence needs a /// third line at this width, it drops its later clauses rather than end in /// "…". On touch-down the sentence goes at once and the quarter's line /// fades in over 0.1 s, so the two never sit on top of each other; on lift @@ -199,6 +225,7 @@ private struct StripSentence: View { let shorter: [String] let placeholder: Bool let grey: Bool + let pass: PlanPass? let readouts: [String] let model: ScrubModel @Environment(\.dynamicTypeSize) private var typeSize @@ -210,9 +237,15 @@ private struct StripSentence: View { return readouts[i] }() ZStack(alignment: .topLeading) { - said - .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) - .redacted(reason: placeholder ? .placeholder : []) + Group { + if let pass, !placeholder { + PassFigures(pass: pass, grey: grey) + } else { + said + .foregroundStyle(grey ? Theme.ink3 : Theme.ink2) + .redacted(reason: placeholder ? .placeholder : []) + } + } .opacity(down ? 0 : 1) // Back only once the quarter's line has gone. .animation(down ? nil : .easeOut(duration: 0.2).delay(0.1), value: down) @@ -255,3 +288,47 @@ private struct StripSentence: View { } } } + +/// A tap target over each pass on the strip's lanes, as wide as its run and +/// at least 28 pt, from just above the battery's lane to the top of the +/// bars. Where a battery run and a car window share the same minutes, each +/// takes its own lane's half. A tap chooses the pass; VoiceOver reads each +/// as a button that says the pass. +private struct PassTargets: View { + let strip: PlanStrip + let layout: PlanStripLayout + @Binding var chosenID: String? + + var body: some View { + GeometryReader { geo in + let width = layout.plotWidth(geo.size.width) + ForEach(strip.passes) { pass in + let r = rect(pass, width: width) + Button { + chosenID = pass.id + } label: { + Color.clear + .frame(width: r.width, height: r.height) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .position(x: r.midX, y: r.midY) + .accessibilityLabel(pass.spoken) + .accessibilityAddTraits(pass.id == (strip.passes.first { $0.id == chosenID } ?? PlanPass.opening(strip.passes))?.id ? .isSelected : []) + } + } + } + + private func rect(_ pass: PlanPass, width: CGFloat) -> CGRect { + let x0 = strip.grid.x(atMs: strip.grid.startMs(pass.first), width: width) + let x1 = strip.grid.x(atMs: strip.grid.endMs(pass.last), width: width) + let w = max(28, x1 - x0) + let x = min(max(0, (x0 + x1) / 2 - w / 2), max(0, width - w)) + let shared = strip.passes.contains { $0.isBattery != pass.isBattery && $0.first <= pass.last && $0.last >= pass.first } + let split = PlanStripLayout.carY - 2 + let top: CGFloat = -8, bottom = PlanStripLayout.priceTop + let y0 = shared && !pass.isBattery ? split : top + let y1 = shared && pass.isBattery ? split : bottom + return CGRect(x: x, y: y0, width: w, height: y1 - y0) + } +} diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index bc571eac..92878cbe 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -82,9 +82,25 @@ struct PlanStripLayout: Equatable { /// ghost: their colour at `Theme.plan`, never the full strength that says /// live. A plan that is old, that the box could not make, or from a box /// this phone cannot reach, is ink3 throughout. It never sees a finger. +/// +/// The pass a tap chose stands out (Now, proposal 5): its run in full +/// colour, a faint column of its colour behind it, and the bars under it in +/// its colour, so the prices it meets read at a glance. The bars under the +/// battery's other runs take their colour faintly. Nothing glows: a plan +/// is not live. struct PlanStripDrawing: ChartDrawing { let strip: PlanStrip var layout = PlanStripLayout() + /// The pass a tap chose; nil draws every run as a ghost. + var chosen: PlanPass? + + static func hue(_ kind: PlanPass.Kind) -> Color { + switch kind { + case .gives: return Theme.gives + case .stores: return Theme.stores + case .car: return Theme.car + } + } func draw(in g: inout GraphicsContext, size: CGSize) { let layout = self.layout.fitting(size.height) @@ -96,6 +112,22 @@ struct PlanStripDrawing: ChartDrawing { let grey = strip.grey let ghost = Theme.plan(g.environment.colorSchemeContrast) let oldInk = Theme.oldPlan + let chosen = grey ? nil : self.chosen + func inChosen(_ first: Int, _ last: Int, battery: Bool) -> Bool { + guard let chosen, chosen.isBattery == battery else { return false } + return first >= chosen.first && last <= chosen.last + } + + // The chosen pass's column, behind everything: its colour fading + // from the lanes down to the bars' foot. + if let chosen { + let s = PlanStripLayout.span(first: chosen.first, last: chosen.last, grid: grid, width: width, scale: scale) + let x0 = max(0, s.x0 - 3), x1 = min(width, s.x1 + 3) + let rect = CGRect(x: x0, y: 0, width: x1 - x0, height: layout.priceBottom) + let hue = Self.hue(chosen.kind) + g.fill(Path(roundedRect: rect, cornerRadius: 5, style: .continuous), + with: .linearGradient(Gradient(colors: [hue.opacity(0.16), hue.opacity(0.03)]), startPoint: CGPoint(x: rect.midX, y: rect.minY), endPoint: CGPoint(x: rect.midX, y: rect.maxY))) + } // A lane with nothing keeps a hairline, so a run never appears out // of nothing. @@ -112,16 +144,20 @@ struct PlanStripDrawing: ChartDrawing { return Path(roundedRect: rect, cornerRadius: min(3, rect.width / 2), style: .circular) } - var stores = Path(), gives = Path() + var stores = Path(), gives = Path(), lit = Path() for run in strip.batteryRuns { let p = capsule(run.first, run.last, y: PlanStripLayout.batteryY) - if run.value == .stores { stores.addPath(p) } else { gives.addPath(p) } + if inChosen(run.first, run.last, battery: true) { lit.addPath(p) } else if run.value == .stores { stores.addPath(p) } else { gives.addPath(p) } } g.fill(stores, with: .color(grey ? oldInk : Theme.stores.opacity(ghost))) g.fill(gives, with: .color(grey ? oldInk : Theme.gives.opacity(ghost))) var car = Path() - for run in strip.carRuns { car.addPath(capsule(run.first, run.last, y: PlanStripLayout.carY)) } + for run in strip.carRuns { + let p = capsule(run.first, run.last, y: PlanStripLayout.carY) + if inChosen(run.first, run.last, battery: false) { lit.addPath(p) } else { car.addPath(p) } + } + if let chosen { g.fill(lit, with: .color(Self.hue(chosen.kind))) } // The goal: a 2 pt tick at the end of its quarter, past the lane. for (i, _) in strip.goals { let s = PlanStripLayout.span(first: i, last: i, grid: grid, width: width, scale: scale) @@ -143,15 +179,16 @@ struct PlanStripDrawing: ChartDrawing { g.draw(text, at: CGPoint(x: width + 4, y: layout.priceY(v, priceScale)), anchor: .leading) } - // The price: a bar per stretch of equal price, ink by third. - var thirds: [PriceChart.Third: Path] = [:] - for run in strip.priceRuns { - let s = PlanStripLayout.span(first: run.first, last: run.last, grid: grid, width: width, scale: scale) - let third = strip.thirds[run.first] ?? .middle - thirds[third, default: Path()].addPath(layout.bar(run.value, priceScale, x0: s.x0, x1: s.x1)) + // The price: a bar per stretch of equal price and equal ink. Ink by + // third, or the colour of the pass over it: full under the chosen + // pass, faint under the battery's other runs. + var inks: [BarInk: Path] = [:] + for seg in segments(chosen: chosen) { + let s = PlanStripLayout.span(first: seg.first, last: seg.last, grid: grid, width: width, scale: scale) + inks[seg.ink, default: Path()].addPath(layout.bar(seg.minor, priceScale, x0: s.x0, x1: s.x1)) } - for (third, path) in thirds { - g.fill(path, with: .color(grey ? Theme.ink3.opacity(0.6) : Theme.ink.opacity(Self.ink(third)))) + for (ink, path) in inks { + g.fill(path, with: .color(grey ? Theme.ink3.opacity(0.6) : ink.color(chosen: chosen))) } // Not priced yet: a hatched stub, so a guess never looks like a price. if !strip.pending.isEmpty { @@ -196,6 +233,46 @@ struct PlanStripDrawing: ChartDrawing { } } + /// What a price bar is filled with. + enum BarInk: Hashable { + case third(PriceChart.Third) + /// Under the chosen pass; dear is the top third. + case chosen(dear: Bool) + /// Under another run of the battery. + case battery(PlanStrip.Battery, dear: Bool) + + func color(chosen: PlanPass?) -> Color { + switch self { + case let .third(t): return Theme.ink.opacity(PlanStripDrawing.ink(t)) + case let .chosen(dear): return PlanStripDrawing.hue(chosen?.kind ?? .gives).opacity(dear ? 0.9 : 0.7) + case let .battery(b, dear): return (b == .stores ? Theme.stores : Theme.gives).opacity(dear ? 0.5 : 0.36) + } + } + } + + /// The price runs cut where the ink changes, so each piece is one fill. + private func segments(chosen: PlanPass?) -> [(first: Int, last: Int, minor: Double, ink: BarInk)] { + func ink(_ i: Int) -> BarInk? { + guard strip.minor[i] != nil else { return nil } + let third = strip.thirds[i] ?? .middle + if let chosen, i >= chosen.first, i <= chosen.last, !chosen.isBattery || strip.battery[i] != nil { + return .chosen(dear: third == .dear) + } + if let b = strip.battery[i], chosen != nil { return .battery(b, dear: third == .dear) } + return .third(third) + } + var out: [(first: Int, last: Int, minor: Double, ink: BarInk)] = [] + for i in strip.minor.indices { + guard let m = strip.minor[i], let k = ink(i) else { continue } + if let prev = out.last, prev.last == i - 1, prev.minor == m, prev.ink == k { + out[out.count - 1].last = i + } else { + out.append((i, i, m, k)) + } + } + return out + } + /// The design's thirds: the dearer, the stronger. static func ink(_ third: PriceChart.Third) -> Double { switch third { diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanPass.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanPass.swift new file mode 100644 index 00000000..387bc758 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanPass.swift @@ -0,0 +1,129 @@ +import Foundation + +/// One pass of the plan on a strip of quarters: a run of the battery giving +/// power or storing, or a window in which a car charges. A tap on Now's +/// strip, or on the plan, chooses one; the row of three figures under the +/// price then says when it runs, what it does to the level, and the price +/// it meets (Now, proposal 5 on the design canvas, picked by Fredrik on +/// 1 Nov 2026). Worked out with the strip, so a tap is a lookup. +public struct PlanPass: Equatable, Sendable, Identifiable { + public enum Kind: String, Equatable, Sendable { + case gives, stores, car + } + + /// One of the three figures: a mono title, a figure and a line under it. + public struct Figure: Equatable, Sendable { + /// The title's ink: the pass's colour for the first, ink2 after. + public enum Tint: Equatable, Sendable { + case gives, stores, car, ink + } + + public let title: String + public let tint: Tint + public let value: String + public let sub: String + } + + public let kind: Kind + public let startMs: Double + public let endMs: Double + /// The quarters of the strip it covers, cut to the strip. + public let first: Int + public let last: Int + /// When it runs, what it does to the level, and the price it meets. + public let figures: [Figure] + /// The pass in one line, for VoiceOver. + public let spoken: String + public var id: String { "\(kind.rawValue)-\(Int64(startMs))" } + + public var isBattery: Bool { kind != .car } + + /// The passes in a strip's window, in order: each battery run and each + /// car window that ends after `nowMs` and starts before the strip ends. + /// `minor` is the strip's price per quarter, so a pass's prices are the + /// ones its bars draw. + public static func passes(_ entry: PlanEntry?, grid: SlotGrid, nowMs: Double, minor: [Double?], clock: ZoneClock) -> [PlanPass] { + guard let entry else { return [] } + let currency = entry.currency + let unit = PriceUnits.unit(currency).label + let slots = (entry.battery ?? []).sorted { $0.startMs < $1.startMs } + func price(_ m: Double) -> String { PriceStrip.whole(m, currency) + " " + unit } + func energy(_ wh: Double) -> String { Words.decimal(EnergyFormat.label(wh)) } + + return PlanGlance.events(entry, fromMs: nowMs, toMs: grid.toMs).compactMap { e -> PlanPass? in + let kind: Kind + switch e.kind { + case .discharge: kind = .gives + case .charge: kind = .stores + case .car: kind = .car + case .cheap: return nil + } + guard let first = grid.index(atMs: max(e.startMs, grid.firstMs)), + let last = grid.index(atMs: min(e.endMs, grid.toMs) - 1), last >= first else { return nil } + let prices = (first...last).compactMap { minor[$0] } + let underWay = e.startMs <= nowMs + PlanGlance.graceMs + let start = underWay ? PlanWord.now.text : clock.label(e.startMs) + let until = PlanWord.until.format(clock.label(e.endMs)) + let top = prices.max().map(price) ?? "—" + let mean = prices.isEmpty ? "—" : price(prices.reduce(0, +) / Double(prices.count)) + + var figures: [Figure] + var what: String + switch kind { + case .gives, .stores: + // The energy the run moves from now on, and the level it + // starts at where the plan says one: the slot that ends as + // it starts. + var wh = 0.0 + for s in slots where s.startMs < e.endMs && s.startMs + s.durationMs > max(e.startMs, nowMs) { + let overlap = min(e.endMs, s.startMs + s.durationMs) - max(s.startMs, e.startMs, nowMs) + wh += abs(s.batteryW) * max(0, overlap) / 3_600_000 + } + let before = underWay ? nil : slots.last { abs($0.startMs + $0.durationMs - e.startMs) < 1 }?.levelPct + let to = e.endLevelPct.map(PlanGlance.percent) + let level = switch (before, to) { + case let (a?, b?): "\(Int(a.rounded())) → " + b + case let (nil, b?): "→ " + b + default: "—" + } + let gives = kind == .gives + let third: Figure + if gives { + third = Figure(title: PlanWord.passTopPrice.text, tint: .ink, value: top, sub: PlanWord.passDuring.text) + } else if e.reason == "solar_surplus" { + third = Figure(title: PlanWord.passSource.text, tint: .ink, value: PlanWord.passSun.text, sub: PlanWord.passNoPurchase.text) + } else { + third = Figure(title: PlanWord.passMeanPrice.text, tint: .ink, value: mean, sub: PlanWord.passFromGrid.text) + } + figures = [ + Figure(title: PlanWord.passBattery.text, tint: gives ? .gives : .stores, value: start, sub: until), + Figure(title: PlanWord.passLevel.text, tint: .ink, value: level, sub: (gives ? PlanWord.passGivesEnergy : PlanWord.passStoresEnergy).format(energy(wh))), + third, + ] + what = (gives ? PlanWord.batteryGiving : PlanWord.batteryCharging).text + + ", " + start.lowercased() + " " + until + ", " + third.title.lowercased() + " " + third.value + case .car: + let goal: Figure + if let pct = e.targetPct { + let by = e.goalMs.map { PlanWord.passReadyBy.format(clock.label($0)) } ?? (e.chargesAgain ? PlanWord.passAgainLater.text : "") + goal = Figure(title: PlanWord.passGoal.text, tint: .ink, value: (e.chargesAgain ? PlanWord.passToward.format(PlanGlance.percent(pct)) : PlanGlance.percent(pct)), sub: by) + } else { + goal = Figure(title: PlanWord.passGoal.text, tint: .ink, value: "—", sub: "") + } + figures = [ + Figure(title: PlanWord.passCar.text, tint: .car, value: start, sub: until), + goal, + Figure(title: PlanWord.passMeanPrice.text, tint: .ink, value: mean, sub: e.energyWh.map(energy) ?? ""), + ] + what = PlanWord.car.text + ", " + start.lowercased() + " " + until + ", " + goal.title.lowercased() + " " + goal.value + } + return PlanPass(kind: kind, startMs: e.startMs, endMs: e.endMs, first: first, last: last, figures: figures, spoken: what) + } + } + + /// The pass a strip opens on: the battery's run under way or next, + /// else the first car window. + public static func opening(_ passes: [PlanPass]) -> PlanPass? { + passes.first(where: \.isBattery) ?? passes.first + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index ea2de5af..c3252474 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -55,6 +55,9 @@ public struct PlanStrip: Equatable, Sendable { public let carRuns: [Run] /// Stretches of equal price, drawn as one bar. public let priceRuns: [Run] + /// The battery's runs and the car's windows in the window, each with the + /// figures a tap on it shows. + public let passes: [PlanPass] /// Stretches the market has not priced yet: drawn as hatched stubs, /// never as a level. public let pending: [Run] @@ -195,6 +198,7 @@ public struct PlanStrip: Equatable, Sendable { batteryRuns: runs(battery), carRuns: runs(car.map { $0 ? true : nil }), priceRuns: runs(minor), + passes: PlanPass.passes(entry, grid: grid, nowMs: nowMs, minor: minor, clock: clock), pending: runs(minor.map { $0 == nil ? true : nil }), hours: hours, landmarks: landmarks, diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index 73e244f5..5b0bd767 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -774,6 +774,23 @@ enum PlanWord: String, CaseIterable { case carGoal = "car at %@" case belowZero = "below zero" case noPrice = "no price yet" + // The figures under the strip for the pass a finger chose. + case passBattery = "BATTERY" + case passCar = "CAR" + case passLevel = "LEVEL" + case passGoal = "GOAL" + case passTopPrice = "TOP PRICE" + case passMeanPrice = "AVG PRICE" + case passSource = "SOURCE" + case passDuring = "during the pass" + case passFromGrid = "from the grid" + case passSun = "The sun" + case passNoPurchase = "nothing bought" + case passGivesEnergy = "gives %@" + case passStoresEnergy = "stores %@" + case passReadyBy = "ready by %@" + case passToward = "toward %@" + case passAgainLater = "charges again later" var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Plan") } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index acbea387..caf892f9 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -65,3 +65,21 @@ "car at %@" = "bilen når %@"; "below zero" = "under noll"; "no price yet" = "inget pris än"; + +/* The figures under the strip for the pass a finger chose. */ +"BATTERY" = "BATTERI"; +"CAR" = "BIL"; +"LEVEL" = "NIVÅ"; +"GOAL" = "MÅL"; +"TOP PRICE" = "TOPPRIS"; +"AVG PRICE" = "SNITTPRIS"; +"SOURCE" = "KÄLLA"; +"during the pass" = "under passet"; +"from the grid" = "från nätet"; +"The sun" = "Solen"; +"nothing bought" = "inget köpt"; +"gives %@" = "ger %@"; +"stores %@" = "laddar %@"; +"ready by %@" = "klar till %@"; +"toward %@" = "mot %@"; +"charges again later" = "laddar igen senare"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift index 68af9131..2b7781bd 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanStripTests.swift @@ -69,6 +69,43 @@ import Testing #expect(PlanStrip.third(70, lo: 70, hi: 70) == .middle) } + /// Each battery run and car window is a pass a tap can choose, with the + /// three figures under the strip: when, what it does to the level, and + /// the price it meets. + @Test func thePassesAndTheirFigures() { + let s = strip(at: at(12, 20, 5)) + #expect(s.passes.map(\.kind) == [.gives, .stores, .car, .gives]) + let gives = s.passes[0] + #expect(gives.first == 0 && gives.last == 4) + #expect(gives.figures.map(\.title) == ["BATTERY", "LEVEL", "TOP PRICE"]) + #expect(gives.figures.map(\.value) == ["Now", "—", "160 öre"]) + // From now on: ten minutes of the first quarter and four more at + // 1.8 kW. + #expect(gives.figures.map(\.sub) == ["until 21:15", "gives 2.1 kWh", "during the pass"]) + #expect(gives.figures[0].tint == .gives) + let stores = s.passes[1] + #expect(stores.figures.map(\.value) == ["22:00", "—", "70 öre"]) + #expect(stores.figures.map(\.sub) == ["until 23:30", "stores 4.5 kWh", "from the grid"]) + #expect(stores.figures[0].tint == .stores) + // 01:00–04:45: an hour at 70, two at 30 and three quarters at 70. + let car = s.passes[2] + #expect(car.figures.map(\.title) == ["CAR", "GOAL", "AVG PRICE"]) + #expect(car.figures[0].value == "01:00" && car.figures[0].sub == "until 04:45") + #expect(car.figures[1].value == PlanGlance.percent(80)) + #expect(car.figures[2].value == "49 öre" && car.figures[2].sub == "42 kWh") + #expect(car.spoken.hasPrefix("Car charges, 01:00 until 04:45")) + #expect(PlanPass.opening(s.passes) == gives) + } + + /// A run that stores spare sun buys nothing, and says so. + @Test func aRunOnSpareSunBuysNothing() { + var entry = glance.night() + for i in entry.battery!.indices where entry.battery![i].batteryW > 100 { entry.battery![i].reason = "solar_surplus" } + let s = PlanStrip.build(entry, moment: PlanGlance.live(entry, nowMs: at(12, 20, 5), calendar: calendar), hasCar: true) + #expect(s.passes[1].figures[2].title == "SOURCE") + #expect(s.passes[1].figures[2].value == "The sun" && s.passes[1].figures[2].sub == "nothing bought") + } + /// Under a finger the line and the header say the quarter's price in /// whole öre, the same figure in both places. @Test func aFingerReadsTheQuarterAndBumpsOnItsLandmarks() { From 01b1f449b60762f571e1ebff1a82700d0175294b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 15:28:18 +0100 Subject: [PATCH 3/4] feat(ios): the plan as a clock, with Now's strip under it Erik, 1 November 2026: on the 24-hour dial 18 is not straight down as on a clock, so you lose the feel for when things happen and have to read the figures at the edge. Fredrik said yes to round 3 of the design canvas the same day. - The dial is a clock: one turn is the next twelve hours, 12 at the top and 18 straight down, with the turn's whole hours round the rim. Its ticks are as long as the battery's planned power and coloured by what it does, gold giving power as on the strip, cyan storing. The price leaves the dial. The phone, its widget and the watch draw the same face. - Under the dial, and as the lanes, the plan shows Now's strip: the passes, the price from zero and the chosen pass's figures. A tap on a pass lights its ticks on the dial. The lanes run the whole plan with the battery's level in a row of its own. - The old lanes, the story under the dial and the rows under the lanes go; the figures say what they said. CLAUDE.md's dial rules follow. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 39 +- appleApp/FTW/UI/NowView.swift | 5 +- appleApp/FTW/UI/PlanDialCard.swift | 10 +- appleApp/FTW/UI/PlanStrip.swift | 47 +- appleApp/FTW/UI/PlanView.swift | 609 +++--------------- appleApp/FTWCharts/PlanDialFace.swift | 83 ++- appleApp/FTWCharts/PlanStripDrawing.swift | 63 +- .../Sources/FTWShared/Charts/PlanDial.swift | 87 ++- .../Sources/FTWShared/Charts/PlanStrip.swift | 20 +- .../FTWKit/Sources/FTWShared/PlanGlance.swift | 1 + .../FTWShared/Resources/sv.lproj/Plan.strings | 1 + .../Tests/FTWSharedTests/PlanDialTests.swift | 80 ++- .../FTWSharedTests/PlanGlanceTests.swift | 8 +- .../Tests/FTWSharedTests/WatchTests.swift | 28 +- 14 files changed, 428 insertions(+), 653 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index af640647..f342a246 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -306,8 +306,8 @@ The rules below put these into practice. runs in cyan both ways wherever a trace is drawn, as the grid runs in magenta both ways: the side of zero and the word say which way it goes. Gold keeps the battery on Now's ring and strip, the plan's lanes and the - widgets, where no sun curve is drawn beside it, and the dial has given - it ink since 13 Oct 2026. + widgets, where no sun curve is drawn beside it, and on the dial, where + the sun is only a thin dashed arc on the rim (round 3, 1 Nov 2026). - **Space, not boxes.** No cards and no borders: 24 to 32 pt between groups, and a hairline only where two lists meet. `Card` keeps its spacing and draws nothing. @@ -381,22 +381,27 @@ never their graphics or brands. ### Plan dial FTW's own form, from Fredrik's film (srcfl/sourceful-films, -`product/ftw-confirmed`). His rules, as he settled them on 2026-10-13: - -1. One turn is the next 24 hours. Midnight sits at the bottom and noon at - the top, so the day's light fills the upper half. It never reads as a - 12-hour clock. -2. One tick per quarter hour, as long as the price in eight steps from - zero (`PriceScale`). Its - colour is what the plan does with the battery: `Theme.stores` stores, - `Theme.ink` gives power, `Theme.ink3` waits. On the dial, gold is the - sun's and never the battery's. +`product/ftw-confirmed`). His rules of 2026-10-13, as round 3 of the +design canvas changed them on 1 Nov 2026 (Erik: "18 is not straight down +as on a normal clock, so you lose the feel for when things happen"): + +1. A clock. One turn is the next twelve hours, 12 at the top and 6 at the + bottom, so 18:00 sits straight down. The whole hours of the turn sit + round the rim as the household's clock reads them; none on the hand or + the seam. +2. One tick per quarter hour, as long as the battery's planned power + against the most it moves in the turn (at least 1 kW). Its colour is + what the plan does with the battery: `Theme.stores` stores, + `Theme.gives` gives power, as on the strip under the dial. A quarter in + which it waits, or the plan says nothing, is a faint stub. The sun is a + thin dashed arc on the rim, never a tick. 3. A dotted arc in `Theme.car` marks when the car charges, with its goal at the end. -4. A hand marks now. A 2° gap in the rim marks the seam where +24 h meets +4. A hand marks now. A 2° gap in the rim marks the seam where +12 h meets now, and the ticks fade toward it. -5. Never a tick without a price: a quarter the market has not priced is a - stub. +5. The price is not on the dial. It stands on zero in Now's strip under + it, with the passes and their figures; a tap on a pass lights its ticks + at full strength. 6. The sun's arc on the rim comes from the box's own solar forecast, never from astronomy or the phone's location. No forecast, no arc. 7. The middle holds the time and the price, and a third line kept for @@ -408,7 +413,9 @@ and the rule for a finger; `FTWCharts/PlanDialFace.swift` draws. The phone's plan view, its `energy.ftw.plan-dial` widget (StandBy small and the lock screen's circle), the watch app and the watch's complication all draw that one face. The plan view opens on the dial; the lanes are one -tap away in the toolbar, and the phone keeps the viewer's choice. A drag +tap away in the toolbar, and the phone keeps the viewer's choice. Both +put Now's strip under them, the lanes' over the whole plan with the +battery's level in a row of its own. A drag round the ring costs one lookup per event, and only a change of quarter redraws, and then only the hand and the middle. Now keeps its straight strip, with gold for the battery giving power. Each battery run and car diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 43443b8d..5bb304b0 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -421,6 +421,8 @@ private struct PlanSection: View { let prices: PriceModel let loadpoints: LoadpointsModel let zoom: Namespace.ID + /// The pass a tap chose on the strip. + @State private var chosenID: String? var body: some View { // The plan marks the minute, not the second: it redraws once a @@ -439,7 +441,8 @@ private struct PlanSection: View { priceNow: current.map { PriceStrip.whole($0.totalMinor, window?.currency) }, unit: PriceUnits.unit(window?.currency).label.uppercased(), fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar), - zoom: zoom + zoom: zoom, + chosenID: $chosenID ) .equatable() // Capped where it is placed, so the strip's scaled sizes read the diff --git a/appleApp/FTW/UI/PlanDialCard.swift b/appleApp/FTW/UI/PlanDialCard.swift index 3e7c28e8..441f84ff 100644 --- a/appleApp/FTW/UI/PlanDialCard.swift +++ b/appleApp/FTW/UI/PlanDialCard.swift @@ -6,8 +6,8 @@ import SwiftUI /// each change of the quarter under the finger. private let signposts = OSSignposter(subsystem: "energy.ftw.app", category: .pointsOfInterest) -/// The plan dial at the top of the plan view: the next 24 hours as one -/// turn, the time and the price in the middle. A finger dragged round the +/// The plan dial at the top of the plan view: the next twelve hours as one +/// turn of a clock, the time and the price in the middle. A finger dragged round the /// ring reads the plan a quarter at a time: the hand follows, brackets /// close on its tick, the middle shows that quarter, `.selection` ticks per /// quarter and a light impact marks now, the cheapest hours' edges and the @@ -34,6 +34,8 @@ struct PlanDialCard: View { let grey: Bool /// The dial before the person's last change, while its plan stands. var fade: DialFade? + /// The time of the pass a tap chose on the strip under the dial. + var lit: ClosedRange? /// True while a finger scrubs: the page around holds still. @Binding var holds: Bool /// The page's touches, handed on. @@ -48,11 +50,11 @@ struct PlanDialCard: View { var body: some View { ZStack { - PlanDialFace(dial: dial, grey: grey) + PlanDialFace(dial: dial, grey: grey, lit: lit) .equatable() if let fade { MovedFade(fresh: fade.fresh, mask: QuarterWedges(dial: fade.old, quarters: fade.moved)) { - PlanDialFace(dial: fade.old, grey: grey) + PlanDialFace(dial: fade.old, grey: grey, lit: lit) .equatable() } .id(fade.atMs) diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index 1cf80a25..eb82376b 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -41,8 +41,16 @@ struct PlanStripView: View, Equatable { @State private var scrub = ScrubModel() @State private var width: CGFloat = 0 - /// The pass a tap chose, by id, so it holds while the plan stands. - @State private var chosenID: String? + /// The pass a tap chose, by id, so it holds while the plan stands. The + /// plan view shares it with its dial. + @Binding var chosenID: String? + /// On Now the strip opens the plan; in the plan it is the plan. + var opensPlan = true + /// The battery level's row: none on Now, room for it in the lanes. + var levelHeight: CGFloat = 0 + /// The least the price row takes: Now gives it what room it has; the + /// plan, which scrolls, asks for more. + var leastPrice: CGFloat = PlanStripLayout.leastPrice /// Where the strip's drawing sits, for the passes' tap targets. @State private var canvas: CGRect = .zero @GestureState private var dragging = false @@ -53,10 +61,11 @@ struct PlanStripView: View, Equatable { nonisolated static func == (a: Self, b: Self) -> Bool { a.strip == b.strip && a.sentence == b.sentence && a.shorter == b.shorter && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.unit == b.unit && a.fade == b.fade && a.zoom == b.zoom + && a.chosenID == b.chosenID && a.opensPlan == b.opensPlan && a.levelHeight == b.levelHeight && a.leastPrice == b.leastPrice } /// Mono stops growing at accessibility1. - private var layout: PlanStripLayout { PlanStripLayout(axis: min(axis, 15)) } + private var layout: PlanStripLayout { PlanStripLayout(axis: min(axis, 15), levelHeight: levelHeight) } private var mono: Font { .system(size: min(kicker, 15), weight: .medium, design: .monospaced) } /// The pass the figures show: the one a tap chose while the plan still @@ -67,12 +76,16 @@ struct PlanStripView: View, Equatable { var body: some View { ZStack(alignment: .topTrailing) { - NavigationLink(value: HomeRoute.plan) { + if opensPlan { + NavigationLink(value: HomeRoute.plan) { + plan + } + .buttonStyle(.plain) + .matchedTransitionSource(id: HomeRoute.plan, in: zoom) + .accessibilityIdentifier("now.plan") + } else { plan } - .buttonStyle(.plain) - .matchedTransitionSource(id: HomeRoute.plan, in: zoom) - .accessibilityIdentifier("now.plan") if let priceNow { NavigationLink(value: HomeRoute.price(nil)) { StripPrice(strip: strip, now: priceNow, unit: unit, model: scrub, figure: figure, mono: mono) @@ -113,26 +126,28 @@ struct PlanStripView: View, Equatable { PlanStripOverlay(strip: strip, model: scrub, layout: layout) } // The price row takes the height Now gives the strip. - .frame(minHeight: layout.height, maxHeight: layout.tallest) + .frame(minHeight: layout.height + max(0, leastPrice - PlanStripLayout.leastPrice), maxHeight: layout.tallest) .onGeometryChange(for: CGRect.self) { $0.frame(in: .named(Self.space)) } action: { canvas = $0 } StripSentence(sentence: sentence, shorter: shorter, placeholder: placeholder, grey: strip.grey, pass: chosen, readouts: strip.readouts, model: scrub) } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { width = $0 } - // A tap is the link's; only a finger that moves reads the strip. + // A tap is the link's; only a finger that moves reads the strip. In + // the plan's scroll a drag scrolls the page instead. .gesture( DragGesture(minimumDistance: 4) .updating($dragging) { _, state, _ in state = true } .onChanged { follow($0.location.x) } - .onEnded { _ in scrub.lift() } + .onEnded { _ in scrub.lift() }, + including: opensPlan ? .all : .subviews ) // A gesture the system takes away ends without onEnded. .onChange(of: dragging) { _, now in if !now { scrub.lift() } } .accessibilityElement(children: .ignore) .accessibilityLabel(PlanGlance.title) .accessibilityValue(sentence) - .accessibilityHint(L("Opens the plan.")) + .accessibilityHint(opensPlan ? L("Opens the plan.") : "") #if DEBUG .task { await autoScrub() } #endif @@ -148,9 +163,11 @@ struct PlanStripView: View, Equatable { .foregroundStyle(Theme.ink2) .lineLimit(1) .contentTransition(.opacity) - Image(systemName: "chevron.right") - .font(.caption2.weight(.semibold)) - .foregroundStyle(Theme.ink3) + if opensPlan { + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Theme.ink3) + } Spacer(minLength: 8) // The price's line, never drawn: it gives the header the price's // height and baseline, so the kicker sits on the same line. @@ -326,7 +343,7 @@ private struct PassTargets: View { let x = min(max(0, (x0 + x1) / 2 - w / 2), max(0, width - w)) let shared = strip.passes.contains { $0.isBattery != pass.isBattery && $0.first <= pass.last && $0.last >= pass.first } let split = PlanStripLayout.carY - 2 - let top: CGFloat = -8, bottom = PlanStripLayout.priceTop + let top: CGFloat = -8, bottom = PlanStripLayout.lanesBottom let y0 = shared && !pass.isBattery ? split : top let y1 = shared && pass.isBattery ? split : bottom return CGRect(x: x, y: y0, width: w, height: y1 - y0) diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 82b06bba..82e22424 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -2,7 +2,8 @@ import Charts import FTWKit import SwiftUI -/// The plan, opened from Now: the next 24 hours round the plan dial, or +/// The plan, opened from Now: the next twelve hours round the plan dial, a +/// clock, or /// the whole plan the box sent in lanes (to tomorrow's last priced quarter /// in the evening), then each thing the box means to do, /// when, and why, in the box's own reasons. The dial is the default; the @@ -118,86 +119,105 @@ private struct PlanBody: View { /// recording. let reveal: (Bool) -> Void @State private var dials = DialMemo() + @State private var strips = StripMemo() + /// The pass a tap chose on the strip, lit on the dial too. + @State private var chosenID: String? + @Namespace private var zoom 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. - // The lanes show the whole plan the box sent; the dial one turn. + // The lanes show the whole plan the box sent; the dial one turn, + // twelve hours, as on a clock. let minute = site.minuteMs let moment = models.planShare.moment(nowMs: minute, whole: style == .lanes) let moved = models.planShare.moved + let entry = models.planShare.entry + let quarters = Int(((moment.toMs - minute) / PlanStrip.quarterMs).rounded(.up)) + 1 + let strip = strips.strip(entry, moment: moment, hasCar: !loadpoints.points.isEmpty, quarters: style == .lanes ? quarters : PlanStrip.count) + let chosen = strip.passes.first { $0.id == chosenID } ?? PlanPass.opening(strip.passes) 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, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute), holds: $dialHolds, touch: dialTouch) - 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 { - 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) - // 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) - .fixedSize(horizontal: false, vertical: true) - .dynamicTypeSize(...DynamicTypeSize.accessibility1) - } - } - .transition(.opacity) + let dial = dials.dial(entry, nowMs: minute) + PlanDialCard(dial: dial, grey: moment.isGrey, fade: DialFade.make(models.planShare.before, dial: dial, nowMs: minute), lit: chosen.map { $0.startMs...$0.endMs }, holds: $dialHolds, touch: dialTouch) + .transition(.opacity) case .lanes: - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text(moment.summary) - .font(.headline) - .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) - .fixedSize(horizontal: false, vertical: true) - .redacted(reason: moment.placeholder ? .placeholder : []) - Text(stateLine(moment)) - .font(.footnote) - .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) - MovedLine(text: moved) - } - VStack(alignment: .leading, spacing: 6) { - PlanLanesCard(moment: moment, dial: dials.dial(models.planShare.entry, nowMs: minute, untilMs: moment.toMs), holds: $dialHolds, touch: dialTouch) - .frame(height: 140) - Legend(kinds: Set(moment.events.map(\.kind)), level: !moment.levels.isEmpty) - } + VStack(alignment: .leading, spacing: 4) { + Text(moment.summary) + .font(.headline) + .foregroundStyle(moment.isGrey ? Theme.fgDim : Theme.fg) + .fixedSize(horizontal: false, vertical: true) + .redacted(reason: moment.placeholder ? .placeholder : []) + Text(stateLine(moment)) + .font(.footnote) + .foregroundStyle(moment.state == .boxStale ? Theme.alarm : Theme.fgDim) } .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) + // The same strip as Now's, under the dial or as the lanes: the + // passes, the price from zero, and the chosen pass's figures. On the + // lanes it runs the whole plan, with the battery's level. + VStack(alignment: .leading, spacing: 10) { + PlanStripView( + strip: strip, + sentence: moment.nextText, + shorter: moment.nextShorter, + placeholder: moment.placeholder, + priceNow: priceNow(minute), + unit: PriceUnits.unit(prices.prices?.currency).label.uppercased(), + zoom: zoom, + chosenID: $chosenID, + opensPlan: false, + levelHeight: style == .lanes ? 50 : 0, + leastPrice: 56 + ) + .equatable() + .fitCap(.xLarge) + Legend(dial: style == .dial, level: style == .lanes && strip.levelPct.contains { $0 != nil }) + MovedLine(text: moved) + // Mono stops growing at accessibility1. + Group { + if style == .dial, let cheapest = dials.dial(entry, nowMs: minute).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: entry.currency, nowMs: minute, zone: moment.timeZone) { + Text(saving).foregroundStyle(Theme.ink2) + } + if style == .dial { + Text(plannedLine(moment, zone: moment.timeZone)) + .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) + .fixedSize(horizontal: false, vertical: true) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) } + PriceRow(prices: prices, minute: minute) Levers(mode: models.mode, loadpoints: loadpoints, minute: minute, choose: chooseMode, reveal: reveal) .id(Self.leversID) } + /// The price now in whole units, for the strip's header. + private func priceNow(_ minute: Double) -> String? { + guard let window = prices.prices else { return nil } + let current = window.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } + return current.map { PriceStrip.whole($0.totalMinor, window.currency) } + } + /// "PLANNED 16:51 · REPLANS EVERY 15 MIN", or that the box could not. private func plannedLine(_ m: PlanGlance.Moment, zone: TimeZone) -> String { if m.state == .boxStale { return L("Your box couldn't plan ahead just now, so it runs on safe defaults.") } @@ -220,53 +240,6 @@ 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) - .accessibilityIdentifier("plan.story") - } - - 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. `untilMs` runs its quarters on to the /// end of the lanes. @@ -284,9 +257,25 @@ private final class DialMemo { } } -/// What each colour on the lanes means. +/// The strip worked out once per plan, minute and length, like the dial. +@MainActor +private final class StripMemo { + private var key: (entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int)? + private var kept: PlanStrip? + + func strip(_ entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int) -> PlanStrip { + if let key, key.entry == entry, key.moment == moment, key.hasCar == hasCar, key.quarters == quarters, let kept { return kept } + let s = PlanStrip.build(entry, moment: moment, hasCar: hasCar, quarters: quarters) + key = (entry, moment, hasCar, quarters) + kept = s + return s + } +} + +/// What each colour means, in short words: the figures under the strip +/// say the rest. The dial's ticks are as long as the battery's power. private struct Legend: View { - let kinds: Set + var dial = false var level = false @Environment(\.colorSchemeContrast) private var contrast @@ -299,126 +288,26 @@ private struct Legend: View { .foregroundStyle(Theme.fgDim) } - /// Only what the lanes hold, in short words: the rows below say the rest. @ViewBuilder private var items: some View { - if kinds.contains(.car) { item(.car, L("Car")) } - if kinds.contains(.charge) { item(.charge, L("charging")) } - if kinds.contains(.discharge) { item(.discharge, L("discharging")) } - if kinds.contains(.cheap) { item(.cheap, L("Cheapest")) } + item(Theme.gives, L("discharging")) + item(Theme.stores, L("charging")) + item(Theme.car, L("Car")) 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) + Capsule().fill(Theme.ink2).frame(width: 12, height: 1.75) Text(L("battery level")).lineLimit(1) } } } - private func item(_ kind: PlanGlance.Kind, _ text: String) -> some View { + private func item(_ color: Color, _ text: String) -> some View { HStack(spacing: 4) { - RoundedRectangle(cornerRadius: 2).fill(PlanLanes.color(kind).opacity(Theme.plan(contrast))).frame(width: 10, height: 6) + RoundedRectangle(cornerRadius: 2).fill(color.opacity(Theme.plan(contrast))).frame(width: 10, height: 6) Text(text).lineLimit(1) } } } -/// Each thing the box means to do, when, and why, as plain rows on the -/// ground. Each row's mark has the colour the plan draws it in above, so -/// the rows are the legend. -private struct Events: View { - let moment: PlanGlance.Moment - let entry: PlanEntry - let loadpoints: LoadpointsModel - let style: PlanStyle - @Environment(\.colorSchemeContrast) private var contrast - - var body: some View { - VStack(spacing: 0) { - let shown = Array(moment.events.prefix(4)) - if shown.isEmpty { - Text(moment.summary) - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 10) - } - ForEach(Array(shown.enumerated()), id: \.element.id) { i, e in - if i > 0 { Divider().overlay(Theme.line) } - row(e) - } - if moment.events.count > shown.count { - Text(NodeWords.format("%@ more later", String(moment.events.count - shown.count))) - .font(.caption) - .foregroundStyle(Theme.fgMuted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 6) - } - } - } - - /// On the dial gold is the sun's, so the battery giving power is ink - /// there; the lanes keep gold. A ghost, as the plan draws it. - private func color(_ kind: PlanGlance.Kind) -> Color { - (style == .dial && kind == .discharge ? Theme.ink : PlanLanes.color(kind)).opacity(Theme.plan(contrast)) - } - - private func row(_ e: PlanGlance.Event) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 10) { - RoundedRectangle(cornerRadius: 2) - .fill(color(e.kind)) - .frame(width: 10, height: 6) - .alignmentGuide(.firstTextBaseline) { $0[.bottom] } - VStack(alignment: .leading, spacing: 1) { - Text(PlanGlance.what(e)) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(Theme.fg) - if let why = why(e) { - Text(why) - .font(.footnote) - .foregroundStyle(Theme.fgDim) - .lineLimit(1) - .minimumScaleFactor(0.85) - } - } - Spacer(minLength: 8) - // "now–04:47", or "tomorrow 21:00–22:30" where the clock alone - // would read as tonight. - Text(moment.times(e)) - .font(Theme.figure(13, weight: .medium)) - .foregroundStyle(Theme.fgDim) - .lineLimit(1) - } - .padding(.vertical, 7) - .accessibilityElement(children: .combine) - } - - /// The box's reason, and what the numbers are. - private func why(_ e: PlanGlance.Event) -> String? { - switch e.kind { - case .charge, .discharge: - let reason = e.reason.map(PlanText.reason) - let peak = e.peakW.map { NodeWords.format("up to %@", AppWords.power($0)) } - return [reason, peak].compactMap { $0 }.joined(separator: " · ").capitalizedFirst - case .car: - let lp = loadpoints.points.first - let goal = lp?.schedule.map { EVText.goal($0, at: Date(timeIntervalSince1970: moment.atMs / 1000), zone: loadpoints.zone) } - let energy = e.energyWh.map { AppWords.energy($0) } - let text = [energy, goal].compactMap { $0 }.joined(separator: " · ") - return text.isEmpty ? nil : text.capitalizedFirst - case .cheap: - let inBlock = entry.prices.filter { $0.startMs < e.endMs && $0.startMs + $0.durationMs > e.startMs } - guard !inBlock.isEmpty else { return nil } - let mean = inBlock.map(\.totalMinor).reduce(0, +) / Double(inBlock.count) - let unit = PriceUnits.unit(entry.currency) - return NodeWords.format("%@ on average", "\(AppWords.decimal(PriceStrip.text(mean, entry.currency))) \(unit.perKwh)") - } - } -} - /// The price the plan stands on, in one row, and the way to its chart. private struct PriceRow: View { let prices: PriceModel @@ -567,297 +456,3 @@ private extension String { return first.uppercased() + dropFirst() } } - -/// The hours ahead as lanes: the car's charging on top, the battery under -/// it (charging one colour, giving power another), the cheapest hours as a -/// thin mark, and the prices as a low strip along the bottom. The price is -/// the ground the plan stands on, not the subject. The lanes are a plan, -/// so their colours are a ghost at `Theme.plan`, as on Now's strip. -struct PlanLanes: View, Equatable { - let moment: PlanGlance.Moment - var compact = false - /// The finger on the plan view's lanes, and the dial whose quarters it - /// reads; none on Now's strip. - fileprivate var finger: LaneFinger? = nil - fileprivate var scrub: ScrubModel? = nil - fileprivate var dial: PlanDial? = nil - @Environment(\.colorSchemeContrast) private var contrast - @Environment(\.colorScheme) private var scheme - - nonisolated static func == (a: Self, b: Self) -> Bool { a.moment == b.moment && a.compact == b.compact } - - private static let car = 0.68...0.96 - private static let battery = 0.34...0.62 - private static let cheap = 0.23...0.27 - static let priceFloor = 0.03 - static let priceRise = 0.15 - - var body: some View { - let from = moment.atMs - let to = moment.toMs - // Every three hours over Now's twelve; every six over a day and more. - let every = to - from > 18 * 3_600_000 ? 6 : 3 - let top = max(1, moment.backdrop.map(\.minor).max() ?? 1) - let grey = moment.isGrey - Chart { - ForEach(moment.backdrop) { bar in - RectangleMark( - xStart: .value("From", date(max(from, bar.startMs))), - xEnd: .value("To", date(min(to, bar.endMs))), - yStart: .value("Bottom", 0), - yEnd: .value("Price", Self.priceFloor + Self.priceRise * max(0, bar.minor) / top) - ) - .foregroundStyle(grey ? Theme.fgMuted : Theme.price(bar.tone)) - .opacity(0.4) - } - ForEach(moment.events) { e in - let lane = Self.lane(e.kind) - RectangleMark( - xStart: .value("From", date(max(from, e.startMs))), - xEnd: .value("To", date(min(to, e.endMs))), - yStart: .value("Low", lane.lowerBound), - yEnd: .value("High", lane.upperBound) - ) - .foregroundStyle(grey ? Theme.fgMuted : Self.color(e.kind).opacity(Theme.plan(contrast))) - .cornerRadius(5) - .annotation(position: .overlay, alignment: .center) { - if !compact || e.endMs - e.startMs >= 90 * 60_000, let pct = e.targetPct, e.kind == .car, !e.chargesAgain { - // On the pale car bar: ink in light, where the - // ground's colour would fall under 4.5:1. - Text(AppWords.percent(pct)) - .font(.system(size: 9, weight: .bold).monospacedDigit()) - .foregroundStyle(scheme == .light ? Theme.ink : Theme.ground) - } - } - } - // 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)) - } - .chartXScale(domain: date(from)...date(to)) - .chartYScale(domain: 0...1) - .chartOverlay { proxy in - if let finger, let scrub, let dial { - LanesMark(proxy: proxy, scrub: scrub, dial: dial, finger: finger, fromMs: from, toMs: to) - } - } - .chartYAxis(.hidden) - .chartXAxis { - AxisMarks(preset: .aligned, values: Self.hours(fromMs: from, toMs: to, every: every, zone: moment.timeZone)) { value in - AxisValueLabel { hourLabel(value, zone: moment.timeZone) } - } - } - .accessibilityHidden(true) - } - - /// The whole hours the axis names, counted from the household's - /// midnight so they read 00, 06, 12, 18 (or every three), as the rows - /// read their times: a stride from now would count from whatever hour - /// now is. - static func hours(fromMs: Double, toMs: Double, every: Int, zone: TimeZone) -> [Date] { - var calendar = Calendar.current - calendar.timeZone = zone - let start = Date(timeIntervalSince1970: fromMs / 1000) - guard var t = calendar.dateInterval(of: .hour, for: start)?.end else { return [] } - var out = [Date]() - while t.timeIntervalSince1970 * 1000 < toMs { - if calendar.component(.hour, from: t) % every == 0 { out.append(t) } - t = calendar.date(byAdding: .hour, value: 1, to: t) ?? t.addingTimeInterval(3_600) - } - return out - } - - static func lane(_ kind: PlanGlance.Kind) -> ClosedRange { - switch kind { - case .car: return car - case .charge, .discharge: return battery - case .cheap: return cheap - } - } - - static func color(_ kind: PlanGlance.Kind) -> Color { - switch kind { - case .car: return Theme.car - case .charge: return Theme.stores - case .discharge: return Theme.gives - case .cheap: return Theme.ink2 - } - } - - private func date(_ ms: Double) -> Date { Date(timeIntervalSince1970: ms / 1000) } -} - -/// The lanes with a finger on them. Like the dial, the lanes have no -/// gesture of their own inside the plan's scroll: any there held the page -/// still for every swipe that started on them. The page hands each touch -/// here (`DialTouch`), and the lanes scrub only once the finger moves -/// mostly sideways or rests 0.1 s (`LaneIntent`); up or down scrolls the -/// page, and while it scrubs the page holds still. The quarter under the -/// finger reads as the dial's does, in the dial's words: its time, its -/// price and what the box does then. A tick marks each new quarter. -private struct PlanLanesCard: View { - let moment: PlanGlance.Moment - let dial: PlanDial - @Binding var holds: Bool - let touch: DialTouch - @State private var scrub = ScrubModel() - @State private var finger = LaneFinger() - @Environment(\.scenePhase) private var scenePhase - - var body: some View { - PlanLanes(moment: moment, finger: finger, scrub: scrub, dial: dial) - .equatable() - .sensoryFeedback(.selection, trigger: scrub.ticks) - .sensoryFeedback(.impact(weight: .light), trigger: scrub.bumps) - .onChange(of: dial, initial: true) { _, now in finger.dial = now } - .onAppear { - touch.take(finger, changed: { value in - if finger.startAt != value.startLocation { - end() - start(value) - } - move(value) - }, ended: { end() }) - } - .onDisappear { - touch.release(finger) - end() - } - // A touch the system took (a call, Control Center, a switch of - // app) may never end: the page is not left held. - .onChange(of: scenePhase) { _, phase in if phase != .active { end() } } - .accessibilityIdentifier("plan.lanes") - } - - /// The lanes take a touch only inside their plot, and never within - /// 20 pt of the screen's leading edge, where the back swipe starts. - private func start(_ v: DragGesture.Value) { - finger.startAt = v.startLocation - finger.startMs = ProcessInfo.processInfo.systemUptime * 1000 - finger.decision = nil - guard v.startLocation.x >= EdgeRule.edge, finger.plot.insetBy(dx: 0, dy: -8).contains(v.startLocation) else { - finger.decision = .pass - return - } - finger.hold = Task { - try? await Task.sleep(for: .milliseconds(Int(RingIntent.holdMs))) - guard !Task.isCancelled, finger.decision == nil, let at = finger.last else { return } - decide(.scrub, at: at) - } - } - - private func move(_ v: DragGesture.Value) { - finger.last = v.location - guard finger.decision == nil else { - if finger.decision == .scrub { follow(v.location) } - return - } - let held = ProcessInfo.processInfo.systemUptime * 1000 - (finger.startMs ?? 0) - if let decision = LaneIntent.decide(start: v.startLocation, now: v.location, heldMs: held) { - decide(decision, at: v.location) - } - } - - private func decide(_ decision: RingIntent.Decision, at p: CGPoint) { - finger.decision = decision - finger.hold?.cancel() - guard decision == .scrub else { return } - holds = true - follow(p) - } - - /// The quarter under the finger, clamped to the lanes. - private func follow(_ p: CGPoint) { - let plot = finger.plot - guard plot.width > 0, let dial = finger.dial, finger.toMs > finger.fromMs else { return } - let f = Double(min(max((p.x - plot.minX) / plot.width, 0), 1)) - let ms = finger.fromMs + f * (finger.toMs - finger.fromMs) - guard let i = dial.grid.index(atMs: ms), i != scrub.index else { return } - scrub.move(to: i, landmark: dial.landmarks.contains(i)) - } - - private func end() { - guard finger.startMs != nil else { return } - finger.hold?.cancel() - finger.hold = nil - finger.startAt = nil - finger.startMs = nil - finger.decision = nil - finger.last = nil - if holds { holds = false } - if scrub.isDown { withAnimation(Theme.spring) { scrub.lift() } } - } -} - -/// The rule and the tag over the quarter under the finger, the only marks -/// that move with it: the lanes themselves never redraw for a finger. -private struct LanesMark: View { - let proxy: ChartProxy - let scrub: ScrubModel - let dial: PlanDial - let finger: LaneFinger - let fromMs: Double - let toMs: Double - - var body: some View { - GeometryReader { geo in - let plot = proxy.plotFrame.map { geo[$0] } ?? CGRect(origin: .zero, size: geo.size) - Canvas { g, size in - guard let i = scrub.index, toMs > fromMs else { return } - let mid = dial.grid.startMs(i) + PlanDial.quarterMs / 2 - let x = plot.minX + CGFloat((mid - fromMs) / (toMs - fromMs)) * plot.width - let px = 1 / max(1, g.environment.displayScale) - LockOn.rule(at: x, plot: plot, px: px, in: &g) - // The time, the price and what the battery does, with its - // level: what fits one line. The car and the cheap hours - // are the lanes' own to show. - let what = dial.what(i).components(separatedBy: " · ").prefix(2).joined(separator: " · ") - let words = [dial.time(i), dial.price(i), what].filter { !$0.isEmpty }.joined(separator: " · ") - LockOn.tag(words, over: x, plot: plot, size: 10, in: &g, canvas: size) - } - .allowsHitTesting(false) - .onChange(of: plot, initial: true) { _, now in finger.plotLocal = now } - // A new plan can move the lanes' end while now stays put. - .onChange(of: [fromMs, toMs], initial: true) { _, now in - finger.fromMs = now[0] - finger.toMs = now[1] - } - } - .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { finger.overlay = $0 } - } -} - -/// Where the lanes' plot is on the screen, what the touch is and the dial -/// it reads: a plain reference, so a touch or a scroll writes nothing -/// SwiftUI watches. -@MainActor -private final class LaneFinger { - var startAt: CGPoint? - var startMs: Double? - var decision: RingIntent.Decision? - var last: CGPoint? - var hold: Task? - var overlay: CGRect = .zero - var plotLocal: CGRect = .zero - var plot: CGRect { plotLocal.offsetBy(dx: overlay.minX, dy: overlay.minY) } - var dial: PlanDial? - var fromMs = 0.0 - var toMs = 0.0 -} - -/// "15", on the 24-hour clock FTW uses in both languages: an English phone -/// would otherwise label the afternoon "03". -private func hourLabel(_ value: AxisValue, zone: TimeZone) -> some View { - var calendar = Calendar.current - calendar.timeZone = zone - return Text(value.as(Date.self).map { String(format: "%02d", calendar.component(.hour, from: $0)) } ?? "") - .font(.caption2) - .foregroundStyle(Theme.ink3) -} diff --git a/appleApp/FTWCharts/PlanDialFace.swift b/appleApp/FTWCharts/PlanDialFace.swift index 57135a04..e467a2d3 100644 --- a/appleApp/FTWCharts/PlanDialFace.swift +++ b/appleApp/FTWCharts/PlanDialFace.swift @@ -7,13 +7,16 @@ 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, 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 -/// with its goal. The hand is drawn apart (`PlanDialHand`), so a finger on -/// the phone moves it without redrawing the face. +/// +12 h meets now, and the sun's arc on it from the box's forecast, dashed +/// because it is a guess; the whole hours just outside it, as on a clock; +/// 48 ticks as long as the battery's planned power, cyan where it stores, +/// gold where it gives power (as on Now's strip under it), a faint stub +/// where it waits; the car's dotted +/// arc with its goal. The hand is drawn apart (`PlanDialHand`), so a finger +/// on the phone moves it without redrawing the face. +/// +/// The pass a tap chose (`lit`) has its ticks at full strength; the rest +/// stay a ghost. /// /// Every mark is a still path filled once. Nothing glows: a plan is intent, /// not a reading, so in full colour every hue is a ghost at `Theme.plan`, @@ -31,10 +34,12 @@ struct PlanDialFace: View, Equatable { /// The plan is old or the box could not plan: every tick in ink3. var grey = false var size: Size = .app + /// The time of the pass a tap chose, drawn at full strength. + var lit: ClosedRange? @Environment(\.colorSchemeContrast) private var contrast @Environment(\.widgetRenderingMode) private var renderingMode - nonisolated static func == (a: Self, b: Self) -> Bool { a.dial == b.dial && a.grey == b.grey && a.size == b.size } + nonisolated static func == (a: Self, b: Self) -> Bool { a.dial == b.dial && a.grey == b.grey && a.size == b.size && a.lit == b.lit } var body: some View { GeometryReader { geo in @@ -44,16 +49,19 @@ struct PlanDialFace: View, Equatable { ZStack { if size == .app { rim(g).stroke(Theme.ink.opacity(0.18), style: StrokeStyle(lineWidth: 0.5, lineCap: .butt, lineJoin: .round)) - HourLabels(g: g) + HourLabels(g: g, dial: dial) } // 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.opacity(sunPlan), style: StrokeStyle(lineWidth: g.sunWidth, lineCap: .butt, lineJoin: .round, dash: g.sunDash)) - ticks(g, .waits).fill(grey ? Theme.oldPlan : Theme.ink3.opacity(0.6)) - ticks(g, .gives).fill(grey ? Theme.oldPlan : Theme.ink.opacity(plan)) - ticks(g, .stores).fill(grey ? Theme.oldPlan : Theme.stores.opacity(plan)) + stubs(g).fill(grey ? Theme.oldPlan : Theme.ink3.opacity(0.6)) + ticks(g, .gives, lit: false).fill(grey ? Theme.oldPlan : Theme.gives.opacity(lit == nil ? plan : plan * 0.6)) + ticks(g, .stores, lit: false).fill(grey ? Theme.oldPlan : Theme.stores.opacity(lit == nil ? plan : plan * 0.6)) .widgetAccentable() - stubs(g).fill(Theme.ink3.opacity(0.5)) + if !grey, lit != nil { + ticks(g, .gives, lit: true).fill(Theme.gives) + ticks(g, .stores, lit: true).fill(Theme.stores) + } carDots(g).fill(grey ? Theme.ink3 : Theme.car.opacity(plan)) .widgetAccentable() // The goal sits inside the car's arc. On a watch that is @@ -84,21 +92,26 @@ struct PlanDialFace: View, Equatable { return p } - /// One path per battery action, so each fills in one go. - private func ticks(_ g: DialGeometry, _ action: PlanDial.Battery) -> Path { + /// One path per battery action, so each fills in one go: as long as + /// the planned power, never shorter than a stub. `lit` takes the + /// quarters of the chosen pass, or the rest. + private func ticks(_ g: DialGeometry, _ action: PlanDial.Battery, lit wanted: Bool) -> Path { var p = Path() - for (i, q) in dial.quarters.enumerated() where q.battery == action { - guard let level = q.level else { continue } - let length = g.unit * Double(level + 1) + for (i, q) in dial.quarters.prefix(PlanDial.count).enumerated() where q.battery == action { + guard let reach = dial.reach(i) else { continue } + let mid = q.startMs + PlanDial.quarterMs / 2 + guard (lit?.contains(mid) ?? false) == wanted else { continue } + let length = max(g.unit * 1.2, (g.rim - g.inner) * reach) p.addPath(g.tick(at: dial.angle(quarter: i), from: g.inner, length: length, width: g.tickWidth * dial.fade(i).squareRoot())) } return p } - /// Quarters the market has not priced yet: a short stub, never a level. + /// Quarters in which the battery waits, or the plan says nothing: a + /// short stub, so the turn keeps its shape. private func stubs(_ g: DialGeometry) -> Path { var p = Path() - for (i, q) in dial.quarters.enumerated() where q.minor == nil && i % 2 == 0 { + for i in 0.. Path { var p = Path() - for (i, q) in dial.quarters.enumerated() where q.car && i % 2 == 0 { + for (i, q) in dial.quarters.prefix(PlanDial.count).enumerated() where q.car && i % 2 == 0 { let c = g.point(dial.angle(quarter: i), g.carRadius) p.addEllipse(in: CGRect(x: c.x - g.dot / 2, y: c.y - g.dot / 2, width: g.dot, height: g.dot)) } @@ -205,18 +218,19 @@ struct DialGeometry { let side: CGFloat let size: PlanDialFace.Size - /// Half the seam's gap: 1° each side of where +24 h meets now. + /// Half the seam's gap: 1° each side of where +12 h meets now. static let gap = Double.pi / 180 var centre: CGPoint { CGPoint(x: side / 2, y: side / 2) } - /// The outer edge of the dearest tick, and the sun's arc. + /// The outer edge of the longest tick, and the sun's arc. var rim: CGFloat { side / 2 - (size == .app ? 13 : 1.5) } /// The hours sit just outside the rim, clear of the sun's arc. var labelRadius: CGFloat { side / 2 - 5.5 } /// Where every tick starts. var inner: CGFloat { rim * (size == .app ? 0.62 : 0.56) } - /// One price level's length. - var unit: CGFloat { (rim - inner) / CGFloat(PlanDial.levels) } + /// An eighth of a tick's room: the stub, the hand's overhang and the + /// car's arc are measured in it. + var unit: CGFloat { (rim - inner) / 8 } var tickWidth: CGFloat { max(0.9, side * (size == .app ? 0.0085 : 0.012)) } var carRadius: CGFloat { inner - unit * (size == .app ? 1.6 : 1.4) } var dot: CGFloat { max(1.4, side * 0.012) } @@ -256,18 +270,21 @@ struct DialGeometry { } } -/// 00, 06, 12 and 18 just outside the rim, in the terminal's mono. +/// The whole hours of the turn just outside the rim, in the terminal's +/// mono, where a clock has them: "18" straight down. The three, six, nine +/// and twelve o'clock places a step stronger. private struct HourLabels: View { let g: DialGeometry + let dial: PlanDial var body: some View { - ForEach([0, 6, 12, 18], id: \.self) { h in - // 00 at the bottom, 06 on the left, 12 at the top. - let a = Double(h) / 24 * 2 * .pi + .pi - Text(verbatim: h < 10 ? "0\(h)" : "\(h)") - .font(.system(size: g.label, weight: .regular, design: .monospaced)) - .foregroundStyle(Theme.ink3) - .position(g.point(a, g.labelRadius)) + ForEach(dial.hours, id: \.label) { h in + let k = h.angle / (.pi / 2) + let onQuarter = abs(k - k.rounded()) < 0.01 + Text(verbatim: h.label) + .font(.system(size: g.label, weight: onQuarter ? .semibold : .regular, design: .monospaced)) + .foregroundStyle(onQuarter ? Theme.ink2 : Theme.ink3) + .position(g.point(h.angle, g.labelRadius)) } } } diff --git a/appleApp/FTWCharts/PlanStripDrawing.swift b/appleApp/FTWCharts/PlanStripDrawing.swift index 92878cbe..7f4715d7 100644 --- a/appleApp/FTWCharts/PlanStripDrawing.swift +++ b/appleApp/FTWCharts/PlanStripDrawing.swift @@ -3,15 +3,16 @@ import SwiftUI /// Where the parts of Now's plan strip sit, from the top of its Canvas: /// room for the lock-on's brackets, two 6 pt lanes 4 pt apart (battery, -/// then car), the price bars from zero in a row of 20 pt or more, and the -/// hours under them. The price's scale takes a gutter on the right, so the +/// then car), on the plan's lanes a row for the battery's level, the price +/// bars from zero in a row of 20 pt or more, and the hours under them. The price's scale takes a gutter on the right, so the /// quarters run across the rest. The surface and the finger's overlay /// share it, so a lit quarter and its bar meet. struct PlanStripLayout: Equatable { static let lane: CGFloat = 6 static let batteryY: CGFloat = 3 static let carY: CGFloat = 13 - static let priceTop: CGFloat = 25 + /// Where the lanes end; the level row, or the price, starts here. + static let lanesBottom: CGFloat = 25 /// The price row at its least, and the most it grows to when Now has /// room: the same two Now's layout reads. static let leastPrice = PlanStrip.leastPrice @@ -21,12 +22,20 @@ struct PlanStripLayout: Equatable { var axis: CGFloat = 10 /// The price row's height: 20 pt, or what Now gives it. var priceHeight: CGFloat = Self.leastPrice + /// The battery level's row, between the lanes and the price: none on + /// Now, where the strip stays small. + var levelHeight: CGFloat = 0 - var priceBottom: CGFloat { Self.priceTop + priceHeight } + var priceTop: CGFloat { Self.lanesBottom + levelHeight } + var priceBottom: CGFloat { priceTop + priceHeight } + /// 100 % and 0 % in the level's row, clear of the lanes and the bars. + var levelTop: CGFloat { Self.lanesBottom + 5 } + var levelBottom: CGFloat { priceTop - 14 } + func levelY(_ pct: Double) -> CGFloat { levelBottom - CGFloat(min(100, max(0, pct)) / 100) * (levelBottom - levelTop) } var hoursTop: CGFloat { priceBottom + 3 } /// Room on the right for the scale's figures, "600", in the axis's - /// mono at nine tenths of its size. - var gutter: CGFloat { (axis * 2.4).rounded(.up) + 4 } + /// mono at nine tenths of its size, or "100 %" beside a level row. + var gutter: CGFloat { (axis * (levelHeight > 0 ? 3.4 : 2.4)).rounded(.up) + 4 } /// The width the quarters run across. func plotWidth(_ width: CGFloat) -> CGFloat { max(0, width - gutter) } var height: CGFloat { hoursTop + (axis * 1.25).rounded(.up) } @@ -165,6 +174,8 @@ struct PlanStripDrawing: ChartDrawing { } g.fill(car, with: .color(grey ? oldInk : Theme.car.opacity(ghost))) + if layout.levelHeight > 0 { drawLevel(in: &g, layout: layout, width: width, px: px, ghost: ghost, chosen: chosen) } + // The scale: a hairline at half the top and at the top, each with // its figure in the gutter, and the zero line when a price falls // under it. @@ -233,6 +244,46 @@ struct PlanStripDrawing: ChartDrawing { } } + /// The battery's planned level through the strip, between hairlines at + /// 0 and 100 % with their figures in the gutter: a line through the + /// level at each quarter's end, in gold where the battery gives power, + /// cyan where it stores and ink where it waits. A ghost, but the chosen + /// pass's stretch at full strength. + private func drawLevel(in g: inout GraphicsContext, layout: PlanStripLayout, width: CGFloat, px: CGFloat, ghost: Double, chosen: PlanPass?) { + let grid = strip.grid + var frame = Path() + for pct in [0.0, 100.0] { frame.addRect(CGRect(x: 0, y: layout.levelY(pct), width: width, height: px)) } + g.fill(frame, with: .color(Theme.ink.opacity(0.08))) + for pct in [0.0, 100.0] { + let text = g.resolve(ChartText.label("\(Int(pct))\u{00A0}%", size: layout.axis * 0.9)) + g.draw(text, at: CGPoint(x: width + 4, y: layout.levelY(pct)), anchor: .leading) + } + guard strip.levelPct.contains(where: { $0 != nil }) else { return } + var paths: [String: Path] = [:] + var last: CGPoint? + for i in strip.levelPct.indices { + guard let pct = strip.levelPct[i] else { + last = nil + continue + } + let p = CGPoint(x: grid.x(atMs: grid.endMs(i), width: width), y: layout.levelY(pct)) + if let from = last { + let lit = !strip.grey && chosen.map { $0.isBattery && i >= $0.first && i <= $0.last } == true + let key = (strip.battery[i].map { $0 == .stores ? "stores" : "gives" } ?? "waits") + (lit ? "!" : "") + paths[key, default: Path()].move(to: from) + paths[key, default: Path()].addLine(to: p) + } + last = p + } + let style = StrokeStyle(lineWidth: 1.75, lineCap: .round, lineJoin: .round) + for (key, path) in paths { + let lit = key.hasSuffix("!") + let color: Color = strip.grey ? Theme.ink3 + : key.hasPrefix("stores") ? Theme.stores : key.hasPrefix("gives") ? Theme.gives : Theme.ink2 + g.stroke(path, with: .color(color.opacity(lit ? 1 : ghost)), style: style) + } + } + /// What a price bar is filled with. enum BarInk: Hashable { case third(PriceChart.Third) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift index c445a5a4..cc1415f6 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanDial.swift @@ -1,22 +1,27 @@ import Foundation -/// The plan dial, as the phone, its widget and the watch all draw it: one -/// turn is the next 24 hours, on a 24-hour face with 00 at the bottom and -/// 12 at the top, so the day's light sits in the upper half and morning on -/// the left. Each quarter hour is one tick. Its length is the price in -/// eight levels, its colour what the battery does then, and a dotted arc -/// inside marks the car's charging. The sun's arc on the rim comes from the -/// box's own forecast, never from astronomy. +/// The plan dial, as the phone, its widget and the watch all draw it: a +/// clock. One turn is the next twelve hours, with 12 at the top and 6 at +/// the bottom, so 18:00 sits straight down as on any clock (Erik and +/// Fredrik, 1 Nov 2026: the 24-hour face made people read the figures at +/// the edge and think). Each quarter hour is one tick. Its length is the +/// battery's planned power, its colour what the battery does then, and a +/// dotted arc inside marks the car's charging. The price has its own chart +/// under the dial. The sun's arc on the rim comes from the box's own +/// forecast, never from astronomy. /// -/// The ticks stand on the same scale as every other price bar: eight steps -/// from zero to a round top (`PriceScale`). +/// Each quarter still carries its price, in eight steps from zero +/// (`PriceScale`), for the watch's strip and a finger's readout. /// /// Built on the chart engine (`SlotGrid`, `ZoneClock`) and worked out once /// per plan and minute, so a finger or the Crown only reads a table. public struct PlanDial: Equatable, Sendable { public static let quarterMs: Double = 15 * 60_000 - /// One turn: 24 hours of quarters. - public static let count = 96 + /// One turn: 12 hours of quarters, as on a clock. + public static let count = 48 + /// A tick at full length stands for at least this much power, so a + /// trickle never looks like the battery's most. + static let leastFullW: Double = 1_000 /// The block-glyph scale: ▁▂▃▄▅▆▇█. public static let levels = 8 /// The box's forecast counts as sun from this many watts up. @@ -45,8 +50,20 @@ public struct PlanDial: Equatable, Sendable { public let levelPct: Double? } + /// A whole hour on the face: where it sits and its label, "18". + public struct Hour: Equatable, Sendable { + public let angle: Double + public let label: String + } + public let grid: SlotGrid public let quarters: [Quarter] + /// The power a full-length tick stands for: the most the battery moves + /// in this turn, and at least `leastFullW`. + public let fullW: Double + /// Each whole hour in the turn, by the household's clock, except the + /// ones too close to the hand or the seam to read. + public let hours: [Hour] public let nowMs: Double public let currency: String public let zone: TimeZone @@ -149,6 +166,17 @@ public struct PlanDial: Equatable, Sendable { let perKwh = unit.perKwh.uppercased() self.grid = grid self.quarters = quarters + fullW = max(Self.leastFullW, quarters.prefix(Self.count).compactMap { $0.batteryW.map(abs) }.max() ?? 0) + var hours = [Hour]() + let hourMs = 3_600_000.0, turnEnd = first + Double(Self.count) * Self.quarterMs + var t = clock.alignDown(nowMs, stepMs: hourMs) + hourMs + while t < turnEnd { + if t - nowMs >= 0.8 * hourMs, turnEnd - t >= 0.8 * hourMs, clock.minuteOfDay(t) % 60 == 0 { + hours.append(Hour(angle: Self.clockAngle(t, clock: clock), label: String(clock.label(t).prefix(2)))) + } + t += hourMs + } + self.hours = hours self.nowMs = nowMs self.currency = currency self.zone = zone @@ -160,10 +188,11 @@ public struct PlanDial: Equatable, Sendable { let from = max(s.fromMs, first), to = min(s.toMs, grid.toMs) return from < to ? from...to : nil } - // Past one turn the clock reads as a time today: the day goes first. + // A time from the next midnight on says the day first. + let midnight = nowMs + Double(1440 - clock.minuteOfDay(nowMs)) * 60_000 times = (0.. Double { - let clock = ZoneClock.of(zone) - let day = 86_400_000.0 - var local = (ms + clock.offsetMs(ms)).truncatingRemainder(dividingBy: day) - if local < 0 { local += day } - return (local / day * 2 * .pi + .pi).truncatingRemainder(dividingBy: 2 * .pi) + /// Radians clockwise from the top for a moment on the household clock, + /// as on a clock's face: 12 and 00 at the top, 15 and 03 on the right, + /// 18 and 06 at the bottom, 21 and 09 on the left. + public func angle(_ ms: Double) -> Double { Self.clockAngle(ms, clock: ZoneClock.of(zone)) } + + static func clockAngle(_ ms: Double, clock: ZoneClock) -> Double { + let half = 43_200_000.0 + var local = (ms + clock.offsetMs(ms)).truncatingRemainder(dividingBy: half) + if local < 0 { local += half } + return local / half * 2 * .pi + } + + /// How long quarter `i`'s tick is, 0 to 1 of the ring: the battery's + /// planned power against `fullW`. Nil while it waits or with no plan. + public func reach(_ i: Int) -> Double? { + let q = quarters[i] + guard q.battery != .waits, let w = q.batteryW else { return nil } + return min(1, abs(w) / fullW) } /// Where the middle of quarter `i` sits. public func angle(quarter i: Int) -> Double { angle(grid.startMs(i) + Self.quarterMs / 2) } - /// Where the turn begins and ends: +24 h meets now. + /// Where the turn begins and ends: +12 h meets now. public var seam: Double { angle(grid.firstMs) } /// The sun's spans as distances round the turn from the seam, 0 to 2π. @@ -245,8 +284,8 @@ public struct PlanDial: Equatable, Sendable { return angle(grid.startMs(i)) } - /// How faint quarter `i` is: full now, fading toward +24 h, so the - /// seam where tomorrow meets now reads as the end of the turn. + /// How faint quarter `i` is: full now, fading toward +12 h, so the + /// seam where the turn meets now reads as its end. public func fade(_ i: Int) -> Double { 1 - 0.55 * Double(i) / Double(Self.count - 1) } /// The quarter under a finger `x` right of and `y` below the dial's diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index c3252474..d2a9748c 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -51,6 +51,9 @@ public struct PlanStrip: Equatable, Sendable { /// The price of the quarter running now, for the dashed line that /// carries it across the hours to come. public let nowMinor: Double? + /// Where the plan leaves the battery by each quarter's end, in percent; + /// nil where the box did not say. + public let levelPct: [Double?] public let batteryRuns: [Run] public let carRuns: [Run] /// Stretches of equal price, drawn as one bar. @@ -86,11 +89,14 @@ public struct PlanStrip: Equatable, Sendable { public let hasCar: Bool /// The strip at the moment's minute, from the plan the moment was read - /// from. - public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool) -> PlanStrip { + /// from: the next twelve hours, or for the plan's lanes `quarters` + /// (the whole plan), with the hours every six once it runs past a day's + /// half. + public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool, quarters wanted: Int = Self.count) -> PlanStrip { let clock = ZoneClock.of(moment.timeZone) let nowMs = moment.atMs let first = clock.alignDown(nowMs, stepMs: quarterMs) + let count = max(Self.count, wanted) let grid = SlotGrid(firstMs: first, stepMs: quarterMs, count: count, fromMs: first, toMs: first + Double(count) * quarterMs) let prices = (entry?.prices ?? []).filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } @@ -142,12 +148,13 @@ public struct PlanStrip: Equatable, Sendable { } landmarks.formUnion(goals.keys) - // Whole local hours every three, by the wall clock, so a day that - // changes its clock still reads 00, 03, 06. + // Whole local hours every three, or six on a long strip, by the wall + // clock, so a day that changes its clock still reads 00, 03, 06. var hours = [Tick]() + let every = count > 64 ? 360 : 180 var t = clock.alignDown(first, stepMs: 3_600_000) + 3_600_000 while t < grid.toMs - quarterMs { - if clock.minuteOfDay(t) % 180 == 0 { hours.append(Tick(ms: t, label: String(clock.label(t).prefix(2)))) } + if clock.minuteOfDay(t) % every == 0 { hours.append(Tick(ms: t, label: String(clock.label(t).prefix(2)))) } t += 3_600_000 } @@ -182,7 +189,7 @@ public struct PlanStrip: Equatable, Sendable { switch (moment.state, moment.readAtMs) { case let (.plan, at?): kicker = PlanWord.plannedAt.format(clock.label(at)) case let (.old, at?): kicker = PlanWord.oldPlan.format(clock.label(at)) - default: kicker = PlanWord.next12h.text + default: kicker = count > Self.count ? PlanWord.wholePlan.text : PlanWord.next12h.text } return PlanStrip( @@ -195,6 +202,7 @@ public struct PlanStrip: Equatable, Sendable { thirds: thirds, scale: PriceScale(known, currency: currency), nowMinor: minor.first ?? nil, + levelPct: planned.map { $0?.levelPct }, batteryRuns: runs(battery), carRuns: runs(car.map { $0 ? true : nil }), priceRuns: runs(minor), diff --git a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift index 5b0bd767..7a81a140 100644 --- a/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift +++ b/appleApp/FTWKit/Sources/FTWShared/PlanGlance.swift @@ -764,6 +764,7 @@ enum PlanWord: String, CaseIterable { case levelUp = "%@, and reaches %@" // The strip's header and what a finger reads on it. case next12h = "NEXT 12 H" + case wholePlan = "THE WHOLE PLAN" case plannedAt = "PLANNED %@" case oldPlan = "OLD PLAN · %@" case nowMark = "NOW" diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings index caf892f9..49ecec32 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Plan.strings @@ -54,6 +54,7 @@ "%@, and reaches %@" = "%@, och når %@"; /* The strip's header and what a finger reads on it. */ +"THE WHOLE PLAN" = "HELA PLANEN"; "NEXT 12 H" = "NÄSTA 12 H"; "PLANNED %@" = "PLANERAD %@"; "OLD PLAN · %@" = "GAMMAL PLAN · %@"; diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift index 62b4802c..416ae272 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanDialTests.swift @@ -30,12 +30,40 @@ import Testing #expect(near(d.seam, d.angle(at(14, 12)))) // The hand at now sits just past it, inside the first quarter. #expect(d.handAngle(selected: nil) > d.seam) - #expect(d.handAngle(selected: nil) - d.seam < 2 * .pi / 96) + #expect(d.handAngle(selected: nil) - d.seam < 2 * .pi / 48) + } + + /// A clock: 18 straight down, the real hours round the rim, none on + /// top of the hand or the seam. + @Test func theHoursSitWhereAClockHasThem() { + let d = dial() + #expect(near(d.angle(at(14, 18)), .pi)) + #expect(near(d.angle(at(14, 15)), .pi / 2)) + #expect(near(d.angle(at(14, 21)), 1.5 * .pi)) + #expect(near(d.angle(at(15, 0)), 0)) + // From 12:07 the turn runs to midnight: 13 to 23, with 12 and 00 + // left off at the hand and the seam. + #expect(d.hours.map(\.label) == ["13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23"]) + #expect(near(d.hours.first { $0.label == "18" }!.angle, .pi)) + // At 20:07, 06 sits at the foot, the next morning's. + let night = dial(at: at(14, 20, 7)) + #expect(night.hours.contains { $0.label == "06" && near($0.angle, .pi) }) + } + + /// A tick is as long as the battery's planned power, against the most + /// it moves in the turn; a quarter in which it waits has none. + @Test func aTickIsTheBatterysPower() { + let d = dial() + #expect(d.fullW == 3_000) + #expect(d.reach(d.grid.index(atMs: at(14, 22))!) == 1) + #expect(abs(d.reach(d.grid.index(atMs: at(14, 18))!)! - 2.0 / 3) < 1e-9) + #expect(d.reach(d.grid.index(atMs: at(14, 13))!) == nil) + #expect(PlanDial(plan: nil, prices: watch.prices(), nowMs: at(14, 12)).reach(3) == nil) } @Test func aFingerFindsTheQuarterUnderIt() { let d = dial() - for i in [0, 1, 17, 48, 80, 95] { + for i in [0, 1, 17, 24, 40, 47] { let p = finger(d, i) #expect(d.index(x: p.x, y: p.y, from: nil) == i) #expect(d.index(x: p.x, y: p.y, from: max(0, i - 1)) == i) @@ -46,37 +74,39 @@ import Testing /// the last quarter, from now on the first. @Test func theSeamStopsAFinger() { let d = dial() - let start = finger(d, 0), end = finger(d, 95) - #expect(d.index(x: start.x, y: start.y, from: 95) == 95) - #expect(d.index(x: start.x, y: start.y, from: 90) == 95) + let start = finger(d, 0), end = finger(d, 47) + #expect(d.index(x: start.x, y: start.y, from: 47) == 47) + #expect(d.index(x: start.x, y: start.y, from: 42) == 47) #expect(d.index(x: end.x, y: end.y, from: 0) == 0) #expect(d.index(x: end.x, y: end.y, from: 3) == 0) // Half a turn on is still an honest move. - #expect(d.index(x: finger(d, 47).x, y: finger(d, 47).y, from: 1) == 47) + #expect(d.index(x: finger(d, 23).x, y: finger(d, 23).y, from: 1) == 23) } /// The sky is the box's forecast inside the turn, never astronomy: at - /// noon the turn holds this afternoon's sun and tomorrow morning's. + /// noon the turn holds this afternoon's sun, at two in the night the + /// morning's. @Test func theSunComesFromTheForecast() { let day = PlanEntry.Daylight(covered: .init(fromMs: at(14, 0), toMs: at(16, 0)), sun: [ .init(fromMs: at(14, 7, 30), toMs: at(14, 17, 30)), .init(fromMs: at(15, 7, 45), toMs: at(15, 17, 15)), ]) let d = dial(daylight: day) - #expect(d.sun == [at(14, 12)...at(14, 17, 30), at(15, 7, 45)...at(15, 12)]) + #expect(d.sun == [at(14, 12)...at(14, 17, 30)]) // Round the turn: this afternoon's sun starts at the seam, not a - // whole turn on, and tomorrow morning's runs to the end. - let quarter = 2 * Double.pi / 96 - #expect(d.sunAlong.count == 2) + // whole turn on. + let quarter = 2 * Double.pi / 48 + #expect(d.sunAlong.count == 1) #expect(near(d.sunAlong[0].lowerBound, 0) && d.sunAlong[0].lowerBound < quarter) #expect(abs(d.sunAlong[0].upperBound - 22 * quarter) < 1e-9) - #expect(abs(d.sunAlong[1].lowerBound - 79 * quarter) < 1e-9) - #expect(abs(d.sunAlong[1].upperBound - 2 * .pi) < 1e-9) - // With the seam at 18:00: tomorrow's sun sits 55 to 93 quarters on. - let evening = dial(at: at(14, 18, 7), daylight: day) - #expect(evening.sunAlong.count == 1) - #expect(abs(evening.sunAlong[0].lowerBound - 55 * quarter) < 1e-9) - #expect(abs(evening.sunAlong[0].upperBound - 93 * quarter) < 1e-9) + // With the seam at 02:00: the morning's sun sits 23 quarters on and + // runs to the end of the turn. + let night = dial(at: at(15, 2, 7), daylight: day) + #expect(night.sunAlong.count == 1) + #expect(abs(night.sunAlong[0].lowerBound - 23 * quarter) < 1e-9) + #expect(abs(night.sunAlong[0].upperBound - 2 * .pi) < 1e-9) + // At 18:07 the turn runs to 06:00 and holds no sun. + #expect(dial(at: at(14, 18, 7), daylight: day).sunAlong.isEmpty) // A sun that never sets fills the turn. let polar = PlanEntry.Daylight(covered: .init(fromMs: at(14, 0), toMs: at(16, 0)), sun: [.init(fromMs: at(14, 0), toMs: at(16, 0))]) let whole = dial(at: at(14, 18, 7), daylight: polar).sunAlong @@ -103,9 +133,11 @@ import Testing } @Test func theMiddleAndTheScrubLineAreMadeOnce() { - let d = dial() + let d = dial(at: at(14, 17, 7)) let six = d.grid.index(atMs: at(14, 18))!, ten = d.grid.index(atMs: at(14, 22))!, goal = d.grid.index(atMs: at(15, 4, 30))! - #expect(d.times[0] == "12:07" && d.times[six] == "18:00") + #expect(d.times[0] == "17:07" && d.times[six] == "18:00") + // Past midnight the clock names the day. + #expect(d.times[goal] == "TOMORROW 04:30") #expect(d.prices[six] == "160 ÖRE/KWH") #expect(d.doing[six] == "GIVES · 2.0 KW") #expect(d.doing[ten] == "STORES · 3.0 KW") @@ -117,7 +149,7 @@ import Testing let bare = PlanDial(plan: nil, prices: watch.prices(), nowMs: at(14, 12)) #expect(bare.doing[0] == "NO PLAN") // Before the market prices a quarter, the middle says so. - let short = PlanDial(plan: watch.plan(), prices: watch.prices(days: 1), nowMs: at(14, 12)) + let short = PlanDial(plan: watch.plan(), prices: watch.prices(days: 1), nowMs: at(14, 17)) #expect(short.prices[short.grid.index(atMs: at(15, 3))!] == "NO PRICE YET") #expect(short.figure(short.grid.index(atMs: at(15, 3))!) == "–") } @@ -190,9 +222,9 @@ import Testing /// 31 Oct 2026). The window on the way is a car quarter, no more. @Test func theGoalIsOnTheDialOnce() { var plan = watch.plan() - plan.charges.insert(PlanEntry.Charge(loadpointID: plan.charges[0].loadpointID, fromMs: at(14, 13), toMs: at(14, 13, 15), energyWh: 2_750, targetPct: 80), at: 0) - let d = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 12, 7)) - let step = d.grid.index(atMs: at(14, 13))!, goal = d.grid.index(atMs: at(15, 4, 30))! + plan.charges.insert(PlanEntry.Charge(loadpointID: plan.charges[0].loadpointID, fromMs: at(14, 17, 15), toMs: at(14, 17, 30), energyWh: 2_750, targetPct: 80), at: 0) + let d = PlanDial(plan: plan, prices: watch.prices(), nowMs: at(14, 17, 7)) + let step = d.grid.index(atMs: at(14, 17, 15))!, goal = d.grid.index(atMs: at(15, 4, 30))! #expect(d.quarters.indices.filter { d.quarters[$0].carGoalPct != nil } == [goal]) #expect(d.quarters[step].car && d.doing[step].hasSuffix(" · CAR")) #expect(d.doing[goal] == "WAITS · CAR TO 80\u{00A0}%") diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PlanGlanceTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PlanGlanceTests.swift index d011f5fc..7b2090ae 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/PlanGlanceTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PlanGlanceTests.swift @@ -155,10 +155,10 @@ import Testing let dial = PlanDial(plan: long, prices: nil, nowMs: at(12, 20), untilMs: at(14, 0)) #expect(dial.quarters.count == 112) #expect(dial.grid.index(atMs: at(13, 22)) != nil) - // Past one turn the finger's clock names the day. - #expect(dial.time(95) == "19:45") - #expect(dial.time(96) == "TOMORROW 20:00") - #expect(PlanDial(plan: long, prices: nil, nowMs: at(12, 20), untilMs: at(13, 12)).quarters.count == PlanDial.count) + // From midnight on the finger's clock names the day. + #expect(dial.time(15) == "23:45") + #expect(dial.time(16) == "TOMORROW 00:00") + #expect(PlanDial(plan: long, prices: nil, nowMs: at(12, 20), untilMs: at(12, 23)).quarters.count == PlanDial.count) } /// A row's times name tomorrow where the clock would mislead, as the diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift index f46505d7..8a0e71de 100644 --- a/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift +++ b/appleApp/FTWKit/Tests/FTWSharedTests/WatchTests.swift @@ -298,39 +298,41 @@ import Testing // MARK: The dial - @Test func theFaceIsTheNextDayInQuarters() { + @Test func theFaceIsTheNextTwelveHoursInQuarters() { let t = at(14, 12, 7) let dial = PlanDial(plan: plan(), prices: prices(), nowMs: t) - #expect(dial.quarters.count == 96) + #expect(dial.quarters.count == 48) #expect(dial.quarters[0].startMs == at(14, 12)) - #expect(dial.quarters[95].startMs == at(15, 11, 45)) + #expect(dial.quarters[47].startMs == at(14, 23, 45)) #expect(dial.time(0) == "12:07") #expect(dial.time(4) == "13:00") #expect(dial.hasPlan) } - /// 00 at the bottom, 06 on the left, 12 at the top, 18 on the right. - @Test func midnightSitsAtTheBottomAndNoonAtTheTop() { + /// As on a clock: 12 and 00 at the top, 15 on the right, 18 at the + /// bottom, 21 on the left. + @Test func eighteenSitsStraightDown() { let dial = PlanDial(plan: nil, prices: prices(), nowMs: at(14, 12)) func near(_ a: Double, _ b: Double) -> Bool { abs(a - b) < 1e-9 || abs(abs(a - b) - 2 * .pi) < 1e-9 } - #expect(near(dial.angle(at(14, 0)), .pi)) - #expect(near(dial.angle(at(14, 6)), 1.5 * .pi)) + #expect(near(dial.angle(at(14, 0)), 0)) #expect(near(dial.angle(at(14, 12)), 0)) - #expect(near(dial.angle(at(14, 18)), 0.5 * .pi)) + #expect(near(dial.angle(at(14, 15)), 0.5 * .pi)) + #expect(near(dial.angle(at(14, 18)), .pi)) + #expect(near(dial.angle(at(14, 21)), 1.5 * .pi)) #expect(near(dial.handAngle(selected: nil), dial.angle(at(14, 12)))) #expect(near(dial.handAngle(selected: 8), dial.angle(at(14, 14)))) } @Test func eachTickHoldsItsPriceLevelAndWhatTheBatteryDoes() { - let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 17)) func q(_ day: Int, _ h: Int, _ m: Int = 0) -> PlanDial.Quarter { dial.quarters[dial.grid.index(atMs: at(day, h, m))!] } // On a 200 öre top: 160 öre is the seventh step, 30 the second. #expect(q(14, 18).level == 6) #expect(q(15, 2).level == 1) - #expect(q(14, 13).level == 2) + #expect(q(14, 23, 30).level == 2) #expect(q(14, 22).battery == .stores) #expect(q(14, 17).battery == .gives) - #expect(q(14, 13).battery == .waits) + #expect(q(14, 23, 30).battery == .waits) #expect(q(15, 2).car && !q(14, 20).car) #expect(q(15, 4, 30).carGoalPct == 80) #expect(dial.what(dial.grid.index(atMs: at(14, 22))!) == "Battery stores 3.0 kW") @@ -340,7 +342,7 @@ import Testing /// A tick never shows without a price: before tomorrow's prices come, /// those quarters have no level. @Test func unpricedQuartersHaveNoLevel() { - let dial = PlanDial(plan: plan(), prices: prices(days: 1), nowMs: at(14, 12)) + let dial = PlanDial(plan: plan(), prices: prices(days: 1), nowMs: at(14, 17)) let tomorrow = dial.grid.index(atMs: at(15, 3))! #expect(dial.quarters[tomorrow].minor == nil && dial.quarters[tomorrow].level == nil) #expect(dial.price(tomorrow) == "—") @@ -348,7 +350,7 @@ import Testing } @Test func theCrownTapsAtNowTheCheapestEdgesAndTheCarsGoal() { - let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 12)) + let dial = PlanDial(plan: plan(), prices: prices(), nowMs: at(14, 17)) let cheap = try! #require(dial.cheapest) #expect(dial.grid.startMs(cheap.lowerBound) == at(15, 2)) #expect(dial.grid.startMs(cheap.upperBound) == at(15, 3, 45)) From 9a29346897b59e06cb11ca6a7ea86ded226f8ffd Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Sun, 1 Nov 2026 15:35:31 +0100 Subject: [PATCH 4/4] feat(ios): choose which price the app shows Fredrik, 1 November 2026: he wants the price without grid fees, and to know whether it is with or without VAT. Home gets "Price shown" with two switches: with the grid fee and tax, and with VAT. Now's strip, the plan and the price page draw the chosen price (PriceShown, FTWShared, tested) and name it: "SPOT INCL. VAT", "ELPRIS INKL. MOMS". The box sends spotMinor and totalMinor, and with srcfl/ftw#1949 the VAT rate it taxed at, as vatPermille (250 is 25 %), on price.get and on FTW Cloud's price tile. The phone reads both and keeps the rate with the prices. A box that does not send it gets the price the phone has: the total keeps its VAT, the spot price goes without, and the label says so. The widgets and the watch still show the total with VAT. The demo box sends 250. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 ++ appleApp/FTW/UI/HomeTab.swift | 31 +++++++ appleApp/FTW/UI/NowView.swift | 11 ++- appleApp/FTW/UI/PlanStrip.swift | 6 +- appleApp/FTW/UI/PlanView.swift | 26 +++--- appleApp/FTW/UI/PriceView.swift | 15 +++- .../FTWKit/Sources/FTWDemo/SimulatedBox.swift | 3 +- .../Sources/FTWKit/State/NodeWords.swift | 7 ++ .../Sources/FTWKit/State/SiteModel.swift | 2 +- .../Sources/FTWShared/Charts/PlanStrip.swift | 6 +- .../Sources/FTWShared/Charts/PriceChart.swift | 5 ++ .../FTWKit/Sources/FTWShared/PriceShown.swift | 88 +++++++++++++++++++ .../FTWKit/Sources/FTWShared/Prices.swift | 7 +- .../Resources/sv.lproj/Price.strings | 6 ++ .../Sources/FTWWire/Carrier/CloudWire.swift | 6 +- .../Sources/FTWWire/Protocol/Messages.swift | 3 +- .../Tests/FTWKitTests/PriceWireTests.swift | 28 ++++++ .../FTWKitTests/SiteModelCloudTests.swift | 3 + .../FTWSharedTests/PriceShownTests.swift | 47 ++++++++++ 19 files changed, 281 insertions(+), 24 deletions(-) create mode 100644 appleApp/FTWKit/Sources/FTWShared/PriceShown.swift create mode 100644 appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift create mode 100644 appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift diff --git a/CLAUDE.md b/CLAUDE.md index f342a246..f7597d57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -301,6 +301,11 @@ The rules below put these into practice. 6 or 8 times a power of ten), with grid lines and figures at half the top and the top where there is room. Now's strip carries the price now on as a dashed line. A price below zero hangs under the zero line. + Which price is a choice on the phone (Home › Price shown, Fredrik, + 1 Nov 2026): with or without the grid fee and tax, with or without VAT + (`PriceShown`, from `spotMinor`, `totalMinor` and the box's + `vatPermille`). Every chart that shows it says which, and a box that + does not send its rate gets the price the phone has, under its own name. - **Gold on a chart is the sun's.** The sun's curve and the battery giving power are both warm, and a tester read one as the other, so the battery runs in cyan both ways wherever a trace is drawn, as the grid runs in diff --git a/appleApp/FTW/UI/HomeTab.swift b/appleApp/FTW/UI/HomeTab.swift index 598aa493..14612994 100644 --- a/appleApp/FTW/UI/HomeTab.swift +++ b/appleApp/FTW/UI/HomeTab.swift @@ -13,6 +13,8 @@ struct HomeTabView: View { /// Back to the tab's root, after this phone is removed. let popToRoot: () -> Void @AppStorage(Appearance.key) private var appearance = Appearance.standard + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true var body: some View { List { @@ -25,6 +27,7 @@ struct HomeTabView: View { } homes phone + price look about } @@ -92,6 +95,34 @@ struct HomeTabView: View { } } + /// Which price the app shows, on this phone: the total the household + /// pays, or the spot price without the grid fee and tax, each with or + /// without VAT. The footer names what is shown, so a price is never + /// read under the wrong name. + private var price: some View { + // Before the box's prices are read, the label names the choice; + // once they are, it names what they allow. + let held = home.prices.prices + let unsaid = held != nil && held?.vatPermille == nil + let kind = PriceShown(fees: fees, vat: vat).kind(vatPermille: unsaid ? nil : held?.vatPermille ?? 0) + return Section { + Toggle(L("With grid fee and tax"), isOn: $fees) + .accessibilityIdentifier("settings.price.fees") + Toggle(L("With VAT"), isOn: $vat) + .accessibilityIdentifier("settings.price.vat") + } header: { + Text(L("Price shown")) + } footer: { + VStack(alignment: .leading, spacing: 4) { + Text(PriceShown.label(kind)).font(Theme.mono()).tracking(0.5) + if unsaid, kind != PriceShown(fees: fees, vat: vat).kind(vatPermille: 250) { + Text(L("Your box does not say its VAT rate yet, so FTW shows the price it has and says which.")) + } + Text(L("Now, the plan and the price on this phone. The widgets and the watch show the total with VAT.")) + } + } + } + /// Dark, light or the iPhone's own setting, for this app on this phone. private var look: some View { Section { diff --git a/appleApp/FTW/UI/NowView.swift b/appleApp/FTW/UI/NowView.swift index 5bb304b0..bd201446 100644 --- a/appleApp/FTW/UI/NowView.swift +++ b/appleApp/FTW/UI/NowView.swift @@ -423,16 +423,20 @@ private struct PlanSection: View { let zoom: Namespace.ID /// The pass a tap chose on the strip. @State private var chosenID: String? + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true var body: some View { // The plan marks the minute, not the second: it redraws once a // minute however often the clock ticks. let minute = site.minuteMs - let window = prices.prices + // The price this phone chose to show, in the strip and its header. + let choice = PriceShown(fees: fees, vat: vat) + let window = prices.prices.map(choice.apply) 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) + let strip = PlanStrip.build(shown.entry, moment: shown.moment, hasCar: hasCar, prices: window?.slots) PlanStripView( strip: strip, sentence: shown.moment.nextText, @@ -442,7 +446,8 @@ private struct PlanSection: View { unit: PriceUnits.unit(window?.currency).label.uppercased(), fade: StripFade.make(models.planShare.before, strip: strip, nowMs: minute, hasCar: hasCar), zoom: zoom, - chosenID: $chosenID + chosenID: $chosenID, + priceLabel: window.map { PriceShown.label(choice.kind(vatPermille: $0.vatPermille)) } ) .equatable() // Capped where it is placed, so the strip's scaled sizes read the diff --git a/appleApp/FTW/UI/PlanStrip.swift b/appleApp/FTW/UI/PlanStrip.swift index eb82376b..bf6999d0 100644 --- a/appleApp/FTW/UI/PlanStrip.swift +++ b/appleApp/FTW/UI/PlanStrip.swift @@ -51,6 +51,8 @@ struct PlanStripView: View, Equatable { /// The least the price row takes: Now gives it what room it has; the /// plan, which scrolls, asks for more. var leastPrice: CGFloat = PlanStripLayout.leastPrice + /// Which price the bars are, "SPOT EXCL. VAT", after the kicker. + var priceLabel: String? /// Where the strip's drawing sits, for the passes' tap targets. @State private var canvas: CGRect = .zero @GestureState private var dragging = false @@ -62,6 +64,7 @@ struct PlanStripView: View, Equatable { a.strip == b.strip && a.sentence == b.sentence && a.shorter == b.shorter && a.placeholder == b.placeholder && a.priceNow == b.priceNow && a.unit == b.unit && a.fade == b.fade && a.zoom == b.zoom && a.chosenID == b.chosenID && a.opensPlan == b.opensPlan && a.levelHeight == b.levelHeight && a.leastPrice == b.leastPrice + && a.priceLabel == b.priceLabel } /// Mono stops growing at accessibility1. @@ -157,11 +160,12 @@ struct PlanStripView: View, Equatable { /// the strip as a link of its own. private var header: some View { HStack(alignment: .firstTextBaseline, spacing: 4) { - Text(strip.kicker) + Text(priceLabel.map { strip.kicker + " · " + $0 } ?? strip.kicker) .font(mono) .tracking(0.5) .foregroundStyle(Theme.ink2) .lineLimit(1) + .minimumScaleFactor(0.75) .contentTransition(.opacity) if opensPlan { Image(systemName: "chevron.right") diff --git a/appleApp/FTW/UI/PlanView.swift b/appleApp/FTW/UI/PlanView.swift index 82e22424..52c39e0a 100644 --- a/appleApp/FTW/UI/PlanView.swift +++ b/appleApp/FTW/UI/PlanView.swift @@ -122,6 +122,8 @@ private struct PlanBody: View { @State private var strips = StripMemo() /// The pass a tap chose on the strip, lit on the dial too. @State private var chosenID: String? + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true @Namespace private var zoom static let topID = "plan.top" @@ -136,7 +138,10 @@ private struct PlanBody: View { let moved = models.planShare.moved let entry = models.planShare.entry let quarters = Int(((moment.toMs - minute) / PlanStrip.quarterMs).rounded(.up)) + 1 - let strip = strips.strip(entry, moment: moment, hasCar: !loadpoints.points.isEmpty, quarters: style == .lanes ? quarters : PlanStrip.count) + // The price this phone chose to show, in the strip and its header. + let choice = PriceShown(fees: fees, vat: vat) + let window = prices.prices.map(choice.apply) + let strip = strips.strip(entry, moment: moment, hasCar: !loadpoints.points.isEmpty, quarters: style == .lanes ? quarters : PlanStrip.count, prices: window?.slots) let chosen = strip.passes.first { $0.id == chosenID } ?? PlanPass.opening(strip.passes) Group { switch style { @@ -168,13 +173,14 @@ private struct PlanBody: View { sentence: moment.nextText, shorter: moment.nextShorter, placeholder: moment.placeholder, - priceNow: priceNow(minute), + priceNow: priceNow(window, minute), unit: PriceUnits.unit(prices.prices?.currency).label.uppercased(), zoom: zoom, chosenID: $chosenID, opensPlan: false, levelHeight: style == .lanes ? 50 : 0, - leastPrice: 56 + leastPrice: 56, + priceLabel: window.map { PriceShown.label(choice.kind(vatPermille: $0.vatPermille)) } ) .equatable() .fitCap(.xLarge) @@ -212,8 +218,8 @@ private struct PlanBody: View { } /// The price now in whole units, for the strip's header. - private func priceNow(_ minute: Double) -> String? { - guard let window = prices.prices else { return nil } + private func priceNow(_ window: Prices?, _ minute: Double) -> String? { + guard let window else { return nil } let current = window.slots.first { minute >= $0.startMs && minute < $0.startMs + $0.durationMs } return current.map { PriceStrip.whole($0.totalMinor, window.currency) } } @@ -260,13 +266,13 @@ private final class DialMemo { /// The strip worked out once per plan, minute and length, like the dial. @MainActor private final class StripMemo { - private var key: (entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int)? + private var key: (entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int, prices: [PriceSlot]?)? private var kept: PlanStrip? - func strip(_ entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int) -> PlanStrip { - if let key, key.entry == entry, key.moment == moment, key.hasCar == hasCar, key.quarters == quarters, let kept { return kept } - let s = PlanStrip.build(entry, moment: moment, hasCar: hasCar, quarters: quarters) - key = (entry, moment, hasCar, quarters) + func strip(_ entry: PlanEntry, moment: PlanGlance.Moment, hasCar: Bool, quarters: Int, prices: [PriceSlot]?) -> PlanStrip { + if let key, key.entry == entry, key.moment == moment, key.hasCar == hasCar, key.quarters == quarters, key.prices == prices, let kept { return kept } + let s = PlanStrip.build(entry, moment: moment, hasCar: hasCar, quarters: quarters, prices: prices) + key = (entry, moment, hasCar, quarters, prices) kept = s return s } diff --git a/appleApp/FTW/UI/PriceView.swift b/appleApp/FTW/UI/PriceView.swift index 8173f07f..203c73f6 100644 --- a/appleApp/FTW/UI/PriceView.swift +++ b/appleApp/FTW/UI/PriceView.swift @@ -104,6 +104,8 @@ struct PriceView: View { /// Reads the minute, the prices and the plan, and hands them on as values, /// so the page below redraws once a minute at most. private struct PriceBody: View { + @AppStorage(PriceShown.feesKey) private var fees = true + @AppStorage(PriceShown.vatKey) private var vat = true let site: SiteModel let prices: PriceModel let plan: PlanShare @@ -121,7 +123,11 @@ private struct PriceBody: View { // the first and last bars are whole. let from = (window?.fromMs ?? site.nowMs) - 3_600_000 let to = (window?.toMs ?? site.nowMs + 24 * 3_600_000) + 3_600_000 - PricePage(slots: prices.slots(fromMs: from, toMs: to), zone: held.zone, currency: held.currency, + // The price this phone chose to show, and what it holds. + let choice = PriceShown(fees: fees, vat: vat) + let kind = choice.kind(vatPermille: held.vatPermille) + PricePage(slots: choice.apply(prices.slots(fromMs: from, toMs: to), vatPermille: held.vatPermille), + shown: kind == .totalWithVAT ? nil : PriceShown.words(kind), zone: held.zone, currency: held.currency, window: window, minuteMs: site.minuteMs, plan: plan.entry, problem: prices.pastProblem, scrub: scrub, chartMax: chartMax, onPan: onPan, onPanEnded: onPanEnded) .equatable() @@ -136,6 +142,9 @@ private struct PriceBody: View { private struct PricePage: View, Equatable { let slots: [PriceSlot] + /// What the price holds when this phone chose other than the total + /// with VAT; nil reads it off the box's numbers. + let shown: String? let zone: String let currency: String let window: TimelineWindow? @@ -149,7 +158,7 @@ private struct PricePage: View, Equatable { let onPanEnded: () -> Void nonisolated static func == (a: Self, b: Self) -> Bool { - a.slots == b.slots && a.zone == b.zone && a.currency == b.currency && a.window == b.window + a.slots == b.slots && a.shown == b.shown && a.zone == b.zone && a.currency == b.currency && a.window == b.window && a.minuteMs == b.minuteMs && a.plan == b.plan && a.problem == b.problem && a.chartMax == b.chartMax && a.scrub === b.scrub } @@ -158,7 +167,7 @@ private struct PricePage: View, Equatable { let clock = ZoneClock.of(.current) let chart = PriceChart.build(slots, currency: currency, nowMs: minuteMs, fromMs: window?.fromMs, spanMs: window?.spanMs ?? 24 * 3_600_000, plan: plan, clock: clock) - let included = PriceGlance.text(PriceGlance.included(slots)) + let included = shown ?? PriceGlance.text(PriceGlance.included(slots)) // The window as the chart draws it, so a finger takes it from // exactly there even before anything has moved it. let drawn = window ?? TimelineWindow(fromMs: chart.grid.fromMs, toMs: chart.grid.toMs, nowMs: minuteMs) diff --git a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift index ca1d7485..c37a698a 100644 --- a/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift +++ b/appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift @@ -391,7 +391,8 @@ public final class SimulatedBox { slots.append(.map([("startMs", .number(t)), ("durationMs", .number(slotMs)), ("spotMinor", .number((total * 0.6).rounded())), ("totalMinor", .number(total))])) t += slotMs } - return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to))]) + // The VAT rate the totals carry, as a box from srcfl/ftw#1949 says it. + return .map([("zone", .text("SE3")), ("currency", .text("SEK")), ("slots", .array(slots)), ("stale", .bool(end < to)), ("vatPermille", .number(250))]) } // MARK: Commands diff --git a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift index 83c36839..2dc25f2b 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift @@ -414,6 +414,13 @@ public enum NodeWords { "About FTW": "Om FTW", "Homes, settings and about.": "Hem, inställningar och om appen.", + // Which price the app shows + "Price shown": "Priset som visas", + "With grid fee and tax": "Med nätavgift och skatt", + "With VAT": "Med moms", + "Your box does not say its VAT rate yet, so FTW shows the price it has and says which.": "Boxen anger inte sin moms än, så FTW visar det pris den har och säger vilket.", + "Now, the plan and the price on this phone. The widgets and the watch show the total with VAT.": "Nu, planen och priset i den här telefonen. Widgetarna och klockan visar totalpriset med moms.", + // How the app looks "Appearance": "Utseende", "Dark": "Mörkt", diff --git a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift index 1be6192b..67ed2bf1 100644 --- a/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift +++ b/appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift @@ -1565,7 +1565,7 @@ extension SiteModel { spotMinor: Double(spot), totalMinor: Double(total))) } guard !slots.isEmpty else { return } - let prices = Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false) + let prices = Prices(zone: p.zone, currency: p.currency, slots: slots, stale: false, vatPermille: p.vatPermille.map { Int($0) }) cloudPrices = Stamped(PriceEntry(fromMs: Double(p.t0Ms), prices: prices, timeZone: session.box?.tz), asOfMs: view.sentMs(p.header, arrivalMs: Int64(at)).map(Double.init) ?? at, origin: .cloud) sharePrices(prices, fromMs: Double(p.t0Ms), via: .cloud) diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift index d2a9748c..a45bcc25 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift @@ -92,14 +92,16 @@ public struct PlanStrip: Equatable, Sendable { /// from: the next twelve hours, or for the plan's lanes `quarters` /// (the whole plan), with the hours every six once it runs past a day's /// half. - public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool, quarters wanted: Int = Self.count) -> PlanStrip { + public static func build(_ entry: PlanEntry?, moment: PlanGlance.Moment, hasCar: Bool, quarters wanted: Int = Self.count, prices shown: [PriceSlot]? = nil) -> PlanStrip { let clock = ZoneClock.of(moment.timeZone) let nowMs = moment.atMs let first = clock.alignDown(nowMs, stepMs: quarterMs) let count = max(Self.count, wanted) let grid = SlotGrid(firstMs: first, stepMs: quarterMs, count: count, fromMs: first, toMs: first + Double(count) * quarterMs) - let prices = (entry?.prices ?? []).filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } + // The prices this phone chose to show, where the price record has + // them, else the ones under the plan. + let prices = (shown ?? entry?.prices ?? []).filter { $0.totalMinor.isFinite && $0.durationMs > 0 }.sorted { $0.startMs < $1.startMs } let slots = (entry?.battery ?? []).sorted { $0.startMs < $1.startMs } let charges = entry?.charges ?? [] let currency = entry?.currency ?? "SEK" diff --git a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift index cc910fa4..6baba726 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift @@ -298,6 +298,11 @@ enum PriceWord: String, CaseIterable { case dayAverage = "Average %@ · lowest %@ at %@" case noPriceThatDay = "No prices for this day" case someMissing = "%@ with no price" + /// Which price a chart shows, as this phone chose it. + case totalWithVAT = "TOTAL INCL. VAT" + case totalWithoutVAT = "TOTAL EXCL. VAT" + case spotWithVAT = "SPOT INCL. VAT" + case spotWithoutVAT = "SPOT EXCL. VAT" var text: String { Words.bundle.localizedString(forKey: rawValue, value: rawValue, table: "Price") } diff --git a/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift b/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift new file mode 100644 index 00000000..a1310b36 --- /dev/null +++ b/appleApp/FTWKit/Sources/FTWShared/PriceShown.swift @@ -0,0 +1,88 @@ +import Foundation + +/// Which price the app shows, a choice on this phone. Fredrik, 1 Nov 2026: +/// he wants it without grid fees, and wants to know whether it is with or +/// without VAT. The box sends the spot price (no fee, no VAT) and the +/// total it bills (fee, tax and VAT), and since `vatPermille` the rate it +/// taxed at, so the phone can show either with or without VAT. A box that +/// does not say its rate gets the price the phone has, and the label says +/// which that is: a price is never shown under the wrong name. +public struct PriceShown: Equatable, Sendable { + /// With the grid fee and energy tax: the total, not the spot price. + public var fees: Bool + public var vat: Bool + + /// Where the two choices are kept, on this phone. + public static let feesKey = "FTWPriceFees" + public static let vatKey = "FTWPriceVAT" + + public init(fees: Bool = true, vat: Bool = true) { + self.fees = fees + self.vat = vat + } + + /// What is shown, which the label names. + public enum Kind: Equatable, Sendable { + case totalWithVAT, totalWithoutVAT, spotWithVAT, spotWithoutVAT + } + + /// What this choice can show at the box's rate: without it, the total + /// keeps its VAT and the spot price goes without. + public func kind(vatPermille: Int?) -> Kind { + switch (fees, vat, vatPermille) { + case (true, true, _), (true, false, nil): return .totalWithVAT + case (true, false, _?): return .totalWithoutVAT + case (false, true, _?): return .spotWithVAT + case (false, _, _): return .spotWithoutVAT + } + } + + /// The price shown for one slot, in minor units per kWh. + public func minor(_ slot: PriceSlot, vatPermille: Int?) -> Double { + let rate = 1 + Double(vatPermille ?? 0) / 1000 + switch kind(vatPermille: vatPermille) { + case .totalWithVAT: return slot.totalMinor + case .totalWithoutVAT: return slot.totalMinor / rate + case .spotWithVAT: return slot.spotMinor * rate + case .spotWithoutVAT: return slot.spotMinor + } + } + + /// The slots with the shown price in `totalMinor`, so every chart that + /// draws the total draws what this phone chose. + public func apply(_ slots: [PriceSlot], vatPermille: Int?) -> [PriceSlot] { + guard kind(vatPermille: vatPermille) != .totalWithVAT else { return slots } + return slots.map { s in + var out = s + out.totalMinor = minor(s, vatPermille: vatPermille) + return out + } + } + + /// The same for a whole answer. + public func apply(_ prices: Prices) -> Prices { + var out = prices + out.slots = apply(prices.slots, vatPermille: prices.vatPermille) + return out + } + + /// The same in a line's words: "spot price only", "incl. VAT". + public static func words(_ kind: Kind) -> String { + switch kind { + case .totalWithVAT: return GlanceWord.inclGridFeeAndVAT.text + case .totalWithoutVAT: return GlanceWord.inclGridFee.text + case .spotWithVAT: return GlanceWord.inclVAT.text + case .spotWithoutVAT: return GlanceWord.spotOnly.text + } + } + + /// "SPOT INCL. VAT", over a chart. + public static func label(_ kind: Kind) -> String { + switch kind { + case .totalWithVAT: return PriceWord.totalWithVAT.text + case .totalWithoutVAT: return PriceWord.totalWithoutVAT.text + case .spotWithVAT: return PriceWord.spotWithVAT.text + case .spotWithoutVAT: return PriceWord.spotWithoutVAT.text + } + } +} diff --git a/appleApp/FTWKit/Sources/FTWShared/Prices.swift b/appleApp/FTWKit/Sources/FTWShared/Prices.swift index 20bbd9f9..e476b45f 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Prices.swift +++ b/appleApp/FTWKit/Sources/FTWShared/Prices.swift @@ -24,11 +24,16 @@ public struct Prices: Equatable, Sendable, Codable { /// The answer does not cover the window asked for. Which of the three /// shapes it is has to be read off the slots. public var stale: Bool + /// The VAT rate the box taxed `totalMinor` at, in permille: 250 is + /// 25 %. Nil from a box that does not say, and in a record from before + /// it was kept. + public var vatPermille: Int? - public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool) { + public init(zone: String, currency: String, slots: [PriceSlot], stale: Bool, vatPermille: Int? = nil) { self.zone = zone self.currency = currency self.slots = slots self.stale = stale + self.vatPermille = vatPermille } } diff --git a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings index d3893786..af1d55f2 100644 --- a/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings +++ b/appleApp/FTWKit/Sources/FTWShared/Resources/sv.lproj/Price.strings @@ -11,3 +11,9 @@ "Average %@ · lowest %@ at %@" = "Snitt %@ · lägst %@ kl %@"; "No prices for this day" = "Inga priser för det dygnet"; "%@ with no price" = "%@ utan pris"; + +/* Which price a chart shows, as this phone chose it. */ +"TOTAL INCL. VAT" = "TOTALPRIS INKL. MOMS"; +"TOTAL EXCL. VAT" = "TOTALPRIS EXKL. MOMS"; +"SPOT INCL. VAT" = "ELPRIS INKL. MOMS"; +"SPOT EXCL. VAT" = "ELPRIS EXKL. MOMS"; diff --git a/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift b/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift index 4ad0b7d8..bb571e47 100644 --- a/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift +++ b/appleApp/FTWKit/Sources/FTWWire/Carrier/CloudWire.swift @@ -554,6 +554,9 @@ public enum CloudWire { public var spotMinor: [Int64?] /// What the household pays, tariff and VAT included. public var totalMinor: [Int64?] + /// The VAT rate in `totalMinor`, in permille; nil from a box that + /// does not say. + public var vatPermille: Int64? = nil } /// The box is there, and this is how old its readings are. It names the @@ -720,7 +723,8 @@ extension CloudWire.Prices { t0Ms: try f.int("t0Ms"), slotMs: try f.int("slotMs"), spotMinor: try f.list("spotMinor") { $0.isNull ? nil : try CloudWire.int($0, "spotMinor") }, - totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") } + totalMinor: try f.list("totalMinor") { $0.isNull ? nil : try CloudWire.int($0, "totalMinor") }, + vatPermille: try f.optionalInt("vatPermille") ) } } diff --git a/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift b/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift index b482f378..ab8353fb 100644 --- a/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift +++ b/appleApp/FTWKit/Sources/FTWWire/Protocol/Messages.swift @@ -216,7 +216,8 @@ extension Prices { totalMinor: s["totalMinor"]?.double ?? s["spotMinor"]?.double ?? 0 ) }, - stale: c["stale"]?.bool ?? false + stale: c["stale"]?.bool ?? false, + vatPermille: c["vatPermille"]?.int64.map { Int($0) } ) } } diff --git a/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift new file mode 100644 index 00000000..3bcd2b4e --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWKitTests/PriceWireTests.swift @@ -0,0 +1,28 @@ +import Foundation +import Testing +@testable import FTWKit +@testable import FTWWire + +/// The box's answer to `price.get`, as srcfl/ftw#1949 sends it: the slots, +/// and the VAT rate the box taxed the totals at, in permille. +@Suite struct PriceWireTests { + func answer(_ extra: [(String, CBOR)]) -> CBOR { + .map([ + ("zone", .text("SE3")), ("currency", .text("SEK")), ("stale", .bool(false)), + ("slots", .array([.map([("startMs", .int(1_800_000_000_000)), ("durationMs", .int(900_000)), ("spotMinor", .int(249)), ("totalMinor", .int(462))])])), + ] + extra) + } + + @Test func theAnswerCarriesTheBoxsVATRate() { + let p = Prices(answer([("vatPermille", .int(255))])) + #expect(p.vatPermille == 255) + #expect(p.slots.first?.spotMinor == 249 && p.slots.first?.totalMinor == 462) + // Zero is a rate. + #expect(Prices(answer([("vatPermille", .int(0))])).vatPermille == 0) + } + + /// An older box leaves it out: the rate is unknown, not zero. + @Test func anOlderBoxSaysNoRate() { + #expect(Prices(answer([])).vatPermille == nil) + } +} diff --git a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift index ff5042b7..7751cc86 100644 --- a/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift +++ b/appleApp/FTWKit/Tests/FTWKitTests/SiteModelCloudTests.swift @@ -375,6 +375,7 @@ final class CloudSite { let prices = try w.box.seal(CloudWire.msgPrices, w.box.header(w.at) + [ ("zone", .text("SE3")), ("currency", .text("SEK")), ("t0Ms", .int(t0)), ("slotMs", .int(900_000)), ("spotMinor", .array([.int(42), .null, .int(40)])), ("totalMinor", .array([.int(118), .null, .int(115)])), + ("vatPermille", .int(250)), ]) let lp = try w.box.seal(CloudWire.msgLP, w.box.header(w.at) + [ ("controlRev", .int(3)), @@ -394,6 +395,8 @@ final class CloudSite { #expect(p.carrier == .cloud) #expect(p.value.prices.slots.count == 2) #expect(p.value.prices.slots.first?.totalMinor == 118) + // The box's VAT rate rides with them, for the price this phone shows. + #expect(p.value.prices.vatPermille == 250) let car = try #require(w.shared.read(CarEntry.self, siteId: "s1")) #expect(car.carrier == .cloud) #expect(car.value.cars.first?.socPct == 43) diff --git a/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift b/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift new file mode 100644 index 00000000..3fc1adf8 --- /dev/null +++ b/appleApp/FTWKit/Tests/FTWSharedTests/PriceShownTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import FTWShared + +/// The price this phone shows. The box's numbers are Erik's morning: a +/// 462 öre total is 249 öre spot, 121 öre grid fee and tax, and 25 % VAT. +@Suite struct PriceShownTests { + let slot = PriceSlot(startMs: 0, durationMs: 900_000, spotMinor: 249, totalMinor: 462) + + @Test func eachChoiceAtTheBoxsRate() { + func shown(_ fees: Bool, _ vat: Bool) -> Double { PriceShown(fees: fees, vat: vat).minor(slot, vatPermille: 250) } + #expect(shown(true, true) == 462) + #expect(abs(shown(true, false) - 369.6) < 1e-9) + #expect(abs(shown(false, true) - 311.25) < 1e-9) + #expect(shown(false, false) == 249) + } + + /// A box that does not say its rate: the total keeps its VAT and the + /// spot price goes without, and the label says so. + @Test func withoutTheRateTheLabelSaysWhatIsShown() { + #expect(PriceShown(fees: true, vat: false).kind(vatPermille: nil) == .totalWithVAT) + #expect(PriceShown(fees: false, vat: true).kind(vatPermille: nil) == .spotWithoutVAT) + #expect(PriceShown(fees: false, vat: true).minor(slot, vatPermille: nil) == 249) + #expect(PriceShown.label(.spotWithoutVAT) == "SPOT EXCL. VAT") + // Zero VAT is a rate, not a missing one. + #expect(PriceShown(fees: false, vat: true).kind(vatPermille: 0) == .spotWithVAT) + } + + /// The charts draw the total, so the choice goes into it; the default + /// leaves the answer as the box sent it. + @Test func theChoiceGoesIntoTheTotal() { + let prices = Prices(zone: "SE3", currency: "SEK", slots: [slot], stale: false, vatPermille: 250) + #expect(PriceShown().apply(prices) == prices) + #expect(PriceShown(fees: false, vat: false).apply(prices).slots[0].totalMinor == 249) + #expect(PriceShown(fees: false, vat: false).apply(prices).slots[0].spotMinor == 249) + } + + /// An answer kept from before the rate was on the wire still reads. + @Test func aRecordWithoutTheRateStillReads() throws { + let prices = Prices(zone: "SE3", currency: "SEK", slots: [slot], stale: false, vatPermille: 250) + var json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(prices)) as? [String: Any]) + #expect(json["vatPermille"] as? Int == 250) + json["vatPermille"] = nil + let old = try JSONDecoder().decode(Prices.self, from: JSONSerialization.data(withJSONObject: json)) + #expect(old.vatPermille == nil && old.slots == prices.slots) + } +}