Skip to content

feat(lock): reconciled ('Afstemt') day lock — frontend - #1713

Merged
renemadsen merged 30 commits into
stablefrom
feat/reconciled-day-lock-frontend
Sep 17, 2026
Merged

renemadsen merged 30 commits into
stablefrom
feat/reconciled-day-lock-frontend

Conversation

@renemadsen

@renemadsen renemadsen commented Sep 16, 2026

Copy link
Copy Markdown
Member

Draft: stacked on #1711. This branch contains #1711's backend commits too, so its diff shrinks to the frontend once that merges. Do not merge this before #1711, and do not ship it to production before the service PR (microting/eform-service-timeplanning-plugin#1301) is deployed — until then background jobs could still write days the web shows as closed.

The UI for the Reconciled ("Afstemt") day lock. Marking a worker's day Afstemt freezes that day and every earlier day for that worker; it can only be unlocked newest-first.

What a planner sees

  • Three day states in the grid. The newest reconciled day carries a seal and a boundary line; earlier days show a lock; earlier days that were themselves reconciled keep their seal without the line. Texture, glyph, cursor and tooltip all carry the state, so colour is never the only signal. A legend appears while a locked day is on screen.
  • A locked day opens read-only rather than refusing to open — people read closed days constantly. It cannot be saved or reset, and it says which day must be freed first.
  • Reconcile takes two steps in the dialog footer, and leaves a provenance line behind.
  • Unlock asks for a typed word and is offered only on the newest reconciled day. Sealing takes a click; unsealing takes a word.
  • Bulk reconcile from the toolbar previews exactly which days will be sealed, per worker, before anything is sent. With rows ticked the scope is those workers, otherwise every visible worker. A reload or a date change cancels the preview rather than widening it. The result says how many were sealed, how many were already sealed further ahead, and how many had no registration.
  • A request that strands, times out or answers late says the state is uncertain and refreshes the view, rather than claiming a failure the client cannot know about.

Permissions

Only the first user may reconcile, unlock or bulk-reconcile — a late reversal of the original "any web user" decision. This PR is the UX half: it hides the reconcile button, the unlock button, the "Reconcile through" field, the bulk preview, the clickable past-day header and the row tick boxes from everyone else, rather than disabling them with an explanation. The server enforces the rule independently in #1711.

All lock display stays visible to every user: hatching, seals, glyphs, tooltips, the legend, the read-only dialog, the provenance line and the free-first line. Knowing a day is frozen is not a permission.

Copy that used to instruct a now-gated verb was reworded to be descriptive — including unlockFreeFirst, which is shown to every user and previously read "Unlock {date} first."

Notes for review

  • Styles live in feat(timeplanning): add locked and reconciled day-cell styles eform-angular-frontend#8068, per the house rule. This PR emits the classes; that PR draws them.
  • Shard s is new, with its own activation bootstrap. Five specs run there for the first time: glyphs, dialog confirm, unlock word, bulk preview, and the full flow. Its cleanup treats a stale grid as "unknown" rather than "clean", so a leaked lock cannot pass silently to the next spec.
  • Danish and English are written; the other 24 locales carry English until translated.

🤖 Generated with Claude Code

renemadsen and others added 29 commits September 15, 2026 07:12
Marking a day Afstemt freezes it and every earlier day for that worker,
against web edits, mobile registrations and background recalculation.
Earlier days lock without being marked Reconciled themselves, and a day
can only be unlocked once every day after it has been unlocked -- the
boundary moves, it never gets a hole punched in it.

Three findings shaped the design:

Reconciled/ReconciledAt already exist on PlanRegistrations and
PlanRegistrationVersions (migration 20260127060748) and ship in the
pinned Microting.TimePlanningBase 10.0.62. Nothing writes them. So this
needs no base-repo change, no migration and no NuGet bump.

The lock is derived from MAX(Date) WHERE Reconciled, not stored. That
makes "earlier days are locked but not marked" and "unlock only in
reverse order" properties of the model rather than rules something has
to enforce and keep in sync.

There is no choke point: 32 write sites across 11 files in 2 repos, and
PnBase.Create/Update/Delete live in the base package. But every write
goes through EF change tracking, so a SaveChanges interceptor sees all
of them. Call-site guards alone would repeat the MaxDaysEditable
mistake, which is enforced only on the read path and in the browser and
is bypassable by a crafted POST today.

Barring reconcile at or after today is load-bearing beyond the obvious:
it is what makes the forward flex cascades -- one running 180 days
ahead, one unbounded -- provably unable to reach a locked day. The spec
records that so nobody relaxes it without revisiting them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bulk reconcile needs three selection modes -- picked rows, a picked day
column, or everything visible in the chosen period. They collapse into
one operation with two axes, a target date and a set of workers, rather
than three separate features: the date comes from a column header or the
picker, and the worker set comes from row selection, defaulting to every
visible row. Recorded as a table mapping each requested mode onto that
one mechanism.

Two consequences worth writing down. The affected cells are previewed in
place before committing, so the cascade is something you see rather than
infer. And a row already reconciled past the target date is skipped, not
moved backwards -- that would be an unlock, which is deliberately the
heavier action; skipped rows are counted and reported instead of quietly
dropped.

Reconciled and TransferredToPayroll stay independent. They are adjacent
columns from the same migration, which makes it easy to assume they are
related; they are not, in either direction.

Mobile rejects the write with the same localized failure as the web
paths and renders no lock state. No mobile UI work is in scope.

Open questions section now holds nothing blocking -- just the skipped-row
reporting surface and whether ReconciledBy is wanted on the record, which
would need the base migration this design otherwise avoids.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 tasks across three repos, phased so the backend is complete and
enforceable at task 6.

Revised after review, which found the first draft would not have
survived execution. The defects worth recording:

The interceptor opened a second DbContext to read the boundary, and
built it with ServerVersion.AutoDetect -- which opens a connection and
runs a version query. That guard fires on every save, including the four
Update calls inside the per-day loop that runs on every dashboard load;
50 workers over a month would have meant thousands of extra connections
per page view. It now queries the context it is already inside, which is
safe because the boundary query projects into an anonymous type and
tracks nothing.

The same mistake in miniature: the rewritten context helper used
AutoDetect where the factory it replaced hardcodes the version, adding a
round-trip per worker per load. Hardcoded to match.

Task 5 said to iterate a filtered collection while still projecting the
unfiltered one. UpdatePlanRegistrationsInPeriod has a single 715-line
loop that fuses writing and projection, so following that instruction
would have dropped locked days out of the grid -- silently wrong rather
than blocked, which is the worst failure mode for a plan. Now: guard the
four Update calls, leave the loop alone.

LockedThrough was set inside that loop, so a worker with no rows in the
window never got one and a fully locked month would have rendered
editable.

A `using Pomelo...` line that does not compile -- this repo uses the
Microting fork. Three tests that fail in arrange because the seed order
put the row inside the range its own Create would be blocked by. A test
that seeded WorkflowState = Removed, which PnBase.Create overwrites
unconditionally. Three test helpers that are private or do not exist.
Seven wrong line numbers.

Also corrected: the rationale for guarding setDisabled claimed the
cascade re-enables unconditionally. It does not -- every enable is
value-guarded. The true reason is that none is guarded by isInTheFuture,
which is a pre-existing bug the lock would inherit. An agent that
checked the false premise would have skipped a load-bearing step.

Spec UI requirements 8.1-8.4 (glyph, tooltip, legend, two-step confirm,
bulk preview, typed unlock confirmation) have no tasks and are listed as
an open decision rather than quietly dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Design and plan are complete and reviewed; no implementation code
exists. Records where the branch is, the one decision still open
(spec 8.1-8.4 UI requirements have no tasks -- particularly the typed
unlock confirmation, which is the only safeguard on a feature any web
user can trigger), what the design settled so it is not re-litigated,
and the traps the plan review already caught so they are not
reintroduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renamed STATUS -> HANDOFF and folded in what a cold session needs that
was previously only in chat: the dev-mode gate, the normal development
cycle including the dual review gate, subagent-driven execution, the
CI-only testing rule and the shard-filter trap, and a note that stable
has moved and that the dependency-alignment map shares this branch by
accident.

One entry point instead of a status note plus a separate brief.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The lock is derived, never stored: a worker's boundary is MAX(Date) over
their non-removed Reconciled PlanRegistration rows. Every day at or before
that boundary is locked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every PlanRegistration write is now checked against the worker's
reconciled boundary via a SaveChangesInterceptor, on both its
original and its current (site, date) slot -- so a write cannot
escape the lock by moving a locked row's date past the boundary or
reassigning it to another worker.

Two writes remain permitted inside the locked range: clearing the
flag on the boundary day itself (unlock, which also requires
ReconciledAt to be cleared), and a payroll-flag-only write, which
spec §11.2 requires so exporting a reconciled period keeps working.

Wired into the pooled DbContext registration, TimePlanningDbContextHelper,
and both test context builders in TestBaseSetup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconcile rejects today and later days (I2) and is idempotent; unlock is
allowed only on the worker's boundary day; reconcile-through marks one
landing day per worker and reports skips by reason; a concurrent boundary
move degrades to a per-site skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…en recalculating

- web and mobile edits of a locked day return DayIsReconciled or DayIsLockedByReconciledDay;
- the dashboard recompute reverts locked days before any save, so a closed month renders its stored values and never trips the lock;
- gap-fill never creates rows inside the lock, and sends a non-persisted placeholder day instead, so the positional grid keeps one column per date;
- the working-hours save skips locked rows (the page posts every row) and marks them read-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…th tasks

The spec gains section 13, which lists what changed during execution: the
payroll-flag exemption, the two-slot check, reverting locked days before any
save, placeholder days for the positional grid, and the working-hours skip.
Section 6.3 is corrected, and the section 8.3 grid gotcha now uses
disableRowClickSelection.

The plan gains Task 5B (remaining plugin write paths) and the reviewed UI
tasks for section 8.1-8.4 (8A-14A), which settles the handoff's open decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… handover inside the lock

- The startup pause-id repair skips locked rows, runs on an interceptor-guarded context, and a DayLockedException can no longer take the host down.
- Sheet pull, import and the flex follow-up skip locked rows before tracking them.
- Flex corrections, absence approval and handover accept are refused up front with a message that says what the day is.
- CorruptedPauseIdRepairTests now runs in CI (it was in no shard).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each day carries Reconciled/ReconciledAt, and each row carries LockedThrough, set even when the worker has no rows in the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- a test proves both production context paths attach the lock;
- unlock refusals name the day to free first;
- create-time lock checks for absence and handover requests;
- lock refusals are no longer mislabelled or swallowed;
- self-contained comments, twin headers, and skip counts logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Status line, pinned base version, and section 13 now say what is built:
the plugin write paths are guarded, unlock refusals name the day, the race
window is documented, and the frontend must not ship before the service
repo's PR is deployed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The boundary day carries the seal; earlier days show the lock, and
earlier sealed days keep their seal without the boundary line.

Placeholder days inside a locked range render blank and do not open.

A legend appears while a locked day is on screen.

The day-lock rules live in one util, with unit tests and an
end-to-end spec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add three help entries (Danish and English prose) for the reconcile-through
toolbar control and the two new day-cell lock states.
Add the day-lock UI strings in Danish and English, carrying English into the
other 24 locales until translated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A locked day opens read-only, including the reset affordances, and
cannot be saved.
Reconcile takes two steps in the footer and leaves the provenance line
behind.
Unlock is offered only on the newest reconciled day and asks for a typed
word; other locked days name the day to free first.
A request that strands, times out or answers late leaves the day's state
uncertain rather than claiming failure, and the grid refreshes on close.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pick a date and the grid shows exactly which days will be sealed,
per worker, before anything is sent.

With rows ticked the scope is those workers, otherwise every visible
worker; a reload or a date change cancels the preview rather than
widening it.

The result names how many were sealed, how many were already sealed
further ahead, and how many had no registration.

A request that strands, times out or answers late leaves the state
uncertain and refreshes the view.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Shard s is added to the Playwright matrix in both dotnet-core-pr.yml and
dotnet-core-master.yml, so the four reconcile specs written into it finally
run. It gets no seed of its own -- the workflow's Create database step falls
back to a/'s dumps -- but it does get the plugin-activation bootstrap that q
and r carry, because setting EformPlugins.Status in the seed does not load
the plugin, and without the load the first /plannings/index wait in every
spec times out. activate-plugin and assert-true sort ahead of the reconcile
specs, so they run first.

reconcile-day-lock.spec.ts walks the whole flow: reconcile a day, watch the
cascade shut every earlier day without sealing it, open one of those days
read-only, be refused the unlock below the boundary and told which day to
free first, then unlock at the boundary and see the cascade open again. It
uses last week, so no day in view is today or later, and it anchors rows by
worker name rather than by the positional cell ids.

The frame all five specs repeated -- the login, the last-week navigation and
the unlock afterEach -- moves into useLastWeekDashboard() in the shared
helpers. Its pickWorker is now the only way to obtain a worker name, and it
registers every row it hands out for cleanup, including rows a test expects
to leave untouched. The cleanup re-reads the grid from the server before
scanning, because a test that fails between a lock change and its reload
leaves a stale grid that would report the row as already open, and it is
best-effort throughout: one failing step no longer abandons the rows behind
it, and the failures are raised together at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Register the app's locales in the table spec's bed, because these cases are the
first to format a header date.
Override the auth selectors properly in the dialog's mock store; a string key
overrode nothing, so an unhandled selector error had been leaking from every case
that runs ngOnInit and only surfaced once a test held a clock.
Assert the whole reconcile-through message, since the no-registration clause is
reported separately by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconcile, unlock and bulk reconcile now require the admin role, matching how payroll export and the pay-rule endpoints are gated.
Reading a day, editing an open one and the lock's own display are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Supersedes 07b598e ("restrict reconcile and unlock to admins"), already
pushed to this branch/PR #1711: the product decision changed again after
that commit landed. It is not a role at all — only the first user (the
account with the lowest AspNetUsers Id, per FirstUserHelper) may reconcile,
unlock, or bulk-reconcile a day. The three controller actions now carry a
bare [Authorize] (anonymous still refused by the pipeline); which signed-in
caller may proceed is decided in TimePlanningPlanningService instead, since
no [Authorize] role can express "the first user".

Review follow-ups folded in: the first-user check now runs as the first
statement inside each method's existing try (a DB round-trip above the try
would have escaped the catch-all as a raw 500 with nothing in Sentry,
instead of the OperationResult every other failure here produces); the
refusal is extracted once into OnlyTheFirstUserCanReconcileOrUnlock(),
mirroring the existing OnlyLatestReconciledDayCanBeUnlocked() precedent;
the message key is renamed to match (it also covers unlock and bulk
refusals, not just reconcile) with a trailing full stop in both languages,
consistent with its resx neighbours; and the test suite gained the
first-user behaviour tests (success, a different signed-in user refused
with no write, and the userId-0-vs-empty-table edge case) alongside the
restored open-day/no-role-restriction pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconcile, unlock and the bulk reconcile are now offered only to the first
user. This supersedes the admin-role approach: an earlier pass gated the same
three actions on selectCurrentUserIsAdmin, and the product decision changed to
the first user before it shipped. The two are not interchangeable. The admin
flag is read off the token and is there the moment login lands; isFirstUser
rides on the current-user slice, which is populated separately, so every read
of it here is a live subscription and never take(1), and the grid rebuilds its
header row if the flag answers after the first build.

Hidden, never disabled, and with no line saying why: a disabled control would
have to explain itself, and no copy here may explain a restriction by appealing
to what some other user may do. What is hidden:

  - the Reconcile day button and its "save first" note (reconcileEligible);
  - the Unlock button (new canUnlock = isFirstUser && isBoundaryDay);
  - the "Reconcile through" toolbar field;
  - the bulk preview itself and its commit, guarded at the one place a preview
    is built and again where the request is made;
  - the clickable past-day column header, by not installing its template, so
    everyone else keeps the date above the column and loses only the
    affordance;
  - the row tick boxes, which exist to scope a bulk reconcile and nothing else;
  - the toolbar.reconcileThrough help entry, through a new firstUserOnly axis
    on HelpEntry. Deliberately NOT folded into adminOnly: several people hold
    the admin role while the first user is the single lowest-id account, so an
    admin who is not the first user was being taught a field their toolbar does
    not have.

All lock DISPLAY stays for every user: the hatching and seals, the glyphs and
tooltips, the legend, the read-only dialog, the provenance line and the "free
this day first" line. Knowing a day is frozen is not a privilege. isBoundaryDay
is deliberately left free of the flag, because it also decides whether the
footer names the day to free first; gating it would have told a reader standing
on the boundary to free that same day first.

isAdmin keeps its own jobs — the help tour, the payroll export button and the
help panel — so both flags now sit side by side, each with a comment saying the
other is not its duplicate.

The server enforces this independently; the UI is the courtesy, not the
control.

Tests: every gated control has a pair, the same call as someone else and as the
first user, so no negative half can pass vacuously. Existing cases that built
the component as the bed's default user now say which user they mean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The spec and plan still asserted "any web user may reconcile" / "no admin
gate", the opposite of what commits 07b598e and 1eb5f77 actually built.
Updates §8.6 Permissions (the reversal, why it is a service-layer check and
not an [Authorize] role, that the UI hides rather than disables the three
controls, and that lock display stays visible to everyone regardless), adds
a §13 revision bullet recording when and why the decision reversed a second
time and the release-order consequence for PR1/PR4, and points the plan's
Global Constraints and Spec coverage lines at §8.6 instead of restating the
mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two copy lines instructed the reader to unlock a day, and the Unlock button
they pointed at is now the first user's alone.

The day dialog's "free this day first" line is the plain one: it is shown to
every user on every locked non-boundary day, and it read "Unlock {{date}}
first." The help detail for a locked day ended the same way. Help chrome is
admin-gated, not first-user-gated, so its reader is precisely the
admin-who-is-not-the-first-user that the ordinary Microting account is: told to
go and unlock the boundary day, they navigate there and find no button.

Both lines now say what releases the day instead of telling someone to release
it — "The day is released when {{date}} is unlocked." — and neither names who
may act, because copy may not explain a restriction by appealing to what some
other user may do. The entry stays visible to everyone: knowing why a day is
closed is not a privilege, so it did NOT gain firstUserOnly.

unlockFreeFirst lives in all 25 shared locale files, but only Danish was ever
translated; the other 24 carried the English string. The e2e spec that pins the
Danish sentence is updated with it. A sweep of every other string this feature
ships, in both locales, found no third case: the remaining imperatives all sit
behind a control the first user alone is given.

Also corrects a rationale comment that was simply false. It claimed isFirstUser
reaches the store later than isAdmin because one rides the user slice and the
other the token. Both arrive in the same auth payload; there is no such window.
The live subscriptions, headersBuilt and the rebuild-on-change all stay, for the
two reasons that are true: bound inputs make ngOnChanges and the first header
build run before ngOnInit, so that build cannot have seen the flag whatever the
store does, and the flag can change while the page is alive.

Three smaller corrections: the help panel's "nothing but isAdmin changes what is
listed" is stale now that there are two axes; the table's equality guard no
longer claims store.select re-emits repeats, which distinctUntilChanged already
prevents; and the registry spec no longer bans an entry from carrying both
flags, since entries() ANDs them and a legitimate case exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stable's #1712 rewrote the sheet pull's column mapping (header name instead
of a fixed stride, so `columnSiteMap` keyed by column index became `workers`,
a list of (WorkerColumns, Site) pairs) while this branch added the
reconciled-day lock guard to the same row loop. The two are orthogonal: the
mapping decides WHICH site a pair of columns belongs to, the guard then asks
whether THAT site's day is frozen. Neither reads the other's state.

Resolution in GoogleSheetHelper.PullEverythingFromGoogleSheet:

- Kept stable's mapping verbatim. The resolved file is byte-identical to
  stable's apart from the three additive lock hunks.
- `lockedThroughBySite` is still seeded from `oneMinuteTimelines.Keys`, which
  stable now builds from `workers` rather than `columnSiteMap.Values`. That
  set still covers every site the row loop can reach -- `workers` only holds
  sites with a non-null MicrotingUid, since `sitesByKey` filters them -- which
  is what the guard needs, because DayLockHelper.IsLocked reads a site missing
  from the map as having no boundary, i.e. open. Comment updated to say so.
- The guard keeps its position: first thing after the test that decides this
  worker participates in this row (stable's short-row check, which replaced
  the old `columnSiteMap.TryGetValue` continue), and before the
  "Processing site" line, the PlanRegistration load and every write. A locked
  row is still never loaded, so it is never tracked and no later save can
  flush it. Both of stable's new write paths -- the create leg and the
  null-tolerant update leg -- sit below it, as does stable's own
  PlanChangedByAdmin skip, which is narrower (update leg only) and stays.
- The two skip counters are folded into stable's single summary line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two cases in "Bulk reconcile preview" went red in CI. Both set a visible range
of their own and then loaded through loadLastWeek, which since the fixture was
hoisted also SETS the range — so it put last week back underneath them and they
ended up asserting about bounds derived from a window they had not asked for.
"offers no target at all when the whole visible range is today or later" got a
maxDate from last week instead of null, and "drops the preview when the week
navigates away from the target" never navigated away, so the preview stayed.

The bounds are derived from dateFrom/dateTo at load time and only at load time,
which is correct: in the app every handler that moves the range loads, so the
two always agree. The product code is not at fault here.

So the helper splits rather than the assertions relaxing. loadRows() loads and
leaves the range alone; loadLastWeek() sets last week and delegates to it. The
two cases call loadRows, and the helper says which to use and why, so the next
case that wants its own window does not walk into the same thing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@renemadsen
renemadsen marked this pull request as ready for review September 17, 2026 11:08
Copilot AI lite review requested due to automatic review settings September 17, 2026 11:08
@renemadsen
renemadsen merged commit 58013b1 into stable Sep 17, 2026
40 checks passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

A couple of small but concrete UI/test issues (button default submit type inside a form; minor test naming) should be corrected to avoid unintended behavior and keep the new flows robust.

Get a fresh assessment by requesting another Copilot review.

Pull request overview

Implements the “Reconciled / Afstemt” day lock end-to-end: a derived per-worker lock boundary (latest reconciled day) that makes the boundary day and all earlier days read-only, with reconcile/unlock + bulk “reconcile through” flows and consistent messaging across web + mobile write paths.

Changes:

  • Add derived day-lock helpers + EF SaveChangesInterceptor to enforce the lock across all PlanRegistration write paths (with explicit payroll-flag exemption) and wire it into all production DbContext builders.
  • Extend backend models/endpoints and update multiple services (working hours, flex, absence, handover, imports, Google Sheet sync, pause-id repair) to pre-check/skip locked days to avoid partial writes and avoid interceptor-triggered tracked-state issues.
  • Add frontend models/services, UI flows (grid states, dialog footer confirm, typed-word unlock, bulk reconcile preview/scope bar), help registry gating, translations, and Playwright shard s coverage.
File summaries
File Description
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningWorkingHoursService/TimePlanningWorkingHoursService.cs Apply derived lock state to working-hours read model and skip locked rows on writes.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningPlanningService/ITimePlanningPlanningService.cs Expose reconcile/unreconcile/reconcile-through service methods.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/TimePlanningFlexService/TimePlanningFlexService.cs Guard flex batch updates and follow-up loop against locked days.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/RebusService/RebusService.cs Ensure message handlers use a lock-guarded DbContext.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/ContentHandoverService/ContentHandoverService.cs Refuse/guard handovers that would touch locked days to avoid partial persistence.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Services/AbsenceRequestService/AbsenceRequestService.cs Refuse/guard absence requests that touch locked days to avoid inconsistent approvals.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.resx Add lock/reconcile backend message keys (EN).
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs Surface new resource keys in the designer.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.da.resx Add lock/reconcile backend message keys (DA).
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningPrDayModel.cs Add Reconciled + ReconciledAt to day cells.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/TimePlanningPlanningModel.cs Add LockedThrough boundary per worker row.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughResultModel.cs New model for bulk reconcile results.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Models/Planning/ReconcileThroughRequestModel.cs New model for bulk reconcile request payload.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Interceptors/ReconciledDayLockInterceptor.cs New EF interceptor enforcing the lock invariant for all writes.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/TimePlanningDbContextHelper.cs Build guarded DbContexts with the lock interceptor attached.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/PlanRegistrationHelper.cs Prevent recalculation writes into locked days; project lock fields to API model.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/GoogleSheetHelper.cs Skip locked rows during full-history Google Sheet resync.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/FirstUserHelper.cs New helper for “first user” authorization checks.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/DayLockHelper.cs New single source of truth for lock boundary derivation + predicates.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Infrastructure/Helpers/CorruptedPauseIdRepair.cs Skip locked rows during startup pause-id repair; add observability counters.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/EformTimePlanningPlugin.cs Wire interceptor into pooled DbContext; use guarded context for repair; add wiring seam.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Controllers/TimePlanningPlanningController.cs Add reconcile/unreconcile/reconcile-through endpoints with [Authorize].
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/WorkingHoursImportRemovedRowTests.cs Extend import tests for lock skip behavior; refactor workbook helper.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TimePlanningFlexServiceRemovedRowTests.cs Add flex tests for lock batch refusal and follow-up loop lock-safety.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/TestBaseSetup.cs Ensure test DbContexts include interceptor; expose plugin DB connection string.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/PayrollExportRemovedPlanRegistrationTests.cs Add test proving payroll export can flag rows inside reconciled periods.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockWiringTests.cs New tests ensuring production context builders attach the interceptor.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockInterceptorTests.cs New tests pinning interceptor invariants + exemptions.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/DayLockHelperTests.cs New tests pinning boundary derivation and helper predicates.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/CorruptedPauseIdRepairTests.cs Fix test setup duplication; add lock skip coverage for repair job.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/ContentHandoverServiceTests.cs Add lock refusal coverage for handover create/accept flows.
eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn.Test/AbsenceRequestServiceTests.cs Add lock refusal coverage for absence create/approve flows.
eform-client/src/app/plugins/modules/time-planning-pn/testing/register-test-locales.ts Register Angular locale data in Jest beds to avoid NG0701 in non-EN locales.
eform-client/src/app/plugins/modules/time-planning-pn/services/time-planning-pn-plannings.service.ts Add API calls for reconcile/unreconcile/reconcile-through.
eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/time-planning.model.ts Add lockedThrough boundary field to frontend row model.
eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/reconcile-through-result.model.ts New frontend model mirroring bulk reconcile result shape.
eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/planning-pr-day.model.ts Add reconciled + reconciledAt to frontend day cell model.
eform-client/src/app/plugins/modules/time-planning-pn/models/plannings/index.ts Export new reconcile-through result model.
eform-client/src/app/plugins/modules/time-planning-pn/i18n/bgBG.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/csCZ.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/da.ts Add reconciled/lock UI strings (DA).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/deDE.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/elGR.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/enUS.ts Add reconciled/lock UI strings (EN).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/esES.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/etET.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/fiFI.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/frFR.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/hrHR.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/huHU.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/isIS.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/itIT.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/ltLT.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/lvLV.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/nlNL.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/noNO.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/plPL.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptBR.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/ptPT.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/roRO.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/skSK.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/slSL.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/svSE.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/i18n/ukUA.ts Add reconciled/lock UI strings (EN fallback).
eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-tour.service.ts Update help tour start to accept multi-axis audience filtering.
eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-search.service.ts Update help search API to accept multi-axis audience filtering.
eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.ts Add first-user axis to help-entry visibility filtering.
eform-client/src/app/plugins/modules/time-planning-pn/help/services/help-content.service.spec.ts Add tests for first-user-only help entry visibility + update coverage loop.
eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.ts Add reconcile/lock help entries and mark reconcile-through as first-user-only.
eform-client/src/app/plugins/modules/time-planning-pn/help/planning-help.registry.spec.ts Pin first-user-only registry expectations.
eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/enUS.ts Add help prose for reconcile-through + locked/reconciled day cells.
eform-client/src/app/plugins/modules/time-planning-pn/help/i18n/da.ts Add help prose for reconcile-through + locked/reconciled day cells (DA).
eform-client/src/app/plugins/modules/time-planning-pn/help/help.model.ts Add first-user axis to help entry/audience model and IDs.
eform-client/src/app/plugins/modules/time-planning-pn/help/help-wiring.spec.ts Expand template key list and hint ID assertions for new lock UX.
eform-client/src/app/plugins/modules/time-planning-pn/help/components/help-panel/help-panel.component.ts Propagate audience axes through list/search/links; keep behavior consistent.
eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-plannings-container/time-plannings-container.component.html Add bulk reconcile through UI + scope bar; pass isFirstUser to help panel; wire events to table.
eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html Add locked-day read-only footer/provenance + reconcile/unlock flows and help hints.
eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-unlock-word.spec.ts E2E coverage for newest-only unlock + typed word confirmation.
eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-glyphs.spec.ts E2E coverage for seal/lock glyphs, tooltips, and legend behavior.
eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-dialog-confirm.spec.ts E2E coverage for two-step reconcile confirm and dialog staying open read-only.
eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-day-lock.spec.ts E2E coverage for reconcile→cascade lock→read-only open→boundary unlock flow.
eform-client/playwright/e2e/plugins/time-planning-pn/s/reconcile-bulk-preview.spec.ts E2E coverage for bulk reconcile preview/commit, selection scoping, and reload cancel.
eform-client/playwright/e2e/plugins/time-planning-pn/s/assert-true.spec.ts Shard s bootstrap/canary test.
eform-client/playwright/e2e/plugins/time-planning-pn/s/activate-plugin.spec.ts Shard s plugin-activation bootstrap.
docs/superpowers/plans/2026-09-13-HANDOFF-reconciled-day-lock.md Add handoff/entry-point doc for the reconciled day lock workstream.
.github/workflows/dotnet-core-pr.yml Add shard s and include new lock test fixtures in CI filters.
.github/workflows/dotnet-core-master.yml Add shard s and include new lock test fixtures in CI filters.
Review details

Files not reviewed (1)

  • eFormAPI/Plugins/TimePlanning.Pn/TimePlanning.Pn/Resources/Translations.Designer.cs: Generated file

Suppressed comments (1)

eform-client/src/app/plugins/modules/time-planning-pn/components/plannings/time-planning-actions/workday-entity/workday-entity-dialog.component.html:587

  • The Save button sits inside a but doesn’t declare type="button", so it defaults to type="submit". This can trigger implicit submit behavior (especially on Enter in inputs) and conflict with the dialog’s other buttons; set type="button".
  • Files reviewed: 99/101 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

themselves as well, because patchValue and setValue write straight
through a disabled control.
-->
<button mat-icon-button color="warn" data-tp-help="dayCell.resetField" [disabled]="isLocked" (click)="resetPlannedTimes((+shiftId - 1) * 3 + 1)">
await new PluginPage(page).Navbar.goToPluginsPage();
});

test('should enabled Time registration plugin', async ({ page }) => {
Comment on lines 291 to +293
related(id: HelpEntryId): HelpEntryId[] {
return (this.helpContent.entry(id)?.related ?? [])
.filter(relatedId => {
const entry = this.helpContent.entry(relatedId);
return !!entry && (!entry.adminOnly || this.isAdmin);
});
const listed = new Set(this.helpContent.entries(this.audience).map(entry => entry.id));
return (this.helpContent.entry(id)?.related ?? []).filter(relatedId => listed.has(relatedId));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants