diff --git a/src/lib/components/CopyForAiButton.svelte b/src/lib/components/CopyForAiButton.svelte new file mode 100644 index 0000000..5665ea8 --- /dev/null +++ b/src/lib/components/CopyForAiButton.svelte @@ -0,0 +1,332 @@ + + +
+ + + +
+
+

Copy for AI assistant

+ +
+ +

+ A self-contained summary of this benefit calculation, formatted as + markdown. Paste it into ChatGPT, Claude, or another AI assistant to ask + follow-up questions about your Social Security benefits. +

+ +

+ This includes your birthdate, earnings, and PIA. It stays on your device + until you paste it somewhere — only share it with tools you trust. +

+ +
{markdown}
+ +
+ + + {#if copyError} +

+ Could not copy — select the text above and copy it manually. +

+ {/if} +
+
+
+
+ + diff --git a/src/lib/components/copy-for-ai.ts b/src/lib/components/copy-for-ai.ts new file mode 100644 index 0000000..ab04367 --- /dev/null +++ b/src/lib/components/copy-for-ai.ts @@ -0,0 +1,39 @@ +import { + buildCalculatorAiExport, + buildCoupleCalculatorAiExport, + type CalculatorAiExportOptions, +} from '$lib/ai-export'; +import type { Recipient } from '$lib/recipient'; + +/** + * Formats a Date as `YYYY-MM-DD` using its LOCAL calendar parts. + * + * Deliberately not `new Date().toISOString().slice(0, 10)`: that yields the UTC + * day, which can be one calendar day off from the user's local day near + * midnight. The AI export stamps a human-facing "Generated" date, so it must + * match the day the user is actually looking at the page. + */ +export function localIsoDate(d: Date): string { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +/** + * Builds the "Copy for AI assistant" markdown for the calculator report. Uses + * the single-recipient builder, or the couple builder when a spouse is present. + * Options (baseUrl, generatedDate) are forwarded unchanged to the underlying + * builder, which is deterministic for a given set of inputs (see + * buildCalculatorAiExport; amounts depend on constants.CURRENT_YEAR resolved + * at module load, not on the wall clock). + */ +export function buildCalculatorExport( + recipient: Recipient, + spouse: Recipient | null, + options: CalculatorAiExportOptions = {} +): string { + return spouse + ? buildCoupleCalculatorAiExport(recipient, spouse, options) + : buildCalculatorAiExport(recipient, options); +} diff --git a/src/routes/calculator/+page.svelte b/src/routes/calculator/+page.svelte index 0014a22..5ca90c9 100644 --- a/src/routes/calculator/+page.svelte +++ b/src/routes/calculator/+page.svelte @@ -3,6 +3,7 @@ import type { ComponentType, SvelteComponent } from 'svelte'; import { onMount } from 'svelte'; import CombinedChart from '$lib/components/CombinedChart.svelte'; import CombinedHeading from '$lib/components/CombinedHeading.svelte'; +import CopyForAiButton from '$lib/components/CopyForAiButton.svelte'; import EarningsReport from '$lib/components/EarningsReport.svelte'; import EligibilityReport from '$lib/components/EligibilityReport.svelte'; import FilingDateReport from '$lib/components/FilingDateReport.svelte'; @@ -372,6 +373,17 @@ async function loadIntegrationComponents( /> {/if} + +
+

Copy for AI assistant

+

+ Turn this entire report into a self-contained markdown summary you + can paste into ChatGPT, Claude, or another AI assistant — then ask + follow-up questions about your own Social Security benefits. +

+ +
+
@@ -396,6 +408,13 @@ async function loadIntegrationComponents( .recipientName { text-decoration: underline; } + .copyForAiSection { + margin: 0 0.5em; + } + .copyForAiSection p { + max-width: 640px; + line-height: 1.5; + } @media screen { .printFooter { display: none; diff --git a/src/stories/CopyForAiButton.stories.ts b/src/stories/CopyForAiButton.stories.ts new file mode 100644 index 0000000..1ce8b5c --- /dev/null +++ b/src/stories/CopyForAiButton.stories.ts @@ -0,0 +1,48 @@ +import type { Meta } from '@storybook/svelte'; +import { Birthdate } from '$lib/birthday'; +import demo from '$lib/pastes/averagepaste.txt?raw'; +import demo_spouse_low from '$lib/pastes/averagepaste-spouse.txt?raw'; +import { Recipient } from '$lib/recipient'; +import { parsePaste } from '$lib/ssa-parse'; +import CopyForAiButton from '../lib/components/CopyForAiButton.svelte'; + +const recipient = new Recipient(); +recipient.name = 'Alex'; +recipient.markFirst(); +recipient.earningsRecords = parsePaste(demo); +recipient.birthdate = Birthdate.FromYMD(1950, 6, 1); + +const spouse = new Recipient(); +spouse.name = 'Chris'; +spouse.markSecond(); +spouse.earningsRecords = parsePaste(demo_spouse_low); +spouse.birthdate = Birthdate.FromYMD(1950, 6, 1); + +const meta: Meta = { + component: CopyForAiButton, + title: 'Report/CopyForAiButton', + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, +}; +export default meta; + +const Template = ({ ...args }) => ({ + Component: CopyForAiButton, + props: args, +}); + +// Single recipient: the export uses the single-person builder. +export const Single = Template.bind({}); +Single.args = { + recipient: recipient, + spouse: null, +}; + +// Couple: the export uses the couple builder (adds spousal/survivor sections). +export const Couple = Template.bind({}); +Couple.args = { + recipient: recipient, + spouse: spouse, +}; diff --git a/src/test/components/copy-for-ai.test.ts b/src/test/components/copy-for-ai.test.ts new file mode 100644 index 0000000..f5e5293 --- /dev/null +++ b/src/test/components/copy-for-ai.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; + +import { Birthdate } from '$lib/birthday'; +import { + buildCalculatorExport, + localIsoDate, +} from '$lib/components/copy-for-ai'; +import { EarningRecord } from '$lib/earning-record'; +import { Money } from '$lib/money'; +import { Recipient } from '$lib/recipient'; + +/** + * Tests for the importable logic behind the "Copy for AI assistant" button + * (issue #553, Phase 2). The Svelte component is a thin shell over these two + * helpers, so the meaningful branches are tested here rather than by rendering. + */ + +function earningsRecord(year: number, amount: number): EarningRecord { + return new EarningRecord({ + year, + taxedEarnings: Money.from(amount), + taxedMedicareEarnings: Money.from(amount), + }); +} + +/** A 35-year $60k earner: eligible, non-zero PIA. */ +function eligibleRecipient(name = 'Alex'): Recipient { + const r = new Recipient(); + r.name = name; + r.gender = 'male'; + r.birthdate = Birthdate.FromYMD(1965, 8, 21); + const records: EarningRecord[] = []; + for (let year = 1990; year <= 2024; year++) { + records.push(earningsRecord(year, 60000)); + } + r.earningsRecords = records; + return r; +} + +/** A low earner who qualifies for a spousal top-up against the higher earner. */ +function lowerEarner(name = 'Jordan'): Recipient { + const r = new Recipient(); + r.name = name; + r.gender = 'female'; + r.birthdate = Birthdate.FromYMD(1966, 2, 10); + const records: EarningRecord[] = []; + for (let year = 2005; year <= 2016; year++) { + records.push(earningsRecord(year, 14000)); + } + r.earningsRecords = records; + return r; +} + +describe('localIsoDate', () => { + // The ai-export builder docs warn against deriving the "Generated" date from + // new Date().toISOString(), whose UTC value can be a calendar day off from + // the user's local day. localIsoDate must read the LOCAL calendar parts. + it('formats a local date as YYYY-MM-DD', () => { + // Constructed in local time, so the local parts are exactly (2026, Mar, 7). + expect(localIsoDate(new Date(2026, 2, 7))).toBe('2026-03-07'); + }); + + it('zero-pads single-digit months and days', () => { + expect(localIsoDate(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); + + it('handles a two-digit month and day (December 31)', () => { + expect(localIsoDate(new Date(2026, 11, 31))).toBe('2026-12-31'); + }); + + it('reads the local calendar day, not the UTC day', () => { + // Mirror the contract against the local getters. A toISOString()-based + // implementation would diverge from this in timezones east of UTC near + // local midnight, where the local day and the UTC day differ. + const d = new Date(2026, 5, 18, 23, 30); + const expected = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + expect(localIsoDate(d)).toBe(expected); + }); +}); + +describe('buildCalculatorExport', () => { + it('uses the single-recipient builder when there is no spouse', () => { + const out = buildCalculatorExport(eligibleRecipient(), null); + // The single export has no couple-only analysis sections. + expect(out).not.toContain('## Spousal benefits'); + expect(out).not.toContain('## Survivor benefits'); + }); + + it('uses the couple builder when a spouse is present', () => { + const out = buildCalculatorExport(eligibleRecipient(), lowerEarner()); + expect(out).toContain('## Spousal benefits'); + expect(out).toContain('## Survivor benefits'); + }); + + it('stamps the provided generatedDate into the header', () => { + const out = buildCalculatorExport(eligibleRecipient(), null, { + generatedDate: '2026-03-07', + }); + expect(out).toContain('Generated 2026-03-07'); + }); + + it('forwards a custom baseUrl into the deep link', () => { + const out = buildCalculatorExport(eligibleRecipient(), null, { + baseUrl: 'https://example.test/calculator', + }); + expect(out).toContain('https://example.test/calculator'); + }); + + it('uses the production calculator URL when no baseUrl is given', () => { + // The live button calls this with only generatedDate, so the default + // deep-link target must resolve to the production URL. + const out = buildCalculatorExport(eligibleRecipient(), null); + expect(out).toContain('https://ssa.tools/calculator'); + }); + + it('is deterministic given the same inputs and options', () => { + const opts = { generatedDate: '2026-03-07' }; + const a = buildCalculatorExport(eligibleRecipient(), lowerEarner(), opts); + const b = buildCalculatorExport(eligibleRecipient(), lowerEarner(), opts); + expect(a).toBe(b); + }); +});