Meters: add manual reading entry from the meter-detail Readings tab
ci / build-test (push) Successful in 1m37s
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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user