From 0e3352d54ed9420cffa98a090baccd44ee279d26 Mon Sep 17 00:00:00 2001 From: Greg Grothaus Date: Fri, 3 Jul 2026 17:44:50 -0700 Subject: [PATCH 1/2] fix(ai-export): include estimated future earnings in export tables (#553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AIME total blends historical and estimated future earnings, but the export's AIME table, eligibility credit table, and chart-embed earnings1 param only rendered historical records — so estimated years vanished from the .md while their effect on the AIME remained. Claude-Session: https://claude.ai/code/session_016dAFQiMEPD7RREkLEzFVJw --- src/lib/ai-export.ts | 24 +++++++++++++++++++++--- src/test/ai-export.test.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/lib/ai-export.ts b/src/lib/ai-export.ts index 617c94c..90c2bc8 100644 --- a/src/lib/ai-export.ts +++ b/src/lib/ai-export.ts @@ -13,6 +13,7 @@ import { MAX_COLA_YEAR, MAX_WAGE_INDEX_YEAR, } from '$lib/constants'; +import type { EarningRecord } from '$lib/earning-record'; import { MonthDuration } from '$lib/month-time'; import type { Recipient } from '$lib/recipient'; import { buildStrategyHash, type Gender } from '$lib/url-params'; @@ -116,6 +117,23 @@ function nameParam(name: string, defaultName: string): string | undefined { // Per-recipient sections (reused by the single and couple exports) // --------------------------------------------------------------------------- +/** + * All earnings the AIME is computed from, in year order: the recorded + * (historical) years plus any estimated future years the user added. The AIME + * total blends both (EarningsManager concatenates them before taking the top + * 35), so every table derived from the earnings history must show both — or the + * printed rows can't account for the printed AIME. Future years always fall + * after historical ones; the sort is defensive. + */ +function allEarningsRecords( + recipient: Recipient +): ReadonlyArray { + return [ + ...recipient.earningsRecords, + ...recipient.futureEarningsRecords, + ].sort((a, b) => a.year - b.year); +} + /** Eligibility: 40 work credits, with a year-by-year breakdown until reached. */ function eligibilitySection(recipient: Recipient): string { const lines = [ @@ -141,7 +159,7 @@ function eligibilitySection(recipient: Recipient): string { // for eligibility). const rows: string[][] = []; let cumulative = 0; - for (const record of recipient.earningsRecords) { + for (const record of allEarningsRecords(recipient)) { if (cumulative >= 40) break; cumulative = Math.min(40, cumulative + record.credits()); rows.push([ @@ -187,7 +205,7 @@ function aimeSection(recipient: Recipient): string { '', ]; - const rows = recipient.earningsRecords.map((record) => [ + const rows = allEarningsRecords(recipient).map((record) => [ String(record.year), record.taxedEarnings.wholeDollars(), record.indexFactor().toFixed(4), @@ -477,7 +495,7 @@ const EMBED_BASE = 'https://ssa.tools/embed'; /** Encodes a recipient's earnings history as the `earnings1` URL parameter. */ function earningsParam(recipient: Recipient): string { - return recipient.earningsRecords + return allEarningsRecords(recipient) .map((r) => `${r.year}:${Math.round(r.taxedEarnings.value())}`) .join(','); } diff --git a/src/test/ai-export.test.ts b/src/test/ai-export.test.ts index ac73c0a..8c80acc 100644 --- a/src/test/ai-export.test.ts +++ b/src/test/ai-export.test.ts @@ -205,6 +205,37 @@ describe('buildCalculatorAiExport (single, earnings-based)', () => { expect(md).toContain(r.monthlyIndexedEarnings().wholeDollars()); }); + it('lists estimated future-earnings years in the AIME table (issue #553)', () => { + // Regression: estimated (future) earnings feed the AIME total but were + // omitted from the indexed-earnings table, so the visible rows could not + // account for the printed AIME. The table must show every year the AIME + // is computed from, historical and estimated alike. + const r = eligibleRecipient(); // 1990-2024 historical + r.simulateFutureEarningsYears(3, Money.from(70000)); + const futureYears = r.futureEarningsRecords.map((rec) => rec.year); + expect(futureYears.length).toBe(3); // fixture self-check + expect(Math.min(...futureYears)).toBeGreaterThan(2024); + + const md = buildCalculatorAiExport(r); + // Isolate the AIME section so we don't match the eligibility credit table, + // which shares the leading "| Year | Taxed earnings |" columns. + const aime = md.slice( + md.indexOf('## Average Indexed Monthly Earnings'), + md.indexOf('## Primary Insurance Amount') + ); + const rows = tableLines(aime, '| Year | Taxed earnings | Index factor'); + const dataRows = rows.slice(2); // drop header + separator + const yearCells = dataRows.map((row) => row.split('|')[1].trim()); + + // One row per combined earnings year, and every estimated year present. + expect(dataRows.length).toBe( + r.earningsRecords.length + r.futureEarningsRecords.length + ); + for (const year of futureYears) { + expect(yearCells).toContain(String(year)); + } + }); + it('includes the worked PIA bend-point formula', () => { const r = eligibleRecipient(); const md = buildCalculatorAiExport(r); From 8e53b74be0eb6671a5e72184564d6545cba1417e Mon Sep 17 00:00:00 2001 From: Greg Grothaus Date: Fri, 3 Jul 2026 17:47:42 -0700 Subject: [PATCH 2/2] test(ai-export): cover eligibility table and earnings1 param future years Review follow-up: assert the credit table extends into estimated years and caps cumulative at 40, and that the chart-embed earnings1 param carries the estimated years. Claude-Session: https://claude.ai/code/session_016dAFQiMEPD7RREkLEzFVJw --- src/test/ai-export.test.ts | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/test/ai-export.test.ts b/src/test/ai-export.test.ts index 8c80acc..6338823 100644 --- a/src/test/ai-export.test.ts +++ b/src/test/ai-export.test.ts @@ -183,7 +183,22 @@ describe('buildCalculatorAiExport (single, earnings-based)', () => { // Self-verify the fixture sits in the middle branch. expect(r.earnedCredits()).toBeLessThan(40); expect(r.totalCredits()).toBeGreaterThanOrEqual(40); - expect(buildCalculatorAiExport(r)).toMatch(/projected to reach 40/i); + const md = buildCalculatorAiExport(r); + expect(md).toMatch(/projected to reach 40/i); + + // The credit table must extend into the estimated future years backing + // that projection, and stop once the cumulative count reaches 40. + const rows = tableLines( + md, + '| Year | Taxed earnings | Credits | Cumulative |' + ); + const dataRows = rows.slice(2); + const futureYear = String(r.futureEarningsRecords[0].year); + expect( + dataRows.some((row) => row.split('|')[1].trim() === futureYear) + ).toBe(true); + const lastCumulative = dataRows[dataRows.length - 1].split('|')[4].trim(); + expect(lastCumulative).toBe('40'); }); it('does not throw for a recipient with no earnings records', () => { @@ -234,6 +249,10 @@ describe('buildCalculatorAiExport (single, earnings-based)', () => { for (const year of futureYears) { expect(yearCells).toContain(String(year)); } + + // The chart-embed earnings1= param must carry the same combined series, + // or the linked interactive chart would show a different AIME. + expect(md).toContain(`${futureYears[0]}:70000`); }); it('includes the worked PIA bend-point formula', () => {