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
@@ -199,6 +199,69 @@ public sealed class IngestionServiceTests(TimescaleFixture fx)
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_hand_entered_reading_is_stamped_manual_and_normalizes_immediately()
{
// The meter-detail "Add reading" path: provenance has to survive, otherwise a value somebody
// walked to the meter to read is indistinguishable from one a sensor reported.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
var written = await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Written, written);
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(ReadingQuality.Manual, reading.Quality);
Assert.True(await db.Consumption.AnyAsync(c => c.MeterId == meterId));
// Correcting a typo re-enters the same timestamp: value replaced, still manual.
var updated = await service.IngestByMeterAsync(meterId, T0, 1100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.Updated, updated);
var corrected = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1100d, corrected.Value, 6);
Assert.Equal(ReadingQuality.Manual, corrected.Quality);
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_source_reporting_the_same_timestamp_does_not_relabel_a_hand_entered_reading()
{
await using var db = fx.CreateContext();
var (meterId, sourceId) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
await service.IngestAsync(sourceId, T0, 1200); // same instant, this time from the broker
var reading = await db.Readings.AsNoTracking().SingleAsync(r => r.MeterId == meterId && r.Time == T0);
Assert.Equal(1200d, reading.Value, 6); // the newer value still wins...
Assert.Equal(ReadingQuality.Manual, reading.Quality); // ...but provenance is not silently rewritten
await CleanupAsync(db, meterId);
}
[Fact]
public async Task A_hand_entered_decrease_on_a_counter_is_rejected_like_any_other()
{
// The dialog warns before saving, but the guard is what actually protects the series: a
// mistyped register must not silently wipe out a month of consumption.
await using var db = fx.CreateContext();
var (meterId, _) = await SetupAsync(db, MeterMode.CumulativeCounter);
var service = NewIngestion(db);
await service.IngestByMeterAsync(meterId, T0, 1000, quality: ReadingQuality.Manual);
var outcome = await service.IngestByMeterAsync(
meterId, T0.AddDays(30), 100, quality: ReadingQuality.Manual);
Assert.Equal(IngestionOutcome.RejectedDecrease, outcome);
Assert.False(await db.Readings.AnyAsync(r => r.MeterId == meterId && r.Time == T0.AddDays(30)));
await CleanupAsync(db, meterId);
}
private static IngestionService NewIngestion(MeterVaultDbContext db) =>
new(db, new MeterVault.Infrastructure.Normalization.NormalizationService(
db, MeterVault.Core.Normalization.NormalizationEngine.CreateDefault()));