Skip to content

Implementation Plan: PDEV-610 Cross-User Staleness Signal

Implementation Plan: PDEV-610 Cross-User Staleness Signal

Section titled “Implementation Plan: PDEV-610 Cross-User Staleness Signal”

This plan executes the architecture in design.md for PDEV-610. Scope is PDEV-610 only; PDEV-613 is tracked separately. The work is frontend-only — the operations repository is not touched.

Execution model: single agent walking phases serially.

PR strategy: one PR for everything from the integration branch jmpicnic-ag/acceptance-test-failures-pdev-610 in arda-frontend-app. Post-completion documentation lands in a separate PR.

Rollout: default-on. Dev deploy + verification before merge to main triggers prod release. NEXT_PUBLIC_ITEM_CARDS_POLL_MS is the operational kill switch (set to a huge value to effectively disable pollTimer).

File-size guideline: every file touched is kept under 600 lines; mechanical splits are applied when a touched file exceeds that, with no behavioural redesign. The current ItemCardsContext.tsx (~730 lines plus the new wiring) is the only file that requires planned splitting — see § File-split plan.

Topologically ordered to keep main green at every phase boundary. Earlier phases produce the dependencies later phases consume.

PhaseThemeDepends on
1Bus foundation (itemStaleBus, test polyfill, bus tests)
2Provider integration (ItemCardsProvider, registry, pollTimer)1
3Test-utility extensions (render-with-providers, visibility helpers, two-provider harness, stateful MSW handler)1, 2
4Producer wiring (mutations, card-state events, RefreshButton, useRefreshOnFocus)2, 3
5Integration and robustness tests3, 4
6E2E (staleness-mock fixture + Playwright specs)5
7Pre-PR validation6
8Deploy: dev → verify → prod7

Build the itemStaleBus module in isolation and a deterministic BroadcastChannel test polyfill so the next phase’s tests can rely on it.

#FileActionConstructBehavior coverage
1.1src/app/items/itemStaleBus.tscreateItemStaleBus type, createItemStaleBus, markItemStale, dispose, capability fallbackBV-1-01 … BV-1-06
1.2src/test-utils/broadcastChannelTestEnv.tscreateinstallBroadcastChannelTestEnv() + teardown helper(used by tests below)
1.3src/app/items/__tests__/itemStaleBus.test.tscreateunit testsBV-1-01 … BV-1-06
  • npx jest src/app/items/__tests__/itemStaleBus.test.ts --no-coverage --watchAll=false green.
  • npx tsc --noEmit clean for the new files.

Wire the bus into ItemCardsProvider, add mountedEids and channelSubscriber, install visibilityListener and pollTimer. Adjust useFreshRead and useStaleCheck to register / unregister. Apply the file-split plan below to keep ItemCardsContext.tsx under 600 lines.

#FileActionConstructBehavior coverage
2.1src/app/items/ItemCardsContext.tsxmodify + splitApply File-split plan before touching behaviour.(enables 2.2 – 2.6)
2.2src/app/items/ItemCardsContext.tsxmodifyConstruct itemStaleBus on provider mount, dispose on unmount.BV-4-04
2.3src/app/items/ItemCardsContext.tsxmodifymountedEids: Set<string> + register(eid) / unregister(eid) callbacks on internal context.BV-3-01 … BV-3-03
2.4src/app/items/ItemCardsContext.tsxmodifychannelSubscriber effect → enqueueStaleRefresh(eids).BV-4-01
2.5src/app/items/ItemCardsContext.tsxmodifypollTimer effect (interval default 120 s, env-tunable, gated on visibility + non-empty registry).BV-2-06, BV-5-01, BV-5-02, BV-5-04
2.6src/app/items/ItemCardsContext.tsxmodifyExpose markItemStale on public context value (delegates to owned bus).BV-4-02, BV-4-04
2.7src/app/items/useFreshRead.ts (post-split)modifyCall register(eid) / unregister(eid) from the effect. Freshness logic unchanged.BV-3-01, BV-3-02
2.8src/app/items/useStaleCheck.ts (post-split)modifySame registration discipline as useFreshRead.BV-3-01
2.9src/app/items/ItemCardsContext.test.tsxmodify + extendNew cases for the new context surface; ensure existing freshness / staleCheck cases stay green.BV-3-, BV-4-, BV-5-02, BV-5-04
  • All unit tests under src/app/items/__tests__/ and src/app/items/ItemCardsContext.*.test.tsx green.
  • npx tsc --noEmit clean.
  • No file under src/app/items/ exceeds 600 lines.

Stand up the helpers Phases 4 – 6 consume.

#FileActionConstructBehavior coverage
3.1src/test-utils/render-with-providers.tsxmodifyExtend RenderWithProvidersOptions with itemCardsPollIntervalMs?: number and itemStaleBusConsumer?: (eids: string[]) => void. Thread both into the ItemCardsProvider instance created by every helper.(enables most tests)
3.2src/test-utils/visibilityTestEvents.tscreatesetVisibility(state), dispatchVisibilityChange(), dispatchWindowFocus().(enables BV-2-05, BV-5-*)
3.3src/test-utils/itemCardsTwoProviderHarness.tsxcreaterenderTwoProviderHarness(...) mounts two ItemCardsProvider instances sharing one polyfilled BroadcastChannel.(enables BV-6-01)
3.4src/mocks/handlers/kanban-queries.tsmodifyNew exported stateful handler factory: returns rId set A on first call for a fixture eid, set B on subsequent calls.(enables BV-6-02, BV-6-03, BV-7-01, BV-6-04, BV-6-05)
3.5(no file)Pick concrete fixture data: FIXTURE_ITEM_EID = "pdev610-fixture-eid", RID_SET_A = ["card-S0-1"], RID_SET_B = ["card-S1-1"].
  • Building blocks compile and have at least smoke unit coverage where applicable (e.g., renderTwoProviderHarness renders two distinct providers; stateful MSW handler returns A then B on consecutive reads).
  • npx tsc --noEmit clean.

Add markItemStale calls at every real producer site identified in the design’s DQ-005Item mutations, Card-state events (11 surfaces), RefreshButton, and useRefreshOnFocus. The frozen surface count is 13 producer sites + the RefreshButton host; Task 4.14 is the re-grep checkpoint that confirms no surface was missed during implementation.

#FileActionConstructBehavior coverage
4.1src/app/items/hooks/useRefreshOnFocus.tsmodifyReplace direct refreshCardsForItems(eids) call with markItemStale(eids); eid set unchanged (displayed-grid-items ∪ open-panel-item).BV-2-05, BV-5-03
4.2src/app/items/hooks/useRefreshOnFocus.test.tsxmodifyUpdate assertions from refreshCardsForItems to markItemStale.BV-2-05, BV-5-03
4.3src/components/items/ItemFormPanel.tsxmodifyIn post-createItem / updateItem success path, call markItemStale(resultItem.entityId) before invoking the caller’s onSuccess. Apply file split if file grows past 600 lines.BV-2-01
4.4src/app/items/hooks/useDeleteItems.tsmodifyIn success branch, call markItemStale(deletedEntityIds) after the existing toast / dismiss logic.BV-2-02
4.5src/components/common/CardStateDropdown.tsxmodifyOn successful card-state event, call markItemStale(itemEntityId).BV-2-03 (1 of 11)
4.6src/components/table/columnPresets.tsxmodifyOn successful card-state event from grid quick actions, call markItemStale(itemEntityId). Apply file split if file grows past 600 lines.BV-2-03 (2 of 11)
4.7src/components/scan/ScanModal.tsxmodifyOn event success, call markItemStale(itemEntityId).BV-2-03 (3 of 11)
4.8src/components/scan/CardPreviewModal.tsxmodifyOn event success, call markItemStale(itemEntityId).BV-2-03 (4 of 11)
4.9src/components/scan/MobileScanView.tsxmodifyOn event success, call markItemStale(itemEntityId).BV-2-03 (5 of 11)
4.10src/components/scan/DesktopScanView.tsxmodifyOn event success, call markItemStale(itemEntityId).BV-2-03 (6 of 11)
4.11src/app/order-queue/page.tsxmodifyOn event success from order-queue actions, call markItemStale(itemEntityId).BV-2-03 (7 of 11)
4.12src/app/kanban/cards/[cardId]/page.tsxmodifyOn event success from the direct kanban-card page, call markItemStale(itemEntityId).BV-2-03 (8 of 11)
4.13src/components/orders/OrderSidebar.tsxmodifyOn event success, call markItemStale(itemEntityId).BV-2-03 (9 of 11)
4.14(verify after 4.5 – 4.13)Re-grep producer set; capture the remaining two card-state surfaces if any are uncovered during implementation. Update this row with file paths.BV-2-03 (10 – 11 of 11)
4.15src/components/items/ItemDetailsPanel.tsxmodifyAdd RefreshButton to the existing <StaleDataBanner> block (around line 926). On click, call markItemStale(item.eid). Apply file split if file grows past 600 lines.BV-2-04
4.16src/app/items/hooks/useDeleteItems.test.tsxmodifyAdd success-path assertion that markItemStale(deletedEntityIds) is called.BV-2-02
4.17each producer file’s existing testmodifyOne additional case per file asserting the new markItemStale call. Some files will need a new test file alongside.BV-2-01, BV-2-03, BV-2-04
  • All BV-2-* unit tests green.
  • npx jest --no-coverage --watchAll=false green for the touched files plus their reverse dependencies.
  • npx tsc --noEmit clean.
  • No touched file exceeds 600 lines.

Phase 5 — Integration and robustness tests

Section titled “Phase 5 — Integration and robustness tests”

Add the BV-6-01 / BV-6-02 / BV-6-03 integration tests and BV-7-01 … BV-7-04 robustness / coexistence tests.

Test-naming convention. Every new BV-* test (Phases 1, 2, 5, 6, plus the producer tests under Phase 4) names its it(...) or test(...) description with the BV-<group>-<seq> prefix — e.g., it("BV-2-01: ItemFormPanel save calls markItemStale(entityId) on success", ...). This lets npx jest -t "BV-" enumerate the full PDEV-610 suite for the exit-criteria check.

#FileActionConstructBehavior coverage
5.1src/components/items/ItemDetailsPanel.crossTab.test.tsxcreateTwo-provider harness; mutate via the first; assert sibling’s banner appears.BV-6-01
5.2src/components/items/ItemDetailsPanel.polling.test.tsxcreateStateful MSW handler + short poll override + fake timers; assert banner appears within one poll cycle without user action.BV-6-02
5.3src/components/items/ItemDetailsPanel.writerFeedback.test.tsxcreateSingle provider; trigger a mutation whose follow-up refresh returns a different rId set; assert banner appears.BV-6-03
5.4src/components/items/ItemDetailsPanel.refreshFailure.test.tsxcreateMock refreshCardsForItems to resolve to the fetch-failed sentinel; assert isStale stays false.BV-7-01
5.5src/app/items/ItemCardsContext.cycleSafety.test.tsxcreateSpy markItemStale; deliver an inbound channel message; assert no further markItemStale is invoked after the resulting refresh resolves.BV-7-02
5.6src/app/items/hooks/useBulkSelectionStaleGuard.test.tsxmodifyAdd one case where markItemStale is invoked concurrently; assert the guard’s existing behaviour is preserved.BV-7-03
5.7src/components/items/ItemDetailsPanel.windowEventCoexistence.test.tsxcreateDispatch refreshItemCards window event AND call markItemStale in one tick; spy refreshCardsForItems; assert exactly one batched call.BV-7-04
  • All BV-6-01 … BV-6-03 and BV-7-* tests green.
  • npx jest --no-coverage --watchAll=false runs end-to-end with no failures.
  • Coverage thresholds in jest.config.js not regressed against main.

Add the Playwright fixture and the two E2E specs that exercise the cross-browser polling path and the RefreshButton end-to-end.

#FileActionConstructBehavior coverage
6.1e2e/fixtures/staleness-mock.tscreatePlaywright fixture extending e2e/fixtures/base.ts; registers the stateful MSW handler before navigation; overrides NEXT_PUBLIC_ITEM_CARDS_POLL_MS to a short test value.(enables 6.2, 6.3)
6.2e2e/specs/pdev-610.polling.spec.tscreateUse staleness-mock; navigate to ItemDetailsPanel for the fixture eid; assert banner appears within the test’s poll window without user action.BV-6-04
6.3e2e/specs/pdev-610.refresh-button.spec.tscreateUse staleness-mock; navigate; wait for banner; click RefreshButton; assert banner clears (useFreshRead.refresh() re-snapshots and adopts the new server state).BV-6-05
  • make test-e2e-safari (or the equivalent npx playwright test invocation in mock mode) green for the new specs.
  • Existing Playwright suite green.

Local equivalent of the CI gate.

#ActionWhy
7.1make ci-replicate clean from the worktree rootFull CI parity (T1 fast gate + T2 queue gate + T3 quality gate). Catches anything the per-phase gates missed.
7.2npx jest --no-coverage --watchAll=false rerunBelt-and-braces; surface flakes early.
7.3npx tsc --noEmitType checker clean.
7.4npm run lintESLint clean.
7.5Confirm coverage thresholds in jest.config.js are not regressed against origin/mainRequired by the repo’s coverage gate.
7.6Open PR from jmpicnic-ag/acceptance-test-failures-pdev-610 to mainPR body includes ## CHANGELOG section (PR-body changelog convention), ## Closes referencing PDEV-610, the Authored-by attribution block (see the frontend PR process authoring guidance), and links to design.
  • PR is green on CI; reviewer-ready.
#Action
8.1Merge to main is not done in this phase; instead, deploy the PR branch to dev.
8.2Manual verification on dev — repro the PDEV-610 scenario across two browsers; observe banner appearing in the second browser within ≤ 120 s.
8.3If dev verification is green, merge to main. Post-merge changelog assembly + tag are automatic (PR-body changelog model).
8.4Prod deploy follows the standard cadence; no special handling required (the change is default-on; kill switch is NEXT_PUBLIC_ITEM_CARDS_POLL_MS).
  • Dev observation matches the design’s BV-6-04 expectation.
  • Prod release tag created by the assembly workflow.
  • PDEV-610 closed in Linear (post-completion documentation lands in a follow-up PR per user direction).

The 600-line ceiling forces one preplanned split. Apply the split before behaviour changes so the test suite migration is mechanical and reviewable in isolation.

src/app/items/ItemCardsContext.tsx (~730 lines today; target: < 600 after additions)

Section titled “src/app/items/ItemCardsContext.tsx (~730 lines today; target: < 600 after additions)”

Mechanical extraction — no behaviour changes in the split commit:

New fileConstruct(s) extractedRe-export contract
src/app/items/useFreshRead.tsuseFreshRead, FreshReadResult, FreshReadOpts, the rIdSet and setsEqual helpers it usesItemCardsContext.tsx re-exports useFreshRead, FreshReadResult, FreshReadOpts for backwards-compatible import paths.
src/app/items/useStaleCheck.tsuseStaleCheckItemCardsContext.tsx re-exports useStaleCheck for backwards-compatible import paths.

After the split, ItemCardsContext.tsx retains: the ItemCardsEntry / ItemCardsContextType / ItemCardsInternalContextType types, the ITEM_CARDS_TTL_MS constant, the ItemCardsProvider component, the useItemCards / useItemCardsInternal hooks. Estimate post-split: ~430 lines, leaving headroom for the new wiring (estimated +120 lines) under the ceiling.

If any other file modified during Phase 4 grows past 600 lines (the most likely candidates are ItemFormPanel.tsx, columnPresets.tsx, ItemDetailsPanel.tsx), apply the same approach: extract a colocated *.helpers.ts or per-section sub-component. Document each ad-hoc split inline in the PR description.

RiskLikelihoodImpactMitigation
useRefreshOnFocus refactor breaks the existing useRefreshOnFocus.test.tsx.HighLowRefactor + test update in the same commit (Task 4.1 + 4.2). Pre-flight: read every assertion in the existing test before the refactor and migrate each.
useBulkSelectionStaleGuard and the new bus race when both fire in the same tick (e.g., guard preflight just before a sibling tab’s mutation arrives via channel).MediumMediumBV-7-03 covers the concurrent-traffic case. If a flake appears, fall back to scheduling markItemStale on the next microtask so the existing preflight completes deterministically.
BroadcastChannel polyfill behaviour differs from production browsers.MediumLowPolyfill exercises only the postMessageonmessage synchronous path. Production behaviour adds asynchrony only, never different semantics. Confirmed by the E2E specs running against a real browser.
Producer set has more than 11 card-state event surfaces (recon may have missed some).MediumLowTask 4.14 is a re-grep checkpoint at the end of Phase 4. Add any missed surfaces with the same one-row pattern.
ItemCardsContext.tsx split changes some import paths used by tests.LowLowRe-export from the original module preserves the public import surface. The split commit only moves code.
Coverage threshold regression on the new files.LowLowEach new file ships with focused unit tests; Phase 5 / 6 add integration coverage. Task 7.5 verifies before PR.
Polling traffic spike at default 120 s makes the BFF unhappy in dev.LowLowSMB customers, low concurrency. If a spike appears, raise NEXT_PUBLIC_ITEM_CARDS_POLL_MS to 300 s as the immediate response and reassess.
refreshItemCards window event and the bus produce a double-refresh.LowLowBV-7-04 asserts coalescing. If it ever drifts, the cleanup task (remove the window event listener) is a clear follow-up.
600-line ceiling forces an ad-hoc split during Phase 4 (e.g., ItemDetailsPanel.tsx already large).MediumLowPlan section File-split plan describes the approach; PR description must call out any ad-hoc split.
Inbound channel message arrives before any useFreshRead is mounted on the destination eid.LowNonechannelSubscriber enqueues a refresh; enqueueStaleRefresh refreshes the cache; no isStale flips because no subscriber is mounted. When the panel later mounts, useFreshRead snapshots a cache that is already current — banner stays hidden, which is correct. No code fix needed; documenting the behaviour.
Several Phase 4 files (ItemFormPanel.tsx 2469 lines, columnPresets.tsx 1740, ItemDetailsPanel.tsx 1321, ScanModal.tsx 1012, CardPreviewModal.tsx 637, MobileScanView.tsx 1834, DesktopScanView.tsx 2476, order-queue/page.tsx 2645) are already over the 600-line ceiling before any change.HighHighSee dedicated § Pre-existing file-size violations below. Default treatment is documented there.

Verified line counts as of the implementation-plan date:

FileLinesOver by
src/app/order-queue/page.tsx26454.4×
src/components/scan/DesktopScanView.tsx24764.1×
src/components/items/ItemFormPanel.tsx24694.1×
src/components/scan/MobileScanView.tsx18343.1×
src/components/table/columnPresets.tsx17402.9×
src/components/items/ItemDetailsPanel.tsx13212.2×
src/components/scan/ScanModal.tsx10121.7×
src/app/items/ItemCardsContext.tsx7331.2×
src/components/scan/CardPreviewModal.tsx6371.06×

The user constraint is: “whenever possible keep any file you touch to under 600 lines, even if it means mechanical splitting of pre-existing file contents, but don’t engage in re-design for this goal.”

For ItemCardsContext.tsx (the File-split plan target), a clean mechanical split exists: useFreshRead, useStaleCheck, and their colocated helpers come out into sibling modules with re-exports for backwards compatibility. No redesign required.

For the other eight files, no purely mechanical split is feasible:

  • ItemFormPanel.tsx, ItemDetailsPanel.tsx, DesktopScanView.tsx, MobileScanView.tsx, ScanModal.tsx, CardPreviewModal.tsx — each is a single large React component with deeply intermingled state, effects, handlers, and JSX. Splitting requires deciding cohesion boundaries (sub-components, hook extraction), which is design work.
  • columnPresets.tsx — a single module exporting many ag-grid column-renderer constants that share helpers. Mechanical extraction by column family is possible but each family carries different dependencies; “no redesign” is unclear.
  • order-queue/page.tsx — large page component with inline data fetching, state, JSX. Same shape as the form/scan files.

Resolution (per user direction): split where mechanical, leave where design-required. Concretely:

  • Will split mechanically: ItemCardsContext.tsx — extract useFreshRead and useStaleCheck per File-split plan.
  • Marginal — assessed during execution: CardPreviewModal.tsx (only 37 lines over). If a colocated helper / local hook block of 50+ lines can be extracted without cohesion judgment, do it; otherwise leave with the touch-and-go treatment below.
  • Touch-and-go (no split): ItemFormPanel.tsx, ItemDetailsPanel.tsx, ScanModal.tsx, MobileScanView.tsx, DesktopScanView.tsx, order-queue/page.tsx, columnPresets.tsx. Each is large in ways that require cohesion judgment (single React components with intermingled state / effects / JSX, or a 600-line single function inside columnPresets). Mechanical-only splits are infeasible. The PR description will inventory these pre-existing violations and call them out as follow-up cleanup work.

T-shirt per phase, assuming agentic execution with periodic human review.

PhaseSize
1S
2M
3S
4M
5M
6S
7XS
8XS (human-driven verification)

PDEV-610 is closed when all of the following hold:

  1. All 29 BV-* tests across the seven Behavioral Design groups are green in CI: BV-1-01 … BV-1-06, BV-2-01 … BV-2-06 (BV-2-03 includes 11 concrete surface tests), BV-3-01 … BV-3-03, BV-4-01 … BV-4-04, BV-5-01 … BV-5-04, BV-6-01 … BV-6-05, BV-7-01 … BV-7-04.
  2. No regressions on any pre-existing test suite in arda-frontend-app versus main.
  3. Coverage thresholds in jest.config.js are not regressed.
  4. Dev deploy is verified (Task 8.2) and prod deploy succeeds.
  5. PDEV-610 is moved to Done in Linear; PR is merged.
  • Any change to the operations repository (per DQ-010). The operations-pdev-610 worktree is removed as part of this plan’s prep.
  • ETag / conditional GET (per DQ-004).
  • Server-sent events, WebSocket, pub-sub fabric (per DQ-001 rejected alternatives).
  • Reworking useBulkSelectionStaleGuard or the page-level bulk-selection banner.
  • Removing the existing refreshItemCards window-event listener (left as follow-up).
  • Post-completion documentation (separate PR per user direction).
  • Resolution of PDEV-613 (tracked separately).

Copyright: (c) Arda Systems 2025-2026, All rights reserved