src/features/reviews/computation-grid.helpers.ts
Linked repository file from this build.
import { MAX_MONEY_CENTS } from "@/programs/snap-negative/input-limits";
import { calculate, selectFinal } from "@/programs/snap-negative/calculation";
import { resolveReviewPolicy } from "@/programs/snap-negative/policy";
import type {
ComputationResult,
IncomeStream,
Scenario,
SnapReview,
} from "@/programs/snap-negative/types";
export type EditableScenarioId = "eligibility" | "compI" | "compII";
export type ColumnId = EditableScenarioId | "final" | `split-${number}`;
export type DraftMap = Partial<Record<EditableScenarioId, Scenario>>;
export const EDITABLE_SCENARIO_IDS: readonly EditableScenarioId[] = [
"eligibility",
"compI",
"compII",
] as const;
export function parseDollarsToCents(raw: string): number | null {
const trimmed = raw.trim();
if (!trimmed) return null;
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return null;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < 0) return null;
const cents = Math.round(parsed * 100);
if (!Number.isSafeInteger(cents) || cents > MAX_MONEY_CENTS) return null;
return cents;
}
/** Fixed two-decimal display for saved cents; avoids string-format comparison drift. */
export function formatDecimal2FromCents(cents: number): string {
return (cents / 100).toFixed(2);
}
/** ATM keypad: ignore punctuation and treat remaining digits as cents. */
export function centsFromAtmDigits(raw: string): number | null {
const digits = raw.replace(/\D/g, "");
if (!digits) return 0;
if (digits.length > String(MAX_MONEY_CENTS).length) return null;
const cents = Number(digits);
if (!Number.isSafeInteger(cents) || cents > MAX_MONEY_CENTS) return null;
return cents;
}
/** Empty at $0 so the 0.00 placeholder shows until the first digit. */
export function atmMoneyDisplay(cents: number): string {
return cents === 0 ? "" : formatDecimal2FromCents(cents);
}
/** Shift the amount left and add a cents digit. Null when the result would exceed the cap. */
export function appendAtmDigit(cents: number, digit: number): number | null {
if (!Number.isInteger(digit) || digit < 0 || digit > 9) return null;
const next = cents * 10 + digit;
if (!Number.isSafeInteger(next) || next > MAX_MONEY_CENTS) return null;
return next;
}
export function backspaceAtmCents(cents: number): number {
return Math.floor(Math.max(0, cents) / 10);
}
export function centsDiffer(a: number, b: number): boolean {
return a !== b;
}
/** Textual hint when stored cents differ from the Eligibility baseline. */
export function formatCentsDiffNote(baselineCents: number, valueCents: number): string | null {
if (!centsDiffer(baselineCents, valueCents)) return null;
const delta = valueCents - baselineCents;
const sign = delta > 0 ? "+" : "−";
return `${sign}$${formatDecimal2FromCents(Math.abs(delta))} vs Eligibility`;
}
export function incomeStreamsEqual(a: IncomeStream[], b: IncomeStream[]): boolean {
if (a.length !== b.length) return false;
const byId = (rows: IncomeStream[]) => new Map(rows.map((row) => [row.id, row]));
const left = byId(a);
const right = byId(b);
if (left.size !== right.size) return false;
for (const [id, row] of left) {
const other = right.get(id);
if (!other) return false;
if (
row.memberId !== other.memberId ||
row.typeCode !== other.typeCode ||
row.category !== other.category ||
row.amountCents !== other.amountCents ||
row.expensesCents !== other.expensesCents ||
row.selfEmploymentKind !== other.selfEmploymentKind ||
row.description !== other.description
)
return false;
}
return true;
}
export function scenariosEqual(a: Scenario, b: Scenario): boolean {
return (
a.populated === b.populated &&
a.householdSize === b.householdSize &&
(a.educationGrantCents ?? 0) === (b.educationGrantCents ?? 0) &&
(a.educationExpensesCents ?? 0) === (b.educationExpensesCents ?? 0) &&
(a.applicationDay ?? null) === (b.applicationDay ?? null) &&
a.medicalDeductionCents === b.medicalDeductionCents &&
a.dependentCareCents === b.dependentCareCents &&
a.childSupportCents === b.childSupportCents &&
a.rentCents === b.rentCents &&
a.taxesInsuranceCents === b.taxesInsuranceCents &&
a.installationCents === b.installationCents &&
a.otherShelterCents === b.otherShelterCents &&
a.sua === b.sua &&
a.elderlyDisabled === b.elderlyDisabled &&
a.categoricalEligibility === b.categoricalEligibility &&
a.homeless === b.homeless &&
incomeStreamsEqual(a.income, b.income)
);
}
export function scenarioForColumn(
review: SnapReview,
column: ColumnId,
drafts: DraftMap,
): Scenario {
if (column === "final") {
const final = review.facts.final;
if (final) {
const base = final.inputs ?? review.facts.scenarios[final.scenario];
return { ...base, income: final.income };
}
try {
const preview = selectFinal(review);
const base = review.facts.scenarios[preview.scenario];
return { ...base, income: preview.income };
} catch {
return review.facts.scenarios.compI;
}
}
if (!isEditableColumn(column)) return review.facts.scenarios.eligibility;
return drafts[column] ?? review.facts.scenarios[column];
}
export function hasUnsavedDraft(
drafts: DraftMap,
review: SnapReview,
scenario: EditableScenarioId,
): boolean {
const draft = drafts[scenario];
if (!draft) return false;
return !scenariosEqual(draft, review.facts.scenarios[scenario]);
}
export function hasAnyUnsavedDraft(drafts: DraftMap, review: SnapReview): boolean {
return EDITABLE_SCENARIO_IDS.some((id) => hasUnsavedDraft(drafts, review, id));
}
export type CalcCell =
| { kind: "blocked"; message: string }
| { kind: "error"; message: string }
| { kind: "empty"; message: string }
| { kind: "ok"; result: ComputationResult };
export function calcScenario(review: SnapReview, scenario: Scenario, column: ColumnId): CalcCell {
if (!scenario.populated) return { kind: "empty", message: "Scenario not marked populated." };
if (column === "final") {
const final = review.facts.final;
if (!final)
return { kind: "empty", message: "Apply Final Results to save the selected result." };
if (final.inputsRevision !== review.facts.inputsRevision)
return { kind: "blocked", message: "Final Results are stale. Save inputs and apply again." };
return { kind: "ok", result: final.result };
}
try {
return {
kind: "ok",
result: calculate(
scenario,
review.sampleMonth,
review.calcVersion,
review.policy || review.policyId ? resolveReviewPolicy(review) : undefined,
),
};
} catch (cause) {
return {
kind: "error",
message: cause instanceof Error ? cause.message : "Calculation failed.",
};
}
}
export function applyFinalBlockers(
review: SnapReview,
drafts: DraftMap,
editable: boolean,
): string[] {
const reasons: string[] = [];
if (!editable) reasons.push("Case facts are locked for your role or workflow state.");
if (hasAnyUnsavedDraft(drafts, review)) {
reasons.push(
"Save or reset Eligibility, Comp I, and Comp II drafts before applying Final Results.",
);
}
return reasons;
}
export function draftAfterSavedSync(
draft: Scenario | undefined,
saved: Scenario,
): Scenario | undefined {
if (!draft) return undefined;
if (scenariosEqual(draft, saved)) return undefined;
return draft;
}
/** Retire a raw draft entry only when it still matches what was persisted. */
export function retireDraftAfterSave(
rawDrafts: DraftMap,
scenarioId: EditableScenarioId,
savedDraft: Scenario,
): DraftMap {
const current = rawDrafts[scenarioId];
if (!current) return rawDrafts;
if (!scenariosEqual(current, savedDraft)) return rawDrafts;
const next = { ...rawDrafts };
delete next[scenarioId];
return next;
}
export function isEditableColumn(col: ColumnId): col is EditableScenarioId {
return col === "eligibility" || col === "compI" || col === "compII";
}
export type ResultRowKey = keyof Pick<
ComputationResult,
| "educationNetCents"
| "earnedAndEducationCents"
| "monthlyBenefitCents"
| "selfEmploymentLossCents"
| "earnedCents"
| "unearnedCents"
| "grossCents"
| "grossLimitCents"
| "earnedDeductionCents"
| "standardDeductionCents"
| "adjustedIncomeCents"
| "suaCents"
| "shelterCents"
| "shelterDeductionCents"
| "netCents"
| "netLimitCents"
| "allotmentCents"
| "benefitCents"
>;
export const RESULT_ROWS: { key: ResultRowKey | "eligible"; label: string }[] = [
{ key: "earnedCents", label: "Line 1 · Earned income (net self-employment)" },
{ key: "educationNetCents", label: "Line 4 · Net education income" },
{ key: "earnedAndEducationCents", label: "Line 5 · Earned + education income" },
{ key: "unearnedCents", label: "Unearned income" },
{ key: "selfEmploymentLossCents", label: "Line 8 · Self-employment loss" },
{ key: "grossCents", label: "Line 9 · Gross income after SE loss" },
{ key: "grossLimitCents", label: "Gross limit" },
{ key: "earnedDeductionCents", label: "Earned deduction" },
{ key: "standardDeductionCents", label: "Standard deduction" },
{ key: "adjustedIncomeCents", label: "Adjusted income" },
{ key: "suaCents", label: "SUA amount" },
{ key: "shelterCents", label: "Shelter costs incl. SUA" },
{ key: "shelterDeductionCents", label: "Shelter deduction" },
{ key: "netCents", label: "Net SNAP income" },
{ key: "netLimitCents", label: "Net income limit" },
{ key: "allotmentCents", label: "Thrifty Food Plan allotment" },
{ key: "monthlyBenefitCents", label: "Monthly benefit before proration" },
{ key: "benefitCents", label: "Coupon allotment / benefit" },
{ key: "eligible", label: "Eligible" },
];
export function resultValue(
result: ComputationResult,
key: ResultRowKey | "eligible",
): number | boolean | undefined {
if (key === "eligible") return result.eligible;
return result[key];
}
/** Use the alignment identity as the row key; saved IDs can repeat across columns. */
export function alignIncomeRows(lists: IncomeStream[][]) {
const groups = new Map<string, (IncomeStream | undefined)[]>();
lists.forEach((list, column) => {
const occurrences = new Map<string, number>();
for (const row of list) {
const source = row.typeCode.trim().toLowerCase();
const identity = source ? JSON.stringify([row.memberId, source, row.category]) : row.id;
const occurrence = occurrences.get(identity) ?? 0;
occurrences.set(identity, occurrence + 1);
const key = JSON.stringify([identity, occurrence]);
const group = groups.get(key) ?? Array.from({ length: lists.length });
group[column] = row;
groups.set(key, group);
}
});
return Array.from(groups, ([key, rows]) => ({ key, rows }));
}