Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions appleApp/FTW/UI/HomeTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -25,6 +27,7 @@ struct HomeTabView: View {
}
homes
phone
price
look
about
}
Expand Down Expand Up @@ -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 {
Expand Down
11 changes: 8 additions & 3 deletions appleApp/FTW/UI/NowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
6 changes: 5 additions & 1 deletion appleApp/FTW/UI/PlanStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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")
Expand Down
26 changes: 16 additions & 10 deletions appleApp/FTW/UI/PlanView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 12 additions & 3 deletions appleApp/FTW/UI/PriceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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?
Expand All @@ -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
}

Expand All @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion appleApp/FTWKit/Sources/FTWDemo/SimulatedBox.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions appleApp/FTWKit/Sources/FTWKit/State/NodeWords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion appleApp/FTWKit/Sources/FTWKit/State/SiteModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1582,7 +1582,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)
Expand Down
6 changes: 4 additions & 2 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PlanStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve plan prices outside the price record's coverage

When the separately fetched price record is older or shorter than the plan, this replaces all of the plan's prices instead of falling back for uncovered quarters. Both Now and Plan now pass PriceModel.prices.slots here, and PriceModel.load() deliberately retains its previous window when fetching fails. Consequently, a newly received plan can contain tomorrow's prices while these strips show missing-price stubs for tomorrow—or no prices at all if the retained window has ended—even with the default display settings. Preserve coverage from the plan, applying the selected price representation consistently, rather than treating any non-nil price array as complete.

Useful? React with 👍 / 👎.

let slots = (entry?.battery ?? []).sorted { $0.startMs < $1.startMs }
let charges = entry?.charges ?? []
let currency = entry?.currency ?? "SEK"
Expand Down
5 changes: 5 additions & 0 deletions appleApp/FTWKit/Sources/FTWShared/Charts/PriceChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }

Expand Down
Loading