Skip to content

Conversation

@naltatis
Copy link
Member

@naltatis naltatis commented Jan 14, 2026

Add form based configuration for tariffs and forecast to config ui.

πŸ’΅πŸƒβ˜€οΈπŸ•°οΈ make grid, feedin, co2, solar and planner tariffs ui configurable
πŸ‘› add fixed and fixed-zones tariff template. keeping separate to simplify standard setup use case
πŸ“ introduce zones param type to specify hour/day/month based prices
⚑ introduce pricePerKWh param to support currency specific input ct/kWh, rp/kWh, ...
πŸ“Š forecast visualization, using existing price chart
🚚 migration: no auto-migration, config via yaml (file or ui) still works, tariffs can be migrated one by one. only one source allowed per tariff type

TODOs

  • translations
  • type: static template (zones)
  • ui polish
  • persistence and ref handling
  • migration behavior (parallel to yaml, lock ui if yaml exists)
  • cleanup initialization
  • refactor price chart data prep
  • global currency setting
  • ui for zones param
  • adjust existing e2e
  • add e2e for new process

Screenshots
Bildschirmfoto 2026-01-15 um 13 10 38

Bildschirmfoto 2026-01-15 um 17 08 21 Bildschirmfoto 2026-01-15 um 15 59 36 Bildschirmfoto 2026-01-15 um 16 56 31 Bildschirmfoto 2026-01-15 um 15 58 54 Bildschirmfoto 2026-01-15 um 13 13 47 Bildschirmfoto 2026-01-15 um 13 11 21 Bildschirmfoto 2026-01-15 um 13 11 06 Bildschirmfoto 2026-01-15 um 13 11 01 Bildschirmfoto 2026-01-15 um 13 10 51
config.ui.tariff.webm

legacy ui yaml, upgrade hint

Bildschirmfoto 2026-02-02 um 15 42 32 Bildschirmfoto 2026-02-02 um 15 42 43 Bildschirmfoto 2026-02-02 um 15 44 04

@naltatis naltatis added enhancement New feature or request ux User experience/ interface labels Jan 14, 2026
@andig
Copy link
Member

andig commented Jan 14, 2026

This will also simplify adding additional fees ;)

cmd/setup.go Outdated
}

// Track which types are configured via YAML
yamlConfigured := make(map[api.TariffUsage]bool)
Copy link
Member

Choose a reason for hiding this comment

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

Das ist viel Code fΓΌr yaml. KΓΆnnen wir das einfach nicht tun, fΓΌr teil-yaml Setups keine weitere UnterstΓΌtzung anbieten?

cmd/setup.go Outdated
})
}

// Load device-based tariffs
Copy link
Member

Choose a reason for hiding this comment

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

Wozu brauchts das? Die stehen in den tariffs.* Variablen doch schon drin?

var res TariffRefs
if err := settings.Json(keys.TariffRefs, &res); err != nil {
// return defaults if not configured
res = TariffRefs{Currency: "EUR", Solar: []string{}}
Copy link
Member

Choose a reason for hiding this comment

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

do this in line 24

// return defaults if not configured
res = TariffRefs{Currency: "EUR", Solar: []string{}}
}
if res.Solar == nil {
Copy link
Member

Choose a reason for hiding this comment

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

remove


// Validate and update tariff references
if payload.Grid != nil {
if *payload.Grid != "" {
Copy link
Member

Choose a reason for hiding this comment

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

does this make sense? Either ignore in the first place or error. Loops instead of duplicated code?

@naltatis
Copy link
Member Author

Zones UI (work in progress)

Bildschirmfoto 2026-01-19 um 17 49 47 Bildschirmfoto 2026-01-19 um 17 49 54

@naltatis
Copy link
Member Author

Here the working ui for the new type: zones param type.

Bildschirmaufnahme.2026-01-19.um.20.18.53.webm

@naltatis
Copy link
Member Author

Add UI for currency configuration:

Bildschirmfoto 2026-01-22 um 17 03 52 Bildschirmfoto 2026-01-22 um 17 03 55 Bildschirmfoto 2026-01-22 um 17 05 13

@naltatis naltatis mentioned this pull request Jan 27, 2026
6 tasks
@github-actions github-actions bot added the stale Outdated and ready to close label Jan 29, 2026
@naltatis naltatis removed the stale Outdated and ready to close label Jan 30, 2026
@naltatis
Copy link
Member Author

naltatis commented Feb 2, 2026

I reworked the tariff initialization and device ref handling mechanics. Mixed mode (evcc.yaml | yaml ui) + device-based is not supported. Existing users have to remove their yaml based config and configure via UI. All changes should be non-breaking: opt-in.

@andig You can now review. Only thing that's missing from my side is proper test coverage of the new device process.

@naltatis naltatis marked this pull request as ready for review February 2, 2026 14:53
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 3 issues, and left some high level feedback:

  • In configureTariffDevices you start goroutines over the configurable slice using the loop variable conf directly; this will be captured by reference and can lead to all goroutines seeing the last elementβ€”create a shadow variable inside the loop (e.g. c := conf) and close over that instead.
  • The new fmtCurrencyName helper instantiates Intl.DisplayNames on each call; consider caching the DisplayNames instance per-locale or per-component to avoid unnecessary allocations and potential performance issues when rendering lists of currencies.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `configureTariffDevices` you start goroutines over the `configurable` slice using the loop variable `conf` directly; this will be captured by reference and can lead to all goroutines seeing the last elementβ€”create a shadow variable inside the loop (e.g. `c := conf`) and close over that instead.
- The new `fmtCurrencyName` helper instantiates `Intl.DisplayNames` on each call; consider caching the `DisplayNames` instance per-locale or per-component to avoid unnecessary allocations and potential performance issues when rendering lists of currencies.

## Individual Comments

### Comment 1
<location> `cmd/setup.go:901-910` </location>
<code_context>
 	return nil
 }

+func configureTariffDevices(names ...string) error {
+	// load tariff devices from database
+	configurable, err := config.ConfigurationsByClass(templates.Tariff)
+	if err != nil {
+		return err
+	}
+
+	var eg errgroup.Group
+
+	for _, conf := range configurable {
+		eg.Go(func() error {
+			cc := conf.Named()
</code_context>

<issue_to_address>
**issue (bug_risk):** Fix goroutine closure over loop variable in configureTariffDevices

In `configureTariffDevices`, the goroutine inside the `for _, conf := range configurable` loop closes over `conf`, which is reused each iteration. This means all goroutines may see the same (last) `conf` value, leading to incorrect device instances or duplicate work.

Shadow `conf` inside the loop before starting the goroutine:

```go
for _, conf := range configurable {
    conf := conf // shadow loop variable
    eg.Go(func() error {
        cc := conf.Named()
        // ...
    })
}
```

This ensures each goroutine captures the correct configuration, matching the pattern used elsewhere in the codebase.
</issue_to_address>

### Comment 2
<location> `assets/js/components/Config/PropertyZoneForm.vue:182` </location>
<code_context>
+				timeTo: timeTo || "00:00",
+			};
+		},
+		convertFromUiZone(uiZone: UiZone) {
+			const isAllDay = uiZone.timeFrom === "00:00" && uiZone.timeTo === "00:00";
+			return {
</code_context>

<issue_to_address>
**issue:** Avoid saving incomplete time ranges as invalid hour strings like '-'

With the current behaviour, creating/editing a zone with empty `timeFrom`/`timeTo` passes validation (`validateTimeRange` returns `false` β†’ `hasValidationErrors` stays `false`), so `handleSave` allows saving and `convertFromUiZone` may emit `hours: "-"`, `"-HH:MM"` or `"HH:MM-"`. This is neither a valid range nor the "all day" representation and will be awkward to handle server‑side.

Please either:
- Normalise empty `timeFrom`/`timeTo` to an explicit "all day" value at the UI layer, or
- Treat partially filled/empty pairs as invalid and block saving until both are set (or both are `"00:00"`).

You can tighten `validateTimeRange` as in your example and then explicitly map the "no constraint" case to `hours: ""` in `convertFromUiZone`.
</issue_to_address>

### Comment 3
<location> `assets/js/mixins/formatter.ts:420` </location>
<code_context>
+        value: i,
+      }));
+    },
+    fmtConsecutiveRange(
+      selectedIndices: number[],
+      getNameFn: (transformedIndex: number) => string | undefined,
</code_context>

<issue_to_address>
**issue (complexity):** Consider replacing the generic fmtConsecutiveRange (and transformFn) with dedicated weekday/month range formatters and extracting time helpers into a separate module to make the mixin logic more direct and self-contained.

You can keep the new capabilities while reducing indirection by specializing the range formatters and dropping the generic `fmtConsecutiveRange` + `transformFn` layer.

### 1. Replace `fmtConsecutiveRange` with two explicit helpers

The current flow:

- `fmtWeekdaysRange` β†’ `fmtConsecutiveRange` (with `transformFn`)
- `fmtMonthsRange` β†’ `fmtConsecutiveRange`

For two very specific domains, the generic helper (plus transform step) makes it harder to trace what happens. You can keep *exactly* the same behavior with two small, local implementations.

Example replacement:

```ts
// keep fmtWeekdayByIndex / fmtMonthByIndex as-is

fmtWeekdaysRange(selectedWeekdays: number[]): string {
  if (!selectedWeekdays || selectedWeekdays.length === 0) return "–";

  // map Sunday(0) -> 7 for range detection, but keep original index to format
  const mapped = selectedWeekdays.map(i => (i === 0 ? 7 : i));
  const sorted = [...mapped].sort((a, b) => a - b);
  const max = sorted[sorted.length - 1]!;

  const parts: string[] = [];
  let i = 0;

  const name = (mappedIndex: number) =>
    this.fmtWeekdayByIndex(mappedIndex % 7, "short");

  while (i < sorted.length) {
    const start = sorted[i]!;
    let end = start;
    while (i + 1 < sorted.length && sorted[i + 1] === end + 1) {
      end = sorted[i + 1]!;
      i++;
    }

    if (end - start > 1) {
      parts.push(`${name(start)} – ${name(end)}`);
    } else if (end > start) {
      parts.push(`${name(start)}, ${name(end)}`);
    } else {
      parts.push(name(start));
    }
    i++;
  }

  return parts.join(", ");
},

fmtMonthsRange(selectedMonths: number[]): string {
  if (!selectedMonths || selectedMonths.length === 0) return "–";

  const sorted = [...selectedMonths].sort((a, b) => a - b);
  const parts: string[] = [];
  let i = 0;

  const name = (idx: number) => this.fmtMonthByIndex(idx, "short");

  while (i < sorted.length) {
    const start = sorted[i]!;
    let end = start;
    while (i + 1 < sorted.length && sorted[i + 1] === end + 1) {
      end = sorted[i + 1]!;
      i++;
    }

    if (end - start > 1) {
      parts.push(`${name(start)} – ${name(end)}`);
    } else if (end > start) {
      parts.push(`${name(start)}, ${name(end)}`);
    } else {
      parts.push(name(start));
    }
    i++;
  }

  return parts.join(", ");
},
```

Then you can delete `fmtConsecutiveRange` and the `transformFn` concept entirely. This keeps all behavior (including the Sunday=0 ↔ 7 trick) but makes weekday/month formatting self-contained and easier to reason about.

### 2. Keep index→name helpers, drop extra abstraction

`fmtWeekdayByIndex` / `fmtMonthByIndex` and `getWeekdaysList` / `getMonthsList` are reasonable primitives. With specialized range functions, they’re still useful without having to route through a generic formatter:

```ts
getWeekdaysList(format: Intl.DateTimeFormatOptions["weekday"]) {
  return Array.from({ length: 7 }, (_, i) => {
    const value = (i + 1) % 7; // Mon=1...Sun=0
    return { name: this.fmtWeekdayByIndex(value, format), value };
  });
}

getMonthsList(format: Intl.DateTimeFormatOptions["month"]) {
  return Array.from({ length: 12 }, (_, i) => ({
    name: this.fmtMonthByIndex(i, format),
    value: i,
  }));
}
```

No other code needs to understand `transformFn` or the internal algorithm; they call the explicit `fmtWeekdaysRange` / `fmtMonthsRange`.

### 3. Consider moving time helpers to a separate module

To keep this mixin focused on β€œvalue β†’ label” formatting and not time parsing, consider extracting:

```ts
// timeFormatUtils.ts (or similar)
export function fmtTimeStr(locale: string, timeStr: string): string { /* ... */ }
export function fmtTimeRange(locale: string, timeRange: string): string { /* ... */ }
```

and in the mixin:

```ts
fmtTimeStr(timeStr: string): string {
  return fmtTimeStr(this.$i18n?.locale, timeStr);
},
fmtTimeRange(timeRange: string): string {
  return fmtTimeRange(this.$i18n?.locale, timeRange);
},
```

This keeps all behavior, but reduces the sense of the mixin being a grab‑bag and makes weekday/month label logic easier to find and understand.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click πŸ‘ or πŸ‘Ž on each comment and I'll use the feedback to improve your reviews.

Co-authored-by: Maschga <88616799+Maschga@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request ux User experience/ interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants