PageSourceSearch

https://quantcalc.app/js/components/whatif.js?v=3b1d3ef4

js quantcalc.app collected 2026-09-24 14:32:38 UTC 13,521 bytes, 291 lines download raw bytes

1/**
2 * What-if comparisons — the two questions advisors ask first:
3 *   1. Social Security claiming age: the same plan run at 62 / 67 / 70 (and the
4 *      plan's own age), the benefit restated through the PIA (js/ss-claiming.js).
5 *   2. Scenarios: named snapshots of the whole input set ("retire at 63",
6 *      "$60k spending"), run side by side against the current plan.
7 *
8 * Every row is a full simulation at the plan's own path count, through the
9 * same request builder the Run button uses. PRO feature (each comparison is
10 * three or more simulations). Results live on AppState.whatif so the client
11 * report can print them (pdf-export.js renderWhatIfPage).
12 *
13 * Classic script: relies on AppState, activeAPI, QC_SS, formatCurrency,
14 * showUpgradePopup being globals. Exposes window.QC_WHATIF.
15 */
16(function () {
17    'use strict';
18
19    const STORE = 'quantcalc_whatif_scenarios';
20    const MAX_SCENARIOS = 6;
21
22    function whatifState() {
23        if (!AppState.whatif) AppState.whatif = { claiming: null, scenarios: null };
24        return AppState.whatif;
25    }
26
27    /* ---------------- data ---------------- */
28
29    function summarize(r) {
30        const rs = r && r.realised_spending;
31        return {
32            success_rate: r ? r.success_rate : null,
33            median_final: r ? r.median_final_value : null,
34            p10_final: r && r.percentiles ? r.percentiles.p10 : null,
35            spend_p50: rs && rs.p50 > 0 ? rs.p50 : null,
36            spend_p10: rs && rs.p50 > 0 ? rs.p10 : null,
37            num_simulations: r ? r.num_simulations : null
38        };
39    }
40
41    /* Run the plan with some inputs overridden, through the real request
42     * builder, without leaving the override behind. */
43    async function runVariant(overrides) {
44        const saved = AppState.inputs;
45        let request;
46        AppState.inputs = Object.assign({}, saved, overrides);
47        try {
48            request = AppState.getSimulationRequest();
49        } finally {
50            AppState.inputs = saved;
51        }
52        // api.js writes AppState.taxResults (and results) on every simulate
53        // response; a what-if variant must not leave the LAST scenario's tax
54        // figures behind for the current plan's report (found 2026-09-08: the
55        // exported tax page showed the last variant's lifetime tax).
56        const savedTax = AppState.taxResults, savedResults = AppState.results;
57        let response;
58        try {
59            response = await activeAPI.simulate(request);
60        } finally {
61            AppState.taxResults = savedTax;
62            AppState.results = savedResults;
63        }
64        if (!response || !response.success || !response.results) throw new Error('Simulation failed');
65        return summarize(response.results);
66    }
67
68    function gate() {
69        if (!AppState.isPro || !AppState.isPro()) {
70            showUpgradePopup({ context: 'What-if comparisons run several full simulations of your plan. PRO includes them.', source: 'whatif' });
71            return false;
72        }
73        if (!AppState.results) {
74            if (typeof showError === 'function') showError('Run a simulation first, then compare.');
75            return false;
76        }
77        return true;
78    }
79
80    async function compareClaiming(fra) {
81        if (!gate()) return null;
82        fra = Number(fra) || QC_SS.DEFAULT_FRA;
83        const i = AppState.inputs;
84        const ages = QC_SS.comparisonAges(i.ssStartAge);
85        const rows = [];
86        setStatus('claiming', 'Running ' + ages.length + ' simulations…');
87        try {
88            for (let k = 0; k < ages.length; k++) {
89                const age = ages[k];
90                const benefit = Math.round(QC_SS.benefitAtAge(i.socialSecurity, i.ssStartAge, age, fra));
91                setStatus('claiming', 'Running ' + (k + 1) + ' of ' + ages.length + ' (claim at ' + age + ')…');
92                const s = await runVariant({ ssStartAge: age, socialSecurity: benefit });
93                rows.push(Object.assign({ age, benefit, isPlan: age === Math.round(i.ssStartAge) }, s));
94            }
95        } catch (e) {
96            setStatus('claiming', 'Comparison stopped: ' + (e.message || e));
97            return null;
98        }
99        whatifState().claiming = { fra, planAge: i.ssStartAge, planBenefit: i.socialSecurity, rows, ts: new Date().toISOString() };
100        setStatus('claiming', '');
101        renderClaiming();
102        track('whatif_claiming', { rows: rows.length });
103        return rows;
104    }
105
106    /* ---------------- scenarios ---------------- */
107
108    function loadScenarios() {
109        try {
110            const raw = JSON.parse(localStorage.getItem(STORE) || '[]');
111            return Array.isArray(raw) ? raw.filter((s) => s && s.name && s.inputs) : [];
112        } catch (e) { return []; }
113    }
114
115    function persist(list) {
116        try { localStorage.setItem(STORE, JSON.stringify(list)); } catch (e) { /* storage full */ }
117    }
118
119    function saveScenario(name) {
120        name = String(name || '').trim().slice(0, 40);
121        if (!name) return null;
122        const list = loadScenarios().filter((s) => s.name !== name);
123        // Snapshot only JSON-safe input values; lots and arrays copy by value.
124        const inputs = JSON.parse(JSON.stringify(AppState.inputs));
125        list.push({ name, inputs, created: new Date().toISOString() });
126        while (list.length > MAX_SCENARIOS) list.shift();
127        persist(list);
128        renderScenarioChips();
129        return list[list.length - 1];
130    }
131
132    function removeScenario(name) {
133        persist(loadScenarios().filter((s) => s.name !== name));
134        renderScenarioChips();
135    }
136
137    function applyScenario(name) {
138        const sc = loadScenarios().find((s) => s.name === name);
139        if (!sc) return false;
140        Object.assign(AppState.inputs, sc.inputs);
141        if (typeof AppState.saveInputsToStorage === 'function') AppState.saveInputsToStorage();
142        // Inputs bind to the DOM at init; a reload is the one reliable way to
143        // show every field from the snapshot.
144        window.location.reload();
145        return true;
146    }
147
148    const RULE_SHORT = { 'guyton-klinger': 'guardrails', 'vpw': 'VPW', 'bucket': 'bucket' };
149
150    function describe(inputs) {
151        const a = inputs || {};
152        return 'retire ' + a.retirementAge + ' · $' + Number(a.annualSpending || 0).toLocaleString()
153            + '/yr · SS ' + a.ssStartAge + (RULE_SHORT[a.withdrawalStrategy] ? ' · ' + RULE_SHORT[a.withdrawalStrategy] : '');
154    }
155
156    async function runScenarios() {
157        if (!gate()) return null;
158        const list = loadScenarios();
159        if (!list.length) {
160            if (typeof showError === 'function') showError('Save a scenario first (change some inputs, then "Save as scenario").');
161            return null;
162        }
163        const rows = [Object.assign({ name: 'Current plan', isCurrent: true, summary: describe(AppState.inputs) }, summarize(AppState.results))];
164        try {
165            for (let k = 0; k < list.length; k++) {
166                setStatus('scenarios', 'Running ' + (k + 1) + ' of ' + list.length + ' (' + list[k].name + ')…');
167                const s = await runVariant(list[k].inputs);
168                rows.push(Object.assign({ name: list[k].name, summary: describe(list[k].inputs) }, s));
169            }
170        } catch (e) {
171            setStatus('scenarios', 'Comparison stopped: ' + (e.message || e));
172            return null;
173        }
174        whatifState().scenarios = { rows, ts: new Date().toISOString() };
175        setStatus('scenarios', '');
176        renderScenarios();
177        track('whatif_scenarios', { rows: rows.length });
178        return rows;
179    }
180
181    /* ---------------- rendering ---------------- */
182
183    function fmt$(v) {
184        if (v == null || !isFinite(v)) return '—';
185        return typeof formatCurrency === 'function' ? formatCurrency(v) : '$' + Math.round(v).toLocaleString();
186    }
187    function pct(v) { return v == null ? '—' : (v * 100).toFixed(1) + '%'; }
188
189    function table(headers, rows, highlightIdx) {
190        const t = document.createElement('table');
191        t.className = 'whatif-table';
192        const thead = document.createElement('thead');
193        const hr = document.createElement('tr');
194        headers.forEach((h) => { const th = document.createElement('th'); th.textContent = h; hr.appendChild(th); });
195        thead.appendChild(hr); t.appendChild(thead);
196        const tb = document.createElement('tbody');
197        rows.forEach((cells, idx) => {
198            const tr = document.createElement('tr');
199            if (idx === highlightIdx) tr.className = 'is-plan';
200            cells.forEach((c) => { const td = document.createElement('td'); td.textContent = c; tr.appendChild(td); });
201            tb.appendChild(tr);
202        });
203        t.appendChild(tb);
204        return t;
205    }
206
207    function setStatus(which, text) {
208        const el = document.getElementById(which === 'claiming' ? 'whatifClaimingStatus' : 'whatifScenariosStatus');
209        if (el) el.textContent = text;
210    }
211
212    function renderClaiming() {
213        const host = document.getElementById('whatifClaimingResult');
214        const c = whatifState().claiming;
215        if (!host || !c) return;
216        host.innerHTML = '';
217        const dyn = c.rows.some((r) => r.spend_p50 != null);
218        const headers = ['Claim at', 'Benefit (today\'s $/yr)', 'Survival', 'Median ending', '10th pct ending'].concat(dyn ? ['Median realised spending'] : []);
219        const rows = c.rows.map((r) => [String(r.age) + (r.isPlan ? ' (plan)' : ''), fmt$(r.benefit), pct(r.success_rate), fmt$(r.median_final), fmt$(r.p10_final)].concat(dyn ? [fmt$(r.spend_p50)] : []));
220        host.appendChild(table(headers, rows, c.rows.findIndex((r) => r.isPlan)));
221        const note = document.createElement('p');
222        note.className = 'whatif-note';
223        note.textContent = 'Benefit restated through the primary insurance amount with SSA reduction and delayed-credit factors (full retirement age ' + c.fra + '). Each row is a full simulation of the same plan.';
224        host.appendChild(note);
225    }
226
227    function renderScenarios() {
228        const host = document.getElementById('whatifScenariosResult');
229        const s = whatifState().scenarios;
230        if (!host || !s) return;
231        host.innerHTML = '';
232        const dyn = s.rows.some((r) => r.spend_p50 != null);
233        const headers = ['Scenario', 'Inputs', 'Survival', 'Median ending', '10th pct ending'].concat(dyn ? ['Median realised spending'] : []);
234        const rows = s.rows.map((r) => [r.name, r.summary, pct(r.success_rate), fmt$(r.median_final), fmt$(r.p10_final)].concat(dyn ? [fmt$(r.spend_p50)] : []));
235        host.appendChild(table(headers, rows, 0));
236    }
237
238    function renderScenarioChips() {
239        const host = document.getElementById('whatifScenarioList');
240        if (!host) return;
241        host.innerHTML = '';
242        const list = loadScenarios();
243        if (!list.length) {
244            const p = document.createElement('span'); p.className = 'whatif-note';
245            p.textContent = 'No saved scenarios yet. Change some inputs, then save them under a name.';
246            host.appendChild(p); return;
247        }
248        list.forEach((sc) => {
249            const chip = document.createElement('span');
250            chip.className = 'whatif-chip';
251            const label = document.createElement('span'); label.textContent = sc.name; label.title = describe(sc.inputs);
252            const apply = document.createElement('button'); apply.type = 'button'; apply.textContent = 'load'; apply.title = 'Load this scenario into the form';
253            apply.addEventListener('click', () => applyScenario(sc.name));
254            const del = document.createElement('button'); del.type = 'button'; del.textContent = '×'; del.title = 'Delete scenario';
255            del.addEventListener('click', () => removeScenario(sc.name));
256            chip.appendChild(label); chip.appendChild(apply); chip.appendChild(del);
257            host.appendChild(chip);
258        });
259    }
260
261    function track(name, params) {
262        if (window.QuantCalcAnalytics && window.QuantCalcAnalytics.trackProUsage) {
263            window.QuantCalcAnalytics.trackProUsage(name, params || {});
264        }
265    }
266
267    function init() {
268        const card = document.getElementById('whatifCard');
269        if (!card) return;
270        const btnC = document.getElementById('whatifClaimingBtn');
271        if (btnC) btnC.addEventListener('click', () => {
272            const fraSel = document.getElementById('whatifFra');
273            compareClaiming(fraSel ? fraSel.value : QC_SS.DEFAULT_FRA);
274        });
275        const btnS = document.getElementById('whatifSaveScenarioBtn');
276        if (btnS) btnS.addEventListener('click', () => {
277            const nameEl = document.getElementById('whatifScenarioName');
278            if (saveScenario(nameEl ? nameEl.value : '') && nameEl) nameEl.value = '';
279        });
280        const btnR = document.getElementById('whatifRunScenariosBtn');
281        if (btnR) btnR.addEventListener('click', () => runScenarios());
282        renderScenarioChips();
283        if (!(AppState.isPro && AppState.isPro())) {
284            const lock = document.getElementById('whatifProChip');
285            if (lock) lock.hidden = false;
286        }
287    }
288
289    window.QC_WHATIF = { init, compareClaiming, saveScenario, removeScenario, applyScenario, runScenarios, runVariant, summarize, loadScenarios, describe, renderClaiming, renderScenarios, renderScenarioChips };
290    window.initWhatIf = init;
291})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.