diff --git a/.changeset/static-price-provider.md b/.changeset/static-price-provider.md new file mode 100644 index 000000000..fdfc6fc92 --- /dev/null +++ b/.changeset/static-price-provider.md @@ -0,0 +1,7 @@ +--- +"ftw": minor +--- + +A static / time-of-use price provider, so price-driven planning works outside Europe. + +`price.provider: static` takes a flat rate in the install currency, plus optional local-time windows that override peak hours (overnight wrap, optional weekdays). Grid tariff and VAT still apply. Live day-ahead spot remains Europe-only; this is the stand-in for every other market. Settings and the setup wizard offer it next to Sourceful and ENTSO-E. diff --git a/README.md b/README.md index 8a045ac62..176ddc882 100644 --- a/README.md +++ b/README.md @@ -219,6 +219,7 @@ metadata are the detailed reference. - [Product roadmap](docs/roadmap.md) - [Power sign convention](docs/site-convention.md) - [Safety invariants](docs/safety.md) +- [Geographic coverage of external data](docs/data-coverage.md) - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) diff --git a/docs/architecture.md b/docs/architecture.md index d4a7447bf..209a7c1f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,6 +158,12 @@ schema. The handlers registered in [`go/internal/api/api.go`](../go/internal/api/api.go) define the HTTP surface. Driver metadata defines the device catalog. These sources replace manually duplicated reference docs. +External data sources do not all work everywhere. Weather and PV forecasts +are worldwide; live day-ahead spot is European. A site outside ENTSO-E +uses `price.provider: static` — a flat rate or time-of-use schedule — +which is how price-driven planning works without a market feed. See +[data-coverage.md](data-coverage.md). + Some startup bindings cannot be hot-reloaded, including state paths, API listener and selected integration transports. Normal device and control configuration is reloaded through diff --git a/docs/data-coverage.md b/docs/data-coverage.md new file mode 100644 index 000000000..e61cde9fa --- /dev/null +++ b/docs/data-coverage.md @@ -0,0 +1,95 @@ +# Geographic coverage of external data sources + +FTW controls hardware anywhere. The data it plans with does not. + +- **Weather and PV forecasting works worldwide.** +- **Day-ahead spot prices work in Europe only.** +- **A static / time-of-use tariff works anywhere.** That is how a site + outside ENTSO-E (or on a flat retail contract inside it) gets + price-driven planning. + +A site outside Europe still gets monitoring, safety, control, the +dashboard, weather forecasts and the self-learning PV twin. What it did +not have, until `price.provider: static`, was a price source. + +## Spot prices — Europe, or a tariff you type + +Configured under `price.provider`. + +| Provider | Coverage | API key | Notes | +|---|---|---|---| +| `sourceful` | European day-ahead markets | No | Default. Sourceful's cached ENTSO-E API. | +| `elprisetjustnu` | **Sweden only** — zones SE1–SE4 | No | 15-minute PTU since late 2025. | +| `entsoe` | ENTSO-E member markets | Yes | Direct from the Transparency Platform. | +| `static` | **Worldwide** | No | Flat rate or time-of-use schedule the operator types. | +| `none` | — | — | Disables price fetching entirely. | + +There is still no live adapter for CAISO, ERCOT, PJM, AEMO/NEM, JEPX or +the other non-European markets. `static` is the stand-in: a US TOU +tariff, a Japanese flat contract, or any other schedule that can be +written as hours and a rate. + +Prices are stored in **minor units of the configured currency** per kWh +(öre, cent, ¢, pence, …). ENTSO-E figures that arrive in another +currency are converted with **ECB** daily FX rates. Static prices are +already in the install currency; nothing is converted. + +> The Tibber driver (`drivers/tibber.lua`) is telemetry only — it reports +> meter readings, not prices, so it is not a fifth price source. + +```yaml +price: + provider: static + currency: USD + static_ore_kwh: 8 # off-peak, ¢/kWh (minor units) + static_tou: + - { start: "07:00", end: "23:00", ore_kwh: 22 } # peak + grid_tariff_ore_kwh: 4 + vat_percent: 0 +``` + +Windows are local time on the box. First match wins; hours they miss +keep `static_ore_kwh`. Overnight windows wrap (`22:00`–`06:00`). An +optional `days` list (`mon`…`sun`) limits a window to those weekdays. + +The zone picker is served from `GET /api/prices/zones` and is hidden +when the provider is `static`. See +[`go/internal/prices/zones.go`](../go/internal/prices/zones.go) and +[`go/internal/prices/static.go`](../go/internal/prices/static.go). + +## Weather and PV forecasts — worldwide + +Configured under `weather.provider`. All four work at any latitude/longitude. + +| Provider | Coverage | API key | Signal quality | +|---|---|---|---| +| `met_no` | Global | No | Cloud cover only — weakest PV signal. | +| `openweather` | Global | Yes | Cloud cover only. | +| `open_meteo` | Global | No | Shortwave radiation (GHI) — good. | +| `forecast_solar` | Global | No (free tier) | Site-calibrated watts from panel geometry — best. | + +Prefer `open_meteo` or `forecast_solar` when the site has array +geometry: they carry an irradiance signal, which is what the +orientation-aware plane-of-array model needs. + +## What is not in this tree yet + +| Capability | Planned source | Coverage when it lands | +|---|---|---| +| PV performance scoring / forecast calibration | SMHI STRÅNG ([#734](https://github.com/srcfl/ftw/pull/734)) | Nordic region only | +| Automatic roof geometry | Lantmäteriet via STAC ([#735](https://github.com/srcfl/ftw/pull/735)) | Sweden by default; any conformant STAC catalog | + +Until those land, array geometry stays a manual entry on the Weather +tab, and the PV twin still calibrates from measured production. + +## What a non-European site has today + +| Capability | Works outside Europe? | +|---|---| +| Device control, safety, dispatch | Yes | +| Telemetry, history, dashboard | Yes | +| Weather + PV forecasting | Yes | +| Self-learning PV twin | Yes | +| Price-driven planning | **Yes, via `static`** | +| Live day-ahead spot | **No** — Europe only | +| Manual array geometry | Yes | diff --git a/go/internal/config/config.go b/go/internal/config/config.go index ab95bd361..d43dcf0f6 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1411,7 +1411,7 @@ type StateConf struct { // Price is the spot-price source config. type Price struct { - Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | none + Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | static | none Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` GridTariffOreKwh float64 `yaml:"grid_tariff_ore_kwh,omitempty" json:"grid_tariff_ore_kwh,omitempty"` VATPercent float64 `yaml:"vat_percent,omitempty" json:"vat_percent,omitempty"` @@ -1438,6 +1438,37 @@ type Price struct { // most Swedish customer agreements pass through. Set to a pointer // to 0.0 if you have a guaranteed-zero-floor agreement. ExportFloorOreKwh *float64 `yaml:"export_floor_ore_kwh,omitempty" json:"export_floor_ore_kwh,omitempty"` + + // StaticOreKwh is the default energy price in minor units per kWh when + // provider=static. Time-of-use windows below override matching hours; + // hours they miss keep this rate. Zero is a real price (free energy). + StaticOreKwh float64 `yaml:"static_ore_kwh,omitempty" json:"static_ore_kwh,omitempty"` + // StaticTOU is an optional time-of-use schedule in the box's local + // time. First matching window wins. An empty Days list means every day. + StaticTOU []TOUWindow `yaml:"static_tou,omitempty" json:"static_tou,omitempty"` +} + +// TOUWindow is one time-of-use period for provider=static. +type TOUWindow struct { + Start string `yaml:"start" json:"start"` // "HH:MM" local, inclusive + End string `yaml:"end" json:"end"` // "HH:MM" local, exclusive; "24:00" allowed + OreKwh float64 `yaml:"ore_kwh" json:"ore_kwh"` + Days []string `yaml:"days,omitempty" json:"days,omitempty"` // mon..sun; empty = every day +} + +// Validate checks a static tariff's windows. Other providers are accepted +// as-is: an unknown name currently disables fetching rather than failing +// config load, and that behaviour stays. +func (p *Price) Validate() error { + if p == nil || !strings.EqualFold(p.Provider, "static") { + return nil + } + for i, w := range p.StaticTOU { + if strings.TrimSpace(w.Start) == "" || strings.TrimSpace(w.End) == "" { + return fmt.Errorf("price.static_tou[%d]: start and end are required (HH:MM)", i) + } + } + return nil } // Weather is the weather-forecast source config. @@ -1562,6 +1593,18 @@ func (c Config) MaskSecrets() Config { if out.Price != nil { cp := *out.Price cp.APIKey = "" + if len(cp.StaticTOU) > 0 { + tou := make([]TOUWindow, len(cp.StaticTOU)) + copy(tou, cp.StaticTOU) + for i := range tou { + if len(tou[i].Days) > 0 { + days := make([]string, len(tou[i].Days)) + copy(days, tou[i].Days) + tou[i].Days = days + } + } + cp.StaticTOU = tou + } out.Price = &cp } if out.Weather != nil { @@ -2170,6 +2213,11 @@ func (c *Config) Validate() error { if err := c.Assistant.Validate(); err != nil { return err } + if c.Price != nil { + if err := c.Price.Validate(); err != nil { + return err + } + } if err := c.validateVehicles(); err != nil { return err } diff --git a/go/internal/config/config_test.go b/go/internal/config/config_test.go index d5a4658d1..eec42c553 100644 --- a/go/internal/config/config_test.go +++ b/go/internal/config/config_test.go @@ -628,6 +628,40 @@ batteries: } } +func TestStaticPriceParsesTOU(t *testing.T) { + yaml := minimalYAML + ` +price: + provider: static + currency: USD + static_ore_kwh: 8 + static_tou: + - start: "07:00" + end: "23:00" + ore_kwh: 22 + days: [mon, tue, wed, thu, fri] +` + c, err := Parse([]byte(yaml), ".") + if err != nil { + t.Fatal(err) + } + if c.Price.Provider != "static" || c.Price.StaticOreKwh != 8 { + t.Fatalf("flat = %+v", c.Price) + } + if len(c.Price.StaticTOU) != 1 || c.Price.StaticTOU[0].Start != "07:00" { + t.Fatalf("tou = %+v", c.Price.StaticTOU) + } + if err := c.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestStaticPriceRejectsEmptyTOUWindow(t *testing.T) { + p := &Price{Provider: "static", StaticTOU: []TOUWindow{{Start: "", End: "23:00"}}} + if err := p.Validate(); err == nil { + t.Fatal("expected error") + } +} + func TestPVArrayGeometryDistinguishesMissingFromZero(t *testing.T) { yaml := minimalYAML + ` weather: diff --git a/go/internal/prices/prices.go b/go/internal/prices/prices.go index 3defa4963..ae3b276b8 100644 --- a/go/internal/prices/prices.go +++ b/go/internal/prices/prices.go @@ -11,6 +11,8 @@ // hourly if the provider returns that. // - entsoe — All EU, needs ENTSO-E transparency platform API key. // Resolution varies per bidding zone (15m or 60m). +// - static — Operator-supplied flat rate or time-of-use schedule. No +// network. Works anywhere, including markets with no day-ahead feed. // // Consumer price = (spot + grid_tariff) × (1 + VAT/100). We store both // pure spot AND the consumer total so the UI can surface either. @@ -563,7 +565,11 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { } zone := cfg.Zone if zone == "" { - zone = "SE3" + if cfg.Provider == "static" { + zone = "STATIC" + } else { + zone = "SE3" + } } // An unset currency follows the zone: picking BE should not leave a // Belgian household paying in öre. Unknown zones keep the old default. @@ -588,6 +594,8 @@ func FromConfig(cfg *config.Price, st *state.Store, fx FXConverter) *Service { ep.Currency = currency ep.FX = fx p = ep + case "static": + p = NewStatic(cfg) default: return nil } diff --git a/go/internal/prices/static.go b/go/internal/prices/static.go new file mode 100644 index 000000000..88cf1b303 --- /dev/null +++ b/go/internal/prices/static.go @@ -0,0 +1,160 @@ +package prices + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +// StaticProvider synthesises a day of prices from a configured flat rate +// or time-of-use schedule. It is the worldwide stand-in for markets that +// have no day-ahead feed: a US TOU tariff, a Japanese flat contract, or +// any other schedule the operator can type. +// +// Rates are minor units per kWh of the install currency (the same unit as +// grid_tariff_ore_kwh). Fetch converts them to major units so Apply can +// turn them back — the same path every other provider uses. +type StaticProvider struct { + OreKwh float64 + TOU []config.TOUWindow + // Loc is the timezone windows are interpreted in. Nil means the box's + // local zone, which is what a household tariff is billed in. + Loc *time.Location +} + +func NewStatic(cfg *config.Price) *StaticProvider { + if cfg == nil { + return &StaticProvider{} + } + tou := append([]config.TOUWindow(nil), cfg.StaticTOU...) + return &StaticProvider{OreKwh: cfg.StaticOreKwh, TOU: tou} +} + +func (s *StaticProvider) Name() string { return "static" } + +func (s *StaticProvider) loc() *time.Location { + if s != nil && s.Loc != nil { + return s.Loc + } + return time.Local +} + +// Fetch returns 96 quarter-hour slots for the civil day of `day` in the +// provider's location. Zone is ignored: a static tariff is not a bidding +// zone. An empty result is never returned — a configured static source +// always has a price, including zero. +func (s *StaticProvider) Fetch(_ context.Context, _ string, day time.Time) ([]RawPrice, error) { + loc := s.loc() + start := time.Date(day.In(loc).Year(), day.In(loc).Month(), day.In(loc).Day(), 0, 0, 0, 0, loc) + const slotMin = 15 + out := make([]RawPrice, 0, 96) + for i := 0; i < 96; i++ { + t := start.Add(time.Duration(i*slotMin) * time.Minute) + ore, err := s.rateAt(t) + if err != nil { + return nil, err + } + out = append(out, RawPrice{ + SlotStart: t, + SlotLenMin: slotMin, + SEKPerKWh: ore / 100, + }) + } + return out, nil +} + +func (s *StaticProvider) rateAt(t time.Time) (float64, error) { + t = t.In(s.loc()) + mins := t.Hour()*60 + t.Minute() + dow := strings.ToLower(t.Weekday().String()[:3]) // sun, mon, … + for _, w := range s.TOU { + ok, err := windowMatches(w, mins, dow) + if err != nil { + return 0, err + } + if ok { + return w.OreKwh, nil + } + } + return s.OreKwh, nil +} + +func windowMatches(w config.TOUWindow, mins int, dow string) (bool, error) { + if !daysMatch(w.Days, dow) { + return false, nil + } + start, err := parseClockMinutes(w.Start) + if err != nil { + return false, fmt.Errorf("static TOU start %q: %w", w.Start, err) + } + end, err := parseClockMinutes(w.End) + if err != nil { + return false, fmt.Errorf("static TOU end %q: %w", w.End, err) + } + if start == end { + return false, fmt.Errorf("static TOU window %s–%s is empty", w.Start, w.End) + } + if start < end { + return mins >= start && mins < end, nil + } + // Overnight: 22:00–06:00. + return mins >= start || mins < end, nil +} + +func daysMatch(days []string, dow string) bool { + if len(days) == 0 { + return true + } + for _, d := range days { + if dayToken(d) == dow { + return true + } + } + return false +} + +func dayToken(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + switch s { + case "sunday", "sun": + return "sun" + case "monday", "mon": + return "mon" + case "tuesday", "tue", "tues": + return "tue" + case "wednesday", "wed": + return "wed" + case "thursday", "thu", "thur", "thurs": + return "thu" + case "friday", "fri": + return "fri" + case "saturday", "sat": + return "sat" + default: + if len(s) >= 3 { + return s[:3] + } + return s + } +} + +// parseClockMinutes reads "HH:MM" or "H:MM". "24:00" is midnight at the +// end of the day (1440), so a window can close on the civil day boundary. +func parseClockMinutes(s string) (int, error) { + s = strings.TrimSpace(s) + var h, m int + n, err := fmt.Sscanf(s, "%d:%d", &h, &m) + if err != nil || n != 2 { + return 0, fmt.Errorf("want HH:MM") + } + if h == 24 && m == 0 { + return 24 * 60, nil + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, fmt.Errorf("hour 0–23 and minute 0–59, or 24:00") + } + return h*60 + m, nil +} diff --git a/go/internal/prices/static_test.go b/go/internal/prices/static_test.go new file mode 100644 index 000000000..abdf5b991 --- /dev/null +++ b/go/internal/prices/static_test.go @@ -0,0 +1,186 @@ +package prices + +import ( + "context" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestStaticFlatRateFillsADay(t *testing.T) { + loc := time.FixedZone("test", 2*3600) + p := &StaticProvider{OreKwh: 12.5, Loc: loc} + day := time.Date(2026, 9, 3, 15, 0, 0, 0, loc) + rows, err := p.Fetch(context.Background(), "ignored", day) + if err != nil { + t.Fatal(err) + } + if len(rows) != 96 { + t.Fatalf("slots = %d, want 96", len(rows)) + } + if rows[0].SlotStart.Hour() != 0 || rows[0].SlotStart.Minute() != 0 { + t.Errorf("first slot = %s, want local midnight", rows[0].SlotStart) + } + if rows[0].SlotLenMin != 15 { + t.Errorf("slot = %d", rows[0].SlotLenMin) + } + // 12.5 minor units → 0.125 major, the unit Apply multiplies by 100. + if rows[0].SEKPerKWh != 0.125 { + t.Errorf("major = %g, want 0.125", rows[0].SEKPerKWh) + } + last := rows[len(rows)-1] + if last.SlotStart.Hour() != 23 || last.SlotStart.Minute() != 45 { + t.Errorf("last slot = %s", last.SlotStart) + } + if last.SEKPerKWh != rows[0].SEKPerKWh { + t.Errorf("flat rate drifted: first %g last %g", rows[0].SEKPerKWh, last.SEKPerKWh) + } +} + +func TestStaticTOUOverridesFlat(t *testing.T) { + loc := time.FixedZone("test", 0) + p := &StaticProvider{ + OreKwh: 8, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "07:00", End: "23:00", OreKwh: 22}, + }, + } + day := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday + rows, err := p.Fetch(context.Background(), "", day) + if err != nil { + t.Fatal(err) + } + at := func(h, m int) float64 { + t.Helper() + want := time.Date(2026, 9, 3, h, m, 0, 0, loc) + for _, r := range rows { + if r.SlotStart.Equal(want) { + return r.SEKPerKWh * 100 + } + } + t.Fatalf("missing slot %02d:%02d", h, m) + return 0 + } + if got := at(6, 45); got != 8 { + t.Errorf("06:45 = %g, want off-peak 8", got) + } + if got := at(7, 0); got != 22 { + t.Errorf("07:00 = %g, want peak 22", got) + } + if got := at(22, 45); got != 22 { + t.Errorf("22:45 = %g, want peak 22", got) + } + if got := at(23, 0); got != 8 { + t.Errorf("23:00 = %g, want off-peak 8", got) + } +} + +func TestStaticOvernightWindow(t *testing.T) { + loc := time.UTC + p := &StaticProvider{ + OreKwh: 20, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "22:00", End: "06:00", OreKwh: 5}, + }, + } + day := time.Date(2026, 9, 3, 12, 0, 0, 0, loc) + rows, err := p.Fetch(context.Background(), "", day) + if err != nil { + t.Fatal(err) + } + ore := func(h int) float64 { + t.Helper() + want := time.Date(2026, 9, 3, h, 0, 0, 0, loc) + for _, r := range rows { + if r.SlotStart.Equal(want) { + return r.SEKPerKWh * 100 + } + } + t.Fatalf("missing hour %d", h) + return 0 + } + if ore(22) != 5 || ore(5) != 5 { + t.Errorf("overnight: 22h=%g 5h=%g, want 5", ore(22), ore(5)) + } + if ore(6) != 20 || ore(12) != 20 { + t.Errorf("daytime: 6h=%g 12h=%g, want 20", ore(6), ore(12)) + } +} + +func TestStaticWeekdayWindows(t *testing.T) { + loc := time.UTC + p := &StaticProvider{ + OreKwh: 10, + Loc: loc, + TOU: []config.TOUWindow{ + {Start: "00:00", End: "24:00", OreKwh: 30, Days: []string{"sat", "sunday"}}, + }, + } + thu := time.Date(2026, 9, 3, 0, 0, 0, 0, loc) // Thursday + sat := time.Date(2026, 9, 5, 0, 0, 0, 0, loc) + thuRows, err := p.Fetch(context.Background(), "", thu) + if err != nil { + t.Fatal(err) + } + satRows, err := p.Fetch(context.Background(), "", sat) + if err != nil { + t.Fatal(err) + } + if thuRows[48].SEKPerKWh*100 != 10 { + t.Errorf("Thursday noon = %g, want weekday 10", thuRows[48].SEKPerKWh*100) + } + if satRows[48].SEKPerKWh*100 != 30 { + t.Errorf("Saturday noon = %g, want weekend 30", satRows[48].SEKPerKWh*100) + } +} + +func TestStaticZeroIsARealPrice(t *testing.T) { + p := &StaticProvider{OreKwh: 0, Loc: time.UTC} + rows, err := p.Fetch(context.Background(), "", time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + if len(rows) != 96 { + t.Fatalf("slots = %d", len(rows)) + } + if rows[0].SEKPerKWh != 0 { + t.Errorf("got %g", rows[0].SEKPerKWh) + } +} + +func TestFromConfigStatic(t *testing.T) { + s := FromConfig(&config.Price{ + Provider: "static", + Currency: "USD", + StaticOreKwh: 14, + }, nil, nil) + if s == nil { + t.Fatal("expected service") + } + if s.Provider.Name() != "static" { + t.Errorf("name = %s", s.Provider.Name()) + } + if s.Zone != "STATIC" { + t.Errorf("zone = %s, want STATIC when none is set", s.Zone) + } + if s.Currency != "USD" { + t.Errorf("currency = %s", s.Currency) + } +} + +func TestParseClockMinutes(t *testing.T) { + got, err := parseClockMinutes("7:00") + if err != nil || got != 7*60 { + t.Errorf("7:00 → %d %v", got, err) + } + got, err = parseClockMinutes("24:00") + if err != nil || got != 24*60 { + t.Errorf("24:00 → %d %v", got, err) + } + if _, err := parseClockMinutes("25:00"); err == nil { + t.Error("25:00 should fail") + } +} diff --git a/web/components/price-units.js b/web/components/price-units.js index 3bd609840..c8d3386bb 100644 --- a/web/components/price-units.js +++ b/web/components/price-units.js @@ -24,6 +24,10 @@ const UNITS = { CZK: { label: "Kč", perKwh: "Kč/kWh", axis: "Kč", scale: 0.01, decimals: 2 }, HUF: { label: "Ft", perKwh: "Ft/kWh", axis: "Ft", scale: 0.01, decimals: 1 }, RON: { label: "lei", perKwh: "lei/kWh", axis: "lei", scale: 0.01, decimals: 2 }, + USD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 }, + GBP: { label: "p", perKwh: "p/kWh", axis: "p", scale: 1, decimals: 1 }, + AUD: { label: "c", perKwh: "c/kWh", axis: "c", scale: 1, decimals: 1 }, + CAD: { label: "¢", perKwh: "¢/kWh", axis: "¢", scale: 1, decimals: 1 }, }; // A currency with no entry above is shown in its major unit under its ISO diff --git a/web/components/price-units.test.mjs b/web/components/price-units.test.mjs index bb10f57c9..4bf10677e 100644 --- a/web/components/price-units.test.mjs +++ b/web/components/price-units.test.mjs @@ -15,7 +15,7 @@ import { describe("price units", () => { it("keeps minor units where households quote them", () => { - for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"]]) { + for (const [code, label] of [["SEK", "öre"], ["EUR", "cent"], ["NOK", "øre"], ["DKK", "øre"], ["USD", "¢"], ["GBP", "p"]]) { const u = unitFor(code); assert.equal(u.label, label); assert.equal(u.scale, 1, `${code} should stay in minor units`); diff --git a/web/price-zone-picker.test.mjs b/web/price-zone-picker.test.mjs index 32ce707a1..716aeae49 100644 --- a/web/price-zone-picker.test.mjs +++ b/web/price-zone-picker.test.mjs @@ -57,6 +57,13 @@ describe("price zone picker", () => { assert.match(priceTab, /FALLBACK/); assert.match(setupHtml, /'; } + function touEditor(ctx, unit) { + var escHtml = ctx.escHtml; + var windows = (ctx.config.price && ctx.config.price.static_tou) || []; + var rows = windows.map(function (w, i) { + return '
' + + 'Local time on the box. First matching window wins; hours they miss keep the flat rate. ' + + 'Overnight windows wrap (22:00–06:00). Days are weekday names; blank means every day.
' + + '