fix(reconcile): send ReconciledAt as a UTC instant, not naked digits - #1719
Merged
Merged
Conversation
The "Afstemt <dato> kl. HH:mm" stamp read 1-2 hours early for Danish users, year-round, and it also JUMPED: right at the moment of reconciling it showed the correct time, and after the grid reloaded it moved two hours earlier. The chain: SetReconciledAsync wrote DateTime.Now, which on the shipped container (no TZ, aspnet base image defaults to UTC) is in fact a UTC instant. The column is datetime(6), which carries no offset, so EF materialises it as DateTimeKind.Unspecified, and Newtonsoft with the default RoundtripKind emits a suffix only for Kind Utc or Local. The wire therefore carried "2026-09-17T12:30:00" with nothing after it, and new Date(naked) parses as LOCAL, so the browser relabelled the server's UTC digits as Copenhagen wall clock. The jump came from the dialog's optimistic stand-in, which wrote the BROWSER's wall clock, equally naked: right on arrival, an offset away once the stored value replaced it. The decision (the user's) is that the column holds UTC, the wire tags it, and each viewer's browser renders it in their own local time. Three parts, so the UTC claim is true by construction rather than by an unset environment variable: - SetReconciledAsync writes DateTime.UtcNow, so the column genuinely holds UTC even if ops ever sets TZ on the container. - The read projection in PlanRegistrationHelper specifies DateTimeKind.Utc, so the JSON carries the trailing Z. Null stays null. This is the only projection of ReconciledAt into a response model; the mobile gRPC surface does not carry the field. - The optimistic stand-in uses toISOString(), the same shape as the value that replaces it, so neither is relabelled by the viewer's offset and the stamp no longer jumps. Scope is ReconciledAt only. The sibling stamps with related problems (formatStamp's 'UTC' on the shift stamps, the absence/handover *Utc stamps, CompareDateTimeField in the version-history diff) are recorded as follow-ups, not touched here. Also corrects the comments that encoded the old, false premise, in day-lock.util.ts, its spec, and the design doc. DayLockHelper's pre-existing direction comment was wrong in a way this PR had already copied into the spec twice: at a positive offset UtcNow.Date lags the local date in the first hours AFTER local midnight (at local 00:30 on the 16th at +02:00 it is still the 15th in UTC), not late in the local day, where the two dates agree and the clock makes no difference. Fixed at the origin and in both restatements. Existing rows need no backfill: the container was already UTC, so rows written before this change hold UTC digits too and the new Z is retroactively true for them. Guard: ReconcileServiceTests asserts the projected model's ReconciledAt comes back with Kind == Utc and that an unreconciled day stays null. The Kind half fails on every host including CI if the SpecifyKind is reverted, since EF materialises the offsetless column as Unspecified everywhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
The newly added backend test uses multiple DateTime.UtcNow.Date reads that can become inconsistent across a UTC midnight boundary and intermittently fail.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes incorrect and “jumping” display of the reconciled-day provenance stamp by ensuring ReconciledAt is treated as a UTC instant end-to-end (write as UTC, project as UTC-kind so JSON emits Z, and keep the client’s optimistic stand-in in the same ISO-UTC shape).
Changes:
- Backend: write
ReconciledAtusingDateTime.UtcNowand project it withDateTimeKind.Utcso JSON roundtrips with a trailingZ. - Frontend: use
toISOString()for the optimistic in-dialog stand-in and update unit specs to assert shape rather than zone-dependent digits. - Docs/comments/tests: clarify the timezone rationale (including
UtcNow.Datelock behavior) and add a guard test forReconciledAt.Kind == Utc.
File summaries
| File | Description |
|---|---|
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/TimePlanningPlanningService.cs | Writes ReconciledAt as UtcNow to make the DB value UTC by construction. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs | Re-tags ReconciledAt as DateTimeKind.Utc on projection so JSON includes Z. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs | Corrects/clarifies the UtcNow.Date vs local-date boundary explanation. |
| eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ReconcileServiceTests.cs | Adds a test asserting projected ReconciledAt is UTC-kind and near UtcNow. |
| eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.ts | Uses toISOString() for the optimistic reconcile timestamp to avoid offset relabeling/jumps. |
| eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.spec.ts | Updates dialog unit tests to accept localized output and assert ISO-UTC stamp shape. |
| eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.ts | Updates documentation to reflect UTC-instant contract and timezone-arg behavior. |
| eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/day-lock.util.spec.ts | Updates util unit tests to use Z-tagged stamps and zone-independent assertions. |
| docs/superpowers/specs/2026-09-12-reconciled-day-lock-design.md | Updates design spec to reflect UTC instant handling and corrected midnight-boundary reasoning. |
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+1128
to
+1137
| // UtcNow.Date, not Now.Date: this whole chain is meant to run on one | ||
| // clock, and SeedReconciledBoundaryAsync has to satisfy CanReconcile, | ||
| // which compares against UtcNow.Date. | ||
| var open = await SeedPlain(916, DateTime.UtcNow.Date.AddDays(-1)); | ||
| var boundary = await SeedReconciledBoundaryAsync(916, DateTime.UtcNow.Date.AddDays(-4)); | ||
| var window = new TimePlanningPlanningRequestModel | ||
| { | ||
| DateFrom = DateTime.UtcNow.Date.AddDays(-4), | ||
| DateTo = DateTime.UtcNow.Date | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The "Afstemt <dato> kl. HH:mm" provenance stamp on a reconciled day read 1-2 hours early for Danish users, year-round — and it jumped: correct at the moment of reconciling, two hours earlier once the grid reloaded.
Why
The jump had its own cause: the dialog's optimistic stand-in wrote the browser's wall clock, equally naked — right on arrival, an offset away once the stored value replaced it.
The fix
The decision (the user's) is that the column holds UTC, the wire tags it, and each viewer's browser renders it in their own local time. Three parts, so the UTC claim is true by construction rather than by an unset environment variable:
TimePlanningPlanningService.SetReconciledAsyncDateTime.Now→DateTime.UtcNow, so the column genuinely holds UTC even if ops ever setsTZon the container. Tagging the wire asserts this;UtcNowremoves the dependency.PlanRegistrationHelper(read projection)DateTime.SpecifyKind(…, DateTimeKind.Utc), so the JSON carries the trailingZ. Null-safe via pattern match — a null stays null.workday-entity-dialog.component.tstoISOString()— the same shape as the value that replaces it, so neither is relabelled by the viewer's offset and the stamp stops jumping.Only one projection exists.
PlanRegistrationHelperis the sole placeReconciledAtreaches a response model; the mobile gRPC surface does not carry the field at all.No backfill needed. The container was already UTC, so rows written before this change hold UTC digits too and the new
Zis retroactively true for them.Also corrects the comments that encoded the old, false premise (
day-lock.util.ts, its spec, design doc §6.4), including a warning against the plausible-looking "fix" of naming a zone: Angular resolves theDatePipetimezone argument throughDate.parse('Jan 01, 1970 00:00:00 ' + tz), which isNaNfor an IANA name like'Europe/Copenhagen'and then silently falls back to the browser's own offset.Separately,
DayLockHelper's pre-existing direction comment was wrong in a way this branch had already copied into the spec twice: at a positive offsetUtcNow.Datelags the local date in the first hours after local midnight (at local 00:30 on the 16th at +02:00 it is still the 15th in UTC), not late in the local day, where the two dates agree and the clock makes no difference. Fixed at the origin and in both restatements.Scope
ReconciledAtonly — chosen by the user. The sibling stamps with related problems are recorded as follow-ups below, not touched here.Testing — what is and is not actually enforced
ReconciledAtcomes back withKind == Utc(plus: an unreconciled day stays null). It fails on every host, CI included, if theSpecifyKindis reverted, because EF materialises the offsetless column asUnspecifiedeverywhere. It lives in the existingReconcileServiceTestsfixture, which is already in the shard filters of both workflow files.Within(5 minutes)ofUtcNow) catches a regression toDateTime.Now, but only on a host at a non-zero offset. It is inert on CI and anywhere at UTC+0.eform-client/src/app/…: nopackage.json, nonode_modules, no jest config. Andangular-unit-testiscontinue-on-error: true, so it cannot fail this PR regardless of outcome. The frontend assertions were made zone-independent (shape regexes rather than hard-coded digits) precisely because CI runs UTC, where every clock in this chain coincides and a UTC-only test proves almost nothing.dotnet build TimePlanning.Pn.sln: 0 errors.Recorded follow-ups (not in this PR)
CompareDateTimeField(TimePlanningPlanningService.cs:2134) rendersReconciledAtas naked digits in the admin version-history diff — a string, shared verbatim across ~30DateTimefields, so no Kind is involved and tagging this one would split it from every sibling.formatStamppasses'UTC'for the shift stamps, pinning every viewer to the server's clock.requestedAtUtc/respondedAtUtc/decidedAtUtcstamps.TimePlanningAbsenceRequestGrpcService.cs:214,TimePlanningContentHandoverGrpcService.cs:272.🤖 Generated with Claude Code