Skip to content

MPDX-9904 - Include 403b values and special needs left in NS Goal admin preview - #1989

Merged
zweatshirt merged 5 commits into
mainfrom
MPDX-9904
Aug 18, 2026
Merged

MPDX-9904 - Include 403b values and special needs left in NS Goal admin preview#1989
zweatshirt merged 5 commits into
mainfrom
MPDX-9904

Conversation

@zweatshirt

@zweatshirt zweatshirt commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

Testing

  • Go to /accountLists/[id]/coaching/[id]/nsGoalCalculator
  • Test that the fields update with the preview

Checklist:

  • I have given my PR a title with the format "MPDX-(JIRA#) (summary sentence max 80 chars)"
  • I have applied the appropriate labels (Add the label "Preview" to automatically create a preview environment)
  • I have run the Claude Code /quality:agent-review command locally and fixed any relevant suggestions
  • I have requested a review from another person on the project
  • I have tested my changes in preview or in staging
  • I have cleaned up my commit history

@github-actions

Copy link
Copy Markdown
Contributor

Bundle sizes [mpdx-react]

Compared against 5f0edc3

No significant changes found

@zweatshirt
zweatshirt marked this pull request as ready for review August 17, 2026 21:50
@zweatshirt zweatshirt self-assigned this Aug 17, 2026
@zweatshirt zweatshirt added the Preview Environment Add this label to create an Amplify Preview label Aug 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Preview branch generated at https://MPDX-9904.d3dytjb8adxkk5.amplifyapp.com

@zweatshirt zweatshirt left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Multi-Agent Code Review

Verdict: APPROVED WITH SUGGESTIONS — 0 critical, 0 high, 1 Important (7.5), ~14 Medium/Suggestion after dedup.

6 specialized agents (Architecture, Testing, Standards, Data Integrity, UX, Financial Reporting). Security agent not launched — no trigger matched (no pages/api/**, auth, Apollo link/client, env, or workflow changes).

Risk: 6/10 MEDIUM (reviewer override, recorded). The pattern-derived pre-review score was 8/10 HIGH; that score is computed from file globs and line count before any code is read, and it misdescribes this change. The +2 High tier on src/components/**/*.graphql exists for cache-normalization and pagination risk, which three additive scalar fields under fetchPolicy: 'no-cache' do not carry (verified: the preview payload's wire __typename is NewStaffGoalCalculationPreview, no typePolicy exists for either type, and NewStaffGoalCalculationCalculations has no id so it cannot be normalized independently). NsGoalCalculatorTestWrapper.tsx also scored as a feature file because the Low-Risk glob is **/*.test.{ts,tsx} and a test wrapper does not match it. Follow-up owed: amend .claude/rules/code-review.md so this is fixed for future PRs rather than overridden per-PR.

Verified clean

yarn lint:ts exit 0 · yarn eslint (no --fix) exit 0 · yarn prettier --check clean (3.6.2) · codegen freshness proven from generated content (*.generated.ts is gitignored) · all three new fields confirmed Float, null: false in calculations_type.rb:17,22,87 and inherited by NewStaffGoalCalculationPreviewType · previewNewStaffGoalCalculation present in rootFields.generated.ts:132 (primary API, no REST-proxy mixing) · Standards Checklist: no violations.

The feature itself is implemented the right way for this tree: no client-side goal math was added (src/components/HrTools/CLAUDE.md forbids it for NsGoalCalculator), the three figures are server-computed and pass through formatCurrency untouched, and the wider selection set reuses the existing single preview round-trip rather than adding a request.

Important (1)

useMpdGoalPreview.ts:170 — severity 7.5 — the dirty gate diverges from settledPreview. Flagged independently by 5 of 6 agents. See the inline comment.

Also worth addressing

  • Test 1 cannot fail for anything this PR changes — sectionProps.calculations and the provider's calculation.calculations are the same object reference. Inline comment on the test.
  • No in-flight/stale affordance on the three figures; the only preview indicator lives in a non-sticky header that scrolls out of view. Inline comments on both sections.
  • previewMock cannot express a throwing mock, so the .catch path added by this PR is untestable through the wrapper. Inline comment.
  • Assertions are not row-anchored (GoalSettingsPreviewContext.test.tsx, tests 2 and 4): findByText('$175.00') / ('$210.00') only prove both strings exist somewhere, so swapping the primary and spouse 403(b) rows still passes. Use the working idiom from GoalSettingsForm.test.tsx:158-165(await findByText('403(b) Amount — John')).parentElement).toHaveTextContent('$175.00').
  • A previewed 0 is untested, so swapping any of the three ?? in GoalSettingsPreviewContext.tsx to || would silently fall back to the saved figure with a green suite. specialNeedsLeft: 0 ("fully raised") is a real domain state.
  • Client/server validation parity: Yup percentage() is .max(100) (inclusive) while Rails validates less_than: 100 (new_staff_goal_calculation.rb:79, new_staff_validations.rb:25; the guard exists because a 100% election grosses up by 1/(1-1) = ∞). Entering exactly 100 therefore passes the client, fires a preview, and hard-fails server validation — which silently reverts all three money figures to their saved values. Consider .lessThan(100).
  • Ergonomock RNG coupling: the tests type '10', '12', '50' into fields whose initial values are auto-generated from [1, 100). If a future selection-set change shifts the draw order and the initial percentage lands on exactly 10, dirty never flips and the tests fail on an opaque timeout. Pin contribution403bPercentage and nsoSpecialNeedsSupportReceived in savedCalculation.
  • useMpdGoalPreview.ts:139 deletes the only "why" comment on the .catch while growing that block — it explained why a failure writes state rather than clearing it (recording the failed attributes is what stops the spinner and drives the saved-value fallback). Worth restoring one line.

Ruled out (checked, not defects)

Apollo cache poisoning via the partial calculations selection; null as a legitimate server value colliding with the failure sentinel (all three fields are non-null); preview computed from persisted rather than edited attributes (assign_attributes runs in memory before valid?); stale figures after save (the update mutation re-selects the full fragment, so the cache write and enableReinitialize land together); and figures surviving Cancel (resetForm clears dirty, which nulls the line items in the same render).


Findings on Related Files (Not in This PR)

src/components/HrTools/NsGoalCalculator/GoalSettings/goalSettingsSectionProps.ts:10-14 — severity 5.0 — flagged by two agents. The JSDoc on the calculations prop still reads "Computed worksheet amounts for the saved calculation, used for read-only derived displays (e.g. the 403(b) contribution amount)" — but after this PR both sections read those displays from previewCalculations and have zero remaining calculations. dereferences. The file isn't in this diff, but this diff is what made the sentence wrong, so the one-line reword belongs here. A reader trusting it will wire the next derived display straight to the prop and silently bypass the preview.

const failed = settledPreview !== null && settledPreview.monthlyGoal === null;

const previewGoal = settledPreview?.monthlyGoal ?? null;
const previewLineItems = dirty ? (preview?.previewLineItems ?? null) : null;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Important] This gates on `dirty`, but `previewGoal` one line up gates on `settledPreview` (an attributes-identity match). The two disagree whenever the form is dirty *and invalid*, and that state has no on-screen indicator.

Repro in three keystrokes: set 403(b) Contribution to 10, let the preview settle (403(b) Amount shows the previewed value), then type another digit → 101. GoalSettingsNumberField sets only min: 0 with no max, so 101 is freely typeable; Yup's percentage().max(100) then fails, so previewablenull. Now settledPreviewnull, calculatingfalse, failedfalse, previewGoalnull, and the header renders the saved goal with no spinner, no diff, and no "Preview unavailable" — visually identical to a clean form. Meanwhile dirty is still true, so the 403(b) Amount and Left to Raise rows keep serving figures computed from the superseded 10%.

The repo already asserts the opposite contract for the goal: MpdGoalPreview.test.tsx:363-381, "drops the preview when a previewed edit is then made invalid."

One-line fix that preserves the documented anti-flicker hold (previewable stays non-null throughout a valid in-flight edit):

const previewLineItems =
  previewable !== null ? (preview?.previewLineItems ?? null) : null;

Lines 171-172 carry the same dirty gate but predate this PR and are defensible for warning booleans, where holding avoids re-announcing. A displayed dollar figure isn't a warning.

goalCalculationMock?:
| DeepPartial<NewStaffGoalCalculationQuery>
| ApolloErgonoMockMap;
previewMock?: DeepPartial<PreviewNewStaffGoalCalculationMutation>;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Medium] This prop is narrower than its sibling three lines up (`goalCalculationMock?: DeepPartial<…> | ApolloErgonoMockMap`), and the difference matters: a throwing mock is a *field resolver function*, and the vendored types put that leaf only on the ergono shape — `ErgonoMockLeaf = string | boolean | number | null | Error | GraphQLFieldResolver` (`dist/mock.d.ts:1`). `DeepPartial` admits no function leaves, so the pattern used at `MpdGoalPreview.test.tsx:323-331` is not expressible here.

Consequence: the .catch branch this PR adds to useMpdGoalPreview.ts:139-153 — all three line items → null → provider falls back to saved figures — cannot be tested through this wrapper. That's the path where three money figures change meaning, and per the .max(100) vs less_than: 100 parity gap noted in the review body it is reachable by ordinary input, not just by a server outage.

Widen to match the sibling:

previewMock?: DeepPartial<PreviewNewStaffGoalCalculationMutation> | ApolloErgonoMockMap;

A cheaper partial is available today without changing this type: previewNewStaffGoalCalculation is Maybe<>, so previewMock={{ previewNewStaffGoalCalculation: null }} reaches the same all-null state via .then. That covers the behaviour but leaves the literal .catch block uncovered.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AI Review Auto-Approval

Risk Level: MEDIUM (6/10)
Verdict: APPROVED_WITH_SUGGESTIONS (suggestions posted, no blockers)

This PR was auto-approved because:

  • The multi-agent AI review determined it is medium risk
  • No blocking issues were found
  • All suggestions have been posted as review comments for the developer to consider

If you believe this PR needs human review, dismiss this approval and request a review manually.

previewGoalMock must spell the new fields out: its return type is the concrete mutation type, not a partial.
Narrowed to the three preview-aware fields; every other calculations field would still be the saved value.
Dimmed and aria-busy while in flight: the figures still hold the previous edit's values, and the header's spinner is off-screen by these rows.
The server grosses up by 1/(1-rate) and validates less_than: 100. Scoped to these two fields; 100 is valid for percentages used as plain multipliers.
Section props deliberately differ from the provider, so each assertion proves the figure came from the context rather than the prop.
@zweatshirt
zweatshirt enabled auto-merge August 18, 2026 14:15
@zweatshirt
zweatshirt merged commit 8b6c94e into main Aug 18, 2026
20 of 21 checks passed
@zweatshirt
zweatshirt deleted the MPDX-9904 branch August 18, 2026 14:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Preview Environment Add this label to create an Amplify Preview

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant