Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s

Entering a reading by hand previously meant POST /api/v1/readings with an
API key, or a one-row CSV through the import wizard. SourceType.Manual
existed in the enum but nothing was behind it. This adds the click path,
built for the case it is actually used in: walking to each manual meter
with a phone in hand.

"Add reading" on the Readings tab opens a dialog prefilled with the
meter's last register value and the current local time, both editable:

- An on-screen keypad, because a register is read standing at the meter.
  It behaves like a calculator against the prefill - the first digit
  replaces it (a fresh register), while backspace edits it in place,
  which is the common case since only a register's last digits move.
- Typed input accepts both separators (last one wins), so a German and
  an English phone keyboard both do the right thing. ReadingEntry owns
  that rule and is unit-tested; it deliberately differs from
  GermanNumber, where a lone dot really is a thousands separator.
- A live parsed-value echo plus delta-since-last, which is the net that
  catches a mistyped digit before it is committed.
- Decrease / replaces-existing / future / backdated surfaced before
  saving, and DST spring-forward gaps refused rather than shifted.

The verdict line sits in a fixed-height, no-wrap slot above the keypad.
That is load-bearing, not cosmetic: an alert that appears there when the
value dips below the last reading moves the keys out from under the
user's thumb mid-entry, which is a guaranteed mistype on a phone. The
long-form explanation goes below the keypad, where reflow is harmless.

Saving goes through IngestionService.IngestByMeterAsync, so the
monotonic-decrease guard and inline renormalization apply exactly as for
any other ingest. A new optional quality parameter stamps the row
ReadingQuality.Manual; null preserves today's behaviour, so a source
re-reporting the same timestamp updates the value without silently
relabelling a hand-entered or imported reading.

Also: the meter-detail tabs now render times in the instance timezone
per SDD section 10, instead of raw UTC. Without it a reading entered at
18:00 reads back as 16:00. Side effect is that historic imported monthly
rows show 01:00/02:00 rather than 00:00 - correct, if noisier.

Claude-Session: https://claude.ai/code/session_01D4x3JbNKCSV4cBR9s7bJmX
This commit is contained in:
2026-08-01 10:25:05 +02:00
parent 1f575c9da2
commit af786c7b28
8 changed files with 782 additions and 12 deletions
@@ -56,21 +56,26 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease;
}
var outcome = await UpsertAsync(meter, utc, value, source.Id, cancellationToken).ConfigureAwait(false);
var outcome = await UpsertAsync(meter, utc, value, source.Id, quality: null, cancellationToken).ConfigureAwait(false);
await UpdateSourceStatusAsync(source, utc, value, "ok", cancellationToken).ConfigureAwait(false);
await RenormalizeAsync(meter.Id, outcome, cancellationToken).ConfigureAwait(false);
return outcome;
}
/// <summary>Ingests directly against a meter (REST push, e.g. Home Assistant POST /api/v1/readings).</summary>
/// <summary>Ingests directly against a meter (REST push, or a hand-entered reading from the UI).</summary>
/// <param name="renormalize">
/// False to skip deriving consumption, for callers ingesting a batch into one meter: recomputing
/// rewrites the meter's entire series, so doing it per reading is quadratic in batch size. Such a
/// caller must recompute the affected meters itself once the batch is in.
/// </param>
/// <param name="quality">
/// Provenance to stamp on the row. Null keeps the default for a new row and leaves an existing
/// row's quality alone — a source re-reporting a timestamp must not silently relabel a reading
/// somebody entered by hand or that came from an import.
/// </param>
public async Task<IngestionOutcome> IngestByMeterAsync(
int meterId, DateTimeOffset time, double value, bool renormalize = true,
CancellationToken cancellationToken = default)
ReadingQuality? quality = null, CancellationToken cancellationToken = default)
{
var meter = await _db.Meters
.FirstOrDefaultAsync(m => m.Id == meterId, cancellationToken).ConfigureAwait(false);
@@ -86,7 +91,7 @@ public sealed class IngestionService(
return IngestionOutcome.RejectedDecrease;
}
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, cancellationToken).ConfigureAwait(false);
var outcome = await UpsertAsync(meter, utc, value, sourceId: null, quality, cancellationToken).ConfigureAwait(false);
await _db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
if (renormalize)
{
@@ -161,7 +166,8 @@ public sealed class IngestionService(
}
private async Task<IngestionOutcome> UpsertAsync(
Meter meter, DateTimeOffset utc, double value, int? sourceId, CancellationToken cancellationToken)
Meter meter, DateTimeOffset utc, double value, int? sourceId, ReadingQuality? quality,
CancellationToken cancellationToken)
{
var existing = _db.Readings.Local.FirstOrDefault(r => r.MeterId == meter.Id && r.Time == utc)
?? await _db.Readings.FirstOrDefaultAsync(r => r.MeterId == meter.Id && r.Time == utc, cancellationToken).ConfigureAwait(false);
@@ -174,13 +180,18 @@ public sealed class IngestionService(
Time = utc,
Value = value,
SourceId = sourceId,
Quality = ReadingQuality.Measured,
Quality = quality ?? ReadingQuality.Measured,
});
return IngestionOutcome.Written;
}
existing.Value = value;
existing.SourceId = sourceId ?? existing.SourceId;
if (quality is { } stamp)
{
existing.Quality = stamp;
}
return IngestionOutcome.Updated;
}