PageSourceSearch

https://www.flatex.de/_assets/f47c5922d073c852b0e3fabb108534e7/Js/avr/avr-main.1787774194.js

js flatex.de collected 2026-09-24 18:23:43 UTC 56,979 bytes, 1,285 lines download raw bytes

1/**
2 * avr-main.js — Altersvorsorgedepot Rechner: Core Calculator
3 *
4 * Depends on:
5 *   - Chart.js (CDN)
6 *   - avr-pdf.js   → window.AVR_PDF.generatePDF
7 *   - avr-modal.js → window.AVR_MODAL.openEmailModal / closeEmailModal / submitEmailForm
8 *
9 * Load order: avr-pdf.js → avr-modal.js → avr-main.js
10 *
11 * Sections:
12 *   1. State & constants
13 *   2. DOM helpers
14 *   3. View switching
15 *   4. Financial calculations
16 *   5. calc() — orchestrator
17 *   6. Charts
18 *   7. Kinder UI
19 *   8. init()
20 */
21(function () {
22  'use strict';
23
24  /* ─────────────────────────────────────────────────────────
25   * 1. State & constants
26   * ───────────────────────────────────────────────────────── */
27  // ─── CalcConstants — mirrors Dart CalcConstants exactly ─────────────────
28  var DEPOT_START  = 2027;
29  var RETIRE_AGE   = 67;
30  var PAYOUT_YEARS = 18;
31  var GZ_STUFE1_RATE = 0.50;
32  var GZ_STUFE1_CAP  = 360.0;
33  var GZ_STUFE2_RATE = 0.25;
34  var GZ_MAX_BEITRAG = 1800.0;
35  var GZ_MAX_VERTRAG = 6840.0;
36  var KZ_MAX         = 300.0;
37  var BONUS_BETRAG   = 200.0;
38  var BONUS_MAX_AGE  = 25;
39  var MINDESTBEITRAG = 120.0;
40  var TAX_GF   = 12348;
41  var TAX_Z2   = 17799;
42  var TAX_Z3   = 69878;
43  var TAX_Z4   = 277825;
44  var TAX_Z2A  = 914.51;
45  var TAX_Z2B  = 1400;
46  var TAX_Z3A  = 173.10;
47  var TAX_Z3B  = 2397;
48  var TAX_Z3C  = 1034.87;
49  var TAX_Z4O  = 11135.63;
50  var TAX_Z5O  = 19470.38;
51  var TFS          = 0.30;
52  var ABGST_BASE   = 0.26375;
53  var KAPEST_RATE  = 0.25;
54  var SOLI_RATE    = 0.055;
55  var VP_DRAG      = 0.003;
56  var VP_FAKTOR    = 6.5 / 12;
57  var ERTRAGSANTEIL_67 = 0.17;
58  var BANK_RATE    = 0.02;
59  var AVR_KOSTEN       = 0.000;
60  var ETF_KOSTEN       = 0.000;
61  var INFLATION_RATE   = 0.000;
62  var KIRCHENSTEUER    = 0;
63  // ── GRV pension estimation (mirrors Dart EntgeltpunkteEstimator) ──
64  var RENTENWERT           = 39.32;    // €/Entgeltpunkt/Monat (West, Jul 2024, DRV)
65  var DURCHSCHNITTSENTGELT = 45358.0;  // Durchschnittsentgelt 2024 (BBF)
66  var BBG                  = 90600.0;  // Beitragsbemessungsgrenze 2024, West
67  var ARBEITSBEGINN        = 25;       // Assumed career start age (conservative: Uni-Abschluss)
68  var KINDER_MAX_ALTER = 25;
69  var MAX_KINDER   = 6;
70  var BIRTH_MIN    = DEPOT_START - 25;
71  var BIRTH_MAX    = DEPOT_START + 23;
72
73  var children      = [];   // Array of { by: number|null, ta: 18|25 }
74  var gc            = null; // Chart.js instance — view-1
75  var gc2           = null; // Chart.js instance — view-2
76  var lastChartData = { lbl: [], dO: [], dO2: [], dT: [] };
77
78  /* ─────────────────────────────────────────────────────────
79   * 2. DOM helpers
80   * ───────────────────────────────────────────────────────── */
81  function root()         { return document.querySelector('.avr'); }
82  function $id(id)        { return root().querySelector('#' + id); }
83  function $all(selector) { return root().querySelectorAll(selector); }
84
85  function fmt(n) {
86    return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(n) + '\u00a0\u20ac';
87  }
88
89  function setAll(cls, text) {
90    $all(cls).forEach(function (el) { el.textContent = text; });
91  }
92
93  function setEuro(cls, value) { setAll(cls, fmt(value)); }
94
95  function paintSlider(el) {
96    var pct = ((parseFloat(el.value) - parseFloat(el.min)) /
97      (parseFloat(el.max) - parseFloat(el.min))) * 100;
98    el.style.background =
99      'linear-gradient(to right, #F47323 0%, #F47323 ' + pct + '%, #e8ddd7 ' + pct + '%, #e8ddd7 100%)';
100  }
101
102  function syncSliderDisplay(inp, cls, decimals) {
103    var txt = parseFloat(inp.value).toLocaleString('de-DE', {
104      minimumFractionDigits: decimals,
105      maximumFractionDigits: decimals
106    });
107    $all(cls).forEach(function (el) { el.textContent = txt; });
108  }
109
110  /* ─────────────────────────────────────────────────────────
111   * 3. View switching
112   * ───────────────────────────────────────────────────────── */
113  function showView(n) {
114    root().classList.toggle('is-view2', n === 2);
115    if (n === 2 && lastChartData.lbl.length) {
116      setTimeout(function () {
117        renderChart2(lastChartData.lbl, lastChartData.dO, lastChartData.dO2, lastChartData.dT);
118      }, 20);
119    }
120    // scroll to top of calculator on every view switch
121    var r = root();
122    if (r) {
123      setTimeout(function () {
124        r.scrollIntoView({ behavior: 'smooth', block: 'start' });
125      }, 30);
126    }
127    // Move focus to the leading element of the new view
128    setTimeout(function () {
129      var target = n === 2 ? $id('back-btn') : $id('cta-btn');
130      if (target) target.focus();
131    }, 50);
132
133    if (n === 2){
134      window.dataLayer = window.dataLayer || [];
135      window.dataLayer.push({
136        'event': 'calculator_submit',
137        'button_text': 'Dein Rentenreport', //Populate dynamically
138        'lead_type': 'avd_report_download'
139      });
140    }
141
142
143
144  }
145
146  /* ─────────────────────────────────────────────────────────
147   * 4. Financial calculations
148   * ───────────────────────────────────────────────────────── */
149  function yearlySubsidy(annualOwn, kids, taxRate, year) {
150    // Grundzulage requires minimum own contribution of 120 €/year
151    var gzEligible = annualOwn >= MINDESTBEITRAG;
152    var capped     = Math.min(annualOwn, GZ_MAX_BEITRAG);
153    var gz = gzEligible
154      ? Math.min(capped, GZ_STUFE1_CAP) * GZ_STUFE1_RATE + Math.max(Math.min(capped - GZ_STUFE1_CAP, GZ_MAX_BEITRAG - GZ_STUFE1_CAP), 0) * GZ_STUFE2_RATE
155      : 0;
156    var kz = annualOwn > 0
157      ? kids.reduce(function (sum, c) {
158        if (!c.by) return sum;
159        var childAge = year - c.by;
160        if (childAge < 0 || childAge >= c.ta) return sum;
161        return sum + Math.min(annualOwn, KZ_MAX);
162      }, 0)
163      : 0;
164    var zulagen     = gz + kz;
165    var basisBetrag = Math.min(annualOwn, GZ_MAX_BEITRAG) + gz + kz;
166    return {
167      gz:  gz,
168      kz:  kz,
169      zul: zulagen,
170      tax: Math.max(basisBetrag * taxRate - zulagen, 0)
171    };
172  }
173
174  function calcEinkommensteuer(zve) {
175    if (zve <= TAX_GF) return 0;
176    if (zve <= TAX_Z2) { var y = (zve - TAX_GF) / 10000; return (TAX_Z2A * y + TAX_Z2B) * y; }
177    if (zve <= TAX_Z3) { var z = (zve - TAX_Z2) / 10000; return (TAX_Z3A * z + TAX_Z3B) * z + TAX_Z3C; }
178    if (zve <= TAX_Z4) return 0.42 * zve - TAX_Z4O;
179    return 0.45 * zve - TAX_Z5O;
180  }
181
182  function calcIncrementalRetaxRate(avPayout, baseIncome) {
183    if (avPayout <= 0) return 0;
184    return (calcEinkommensteuer(baseIncome + avPayout) - calcEinkommensteuer(baseIncome)) / avPayout;
185  }
186
187  function buildCapital(age, annualOwn, kids, taxRate, annualReturn, bonus200) {
188    var years     = RETIRE_AGE - age;
189    var netReturn = annualReturn - AVR_KOSTEN;
190    var jbGef     = Math.min(annualOwn, GZ_MAX_BEITRAG);
191    var jbUngef   = Math.max(0, annualOwn - GZ_MAX_BEITRAG);
192    var capGef = 0, capUngef = 0, totalOwn = 0, totalZul = 0, totalTax = 0;
193    var balByYear = [], ownByYear = [], zulByYear = [];
194
195    for (var i = 0; i < years; i++) {
196      var s      = yearlySubsidy(jbGef, kids, taxRate, DEPOT_START + i);
197      var bonus  = (i === 0 ? bonus200 : 0);
198      var inflowGef = jbGef + s.zul + bonus;
199      capGef  = (capGef  + inflowGef)  * (1 + netReturn);
200      if (jbUngef > 0) capUngef = (capUngef + jbUngef) * (1 + netReturn);
201      totalOwn  += annualOwn;
202      totalZul  += s.zul + bonus;
203      totalTax  += s.tax;
204      balByYear.push(capGef + capUngef);
205      ownByYear.push(totalOwn);
206      zulByYear.push(totalZul);
207    }
208    return { cap: capGef + capUngef, capGef: capGef, capUngef: capUngef,
209      tO: totalOwn, tZ: totalZul, tT: totalTax,
210      bal: balByYear, own: ownByYear, zul: zulByYear };
211  }
212
213  function calcPension(grossCap, annualReturn, taxRetirement) {
214    var n  = PAYOUT_YEARS * 12;
215    var rM = Math.pow(1 + annualReturn, 1 / 12) - 1;
216    var brutto = (rM < 1e-9) ? grossCap / n : grossCap * rM / (1 - Math.pow(1 + rM, -n));
217    return { b: brutto, n: brutto * (1 - taxRetirement) };
218  }
219
220  /**
221   * ETF / MSCI World — EXACT MATCH to Excel "ETF - Sparplan" tab.
222   *
223   * Frey method (Vorabpauschale annual, liquidation tax at end):
224   *   cap(new)  = start × (1+r) + contrib × (1+r/2) − VAPtax
225   *   VAP       = (start × Basiszins + contrib × Basiszins/2) × (1−TFS)²
226   *   VAPtax    = VAP × ABGST
227   *
228   *   At liquidation:
229   *   ertragForTax  = (cap − totalContrib) + cumVAPtax      ← gross gain incl. VAP credits
230   *   taxableGain   = ertragForTax × (1−TFS) − cumVAP
231   *   liqTax        = taxableGain × ABGST
232   *   netto         = cap − liqTax
233   *
234   * Returns full breakdown object (all values for display + .cap = netto).
235   */
236  function buildETFCapital(years, annualContrib, annualReturn, abgstOverride) {
237    var ABGST     = abgstOverride || ABGST_BASE;
238    var netReturn = annualReturn - ETF_KOSTEN;
239    var cap = 0, totalContrib = 0, vpGesamt = 0;
240    for (var i = 0; i < years; i++) {
241      var depotSoY = cap;
242      cap = (cap + annualContrib) * (1 + netReturn);
243      var vpBase = depotSoY + annualContrib * VP_FAKTOR;
244      var vpJahr = vpBase * VP_DRAG;
245      cap -= vpJahr;
246      vpGesamt += vpJahr;
247      totalContrib += annualContrib;
248    }
249    var grossFV = 0;
250    for (var i = 0; i < years; i++) { grossFV = (grossFV + annualContrib) * (1 + annualReturn - ETF_KOSTEN); }
251    var gewinn       = cap - totalContrib;
252    var ertragForTax = gewinn + vpGesamt;   // gross gain: net gain + cumulative VP credits (§19 Abs. 1 InvStG)
253    var taxableGain  = Math.max(0, ertragForTax * (1 - TFS) - vpGesamt);
254    var liqTax       = taxableGain * ABGST;
255    var netto        = cap - liqTax;
256    return {
257      cap: netto, brutto: cap, grossFV: grossFV, totalContrib: totalContrib,
258      ertrag: gewinn, ertragForTax: ertragForTax, ertragGross: grossFV - totalContrib,
259      cumVAP: vpGesamt, cumVAPTax: vpGesamt, taxableGain: taxableGain,
260      liqTax: liqTax, totalTax: vpGesamt + liqTax
261    };
262  }
263
264  function buildBankCapital(years, annualContrib, bankRate) {
265    var ABGST = 0.26375;
266
267    var cap = 0, totalContrib = 0, cumTax = 0;
268
269    for (var i = 0; i < years; i++) {
270      var zinsertrag = cap * bankRate + annualContrib * bankRate * 0.5;
271      var tax        = zinsertrag * ABGST;
272      cap            = cap * (1 + bankRate) + annualContrib * (1 + bankRate / 2) - tax;
273      totalContrib  += annualContrib;
274      cumTax        += tax;
275    }
276
277    // Gross Future Value (no taxes) — for display
278    var grossFV = 0;
279    for (var i = 0; i < years; i++) { grossFV = grossFV * (1 + bankRate) + annualContrib; }
280
281    return {
282      cap:          cap,                 // Endkapital netto  → .cmp-bank-cap
283      brutto:       cap + cumTax,        // Endkapital brutto (vor Steuern) → .cmp-bank-gross
284      grossFV:      grossFV,             // Endkapital brutto (kein Steuer) → .cmp-bank-gross-fv
285      totalContrib: totalContrib,        // Einzahlungen
286      ertragGross:  (cap + cumTax) - totalContrib, // Zinsertrag brutto → .cmp-bank-ertrag-gross (matches XLS)
287      ertrag:       cap - totalContrib,  // Ertrag netto → .cmp-bank-ertrag
288      cumTax:       cumTax               // Abgeltungssteuer gesamt → .cmp-bank-abgst
289    };
290  }
291
292  /* ─────────────────────────────────────────────────────────
293   * 5. calc() — main orchestrator
294   * ───────────────────────────────────────────────────────── */
295  function calc() {
296    var age      = parseInt($id('age').value, 10);
297    var monthly  = parseFloat($id('contrib').value);
298    var retRate  = parseFloat($id('ret').value) / 100;
299    var taxNow   = parseFloat($id('taxrate').value);
300    var taxRetir = parseFloat($id('retax').value) / 100;
301    var bonus200 = age < BONUS_MAX_AGE ? BONUS_BETRAG : 0;
302    var years    = RETIRE_AGE - age;
303    if (years <= 0) return;
304
305    // ── Fixed defaults ──
306    var bruttoMidpoints = {'0.21': 13000, '0.30': 27000, '0.37': 47000, '0.42': 65000};
307    var bruttoJahr    = bruttoMidpoints[String(taxNow)] || 45000;
308    var kirchensteuer = KIRCHENSTEUER;
309    var kapest        = KAPEST_RATE;
310    var abgst         = ABGST_BASE;
311    var inflationRate = INFLATION_RATE;
312
313    var acc   = buildCapital(age, monthly * 12, children, taxNow, retRate, bonus200);
314    var growth= acc.cap - acc.tO - acc.tZ;
315
316    // ── GRV pension estimate (mirrors Dart EntgeltpunkteEstimator) ──
317    // Total Entgeltpunkte = career years × (brutto / Durchschnittsentgelt), capped at BBG.
318    // Career years = RETIRE_AGE − ARBEITSBEGINN regardless of current age
319    // (person has worked since 25 or will have by 67).
320    var grvYears  = RETIRE_AGE - ARBEITSBEGINN;
321    var epPerYear = Math.min(bruttoJahr, BBG) / DURCHSCHNITTSENTGELT;
322    var renteMonat = grvYears * epPerYear * RENTENWERT;
323    var baseIncome = renteMonat * 12;
324
325    // ── AV-Depot annuities (gross, before tax) ──
326    var avPayoutRate = retRate - AVR_KOSTEN;
327    var monatGef   = calcPension(acc.capGef,   avPayoutRate, 0).b;
328    var monatUngef = calcPension(acc.capUngef, avPayoutRate, 0).b;
329    var avJahresgef = monatGef * 12;
330
331    // ── Gefördert bucket: volle nachgelagerte Besteuerung (§22 Nr. 5 EStG) ──
332    var retaxRate     = calcIncrementalRetaxRate(avJahresgef, baseIncome);
333    var kirchenFaktor = 1 + kirchensteuer;
334    var effectiveRetax = retaxRate * kirchenFaktor;
335    var nettoGef   = monatGef * (1 - effectiveRetax);
336
337    // ── Ungefördert bucket: Ertragsanteilbesteuerung (§22 Nr. 1 Satz 3a EStG) ──
338    // Only ERTRAGSANTEIL_67 (17%) of each annuity payment counts as taxable income.
339    // The incremental rate is computed on top of baseIncome + gefördert already in scope.
340    var ungefErtragsanteilJahr = monatUngef * 12 * ERTRAGSANTEIL_67;
341    var retaxRateUngef = acc.capUngef > 0
342      ? calcIncrementalRetaxRate(ungefErtragsanteilJahr, baseIncome + avJahresgef)
343      : 0;
344    var effectiveRetaxUngef = retaxRateUngef * kirchenFaktor * ERTRAGSANTEIL_67;
345    var nettoUngef = monatUngef * (1 - effectiveRetaxUngef);
346
347    var realFactor = Math.pow(1 + inflationRate, years);
348    var p = { b: monatGef + monatUngef, n: nettoGef + nettoUngef };
349    // Blended effective rate across both buckets (used for display only)
350    var retaxMonthly          = p.b - p.n;
351    var effectiveRetaxBlended = p.b > 0 ? retaxMonthly / p.b : effectiveRetax;
352    var retaxPctActual = (effectiveRetaxBlended * 100).toLocaleStr
352ing('de-DE', {minimumFractionDigits: 1});
353
354    // Net capital at retirement (gross − tax across both buckets)
355    var retaxCapital = acc.capGef * effectiveRetax + acc.capUngef * effectiveRetaxUngef;
356    var netCapital   = acc.cap - retaxCapital;
357
358    // ── Capital & pension outputs ──
359    setAll('.r-total',     fmt(netCapital));
360    setAll('.r-total-age', 'monatlich netto nach\u00a0' + retaxPctActual + '\u00a0% \u00a732a Rentensteuer');
361    setAll('.r-self-pct',  (acc.cap > 0 ? Math.round(acc.tO / acc.cap * 100) : 0) + '%');
362    setEuro('.r-gross',              acc.cap);
363    setEuro('.r-gross-hero',         acc.cap);
364    setEuro('.r-eigen',              acc.tO);
365    setEuro('.r-growth',             growth);
366    setEuro('.r-zulagen',            acc.tZ);
367    setEuro('.r-zulagen2',           acc.tZ);
368    setEuro('.r-taxsave',            acc.tT);
369    setEuro('.r-monthly-brutto',     p.b);
370    setEuro('.r-monthly-netto',      p.n);
371    setEuro('.r-monthly-hero',       p.n);
372    setEuro('.r-monthly-hero-brutto',p.b);
373    setEuro('.r-lump-brutto',        acc.cap * 0.3);
374    setEuro('.r-lump-netto',         acc.cap * (1 - effectiveRetaxBlended) * 0.3);
375    setAll('.r-monthly-note',
376      'Ausz.\u00a0\u00fcber 18\u00a0J. (67\u201385) \u00b7 ' + retaxPctActual + '\u00a0% \u00a732a (inkrementell)');
377
378    // ── Narrative ──
379    setAll('.r-narr-contrib', fmt(monthly));
380    setAll('.r-narr-ret',     (retRate * 100).toLocaleString('de-DE', { minimumFractionDigits: 1 }));
381    setAll('.r-narr-gross',   fmt(acc.cap));
382    setAll('.r-narr-netto',   fmt(p.n));
383
384    // ── Zulagen sub-label ──
385    var yr1 = yearlySubsidy(monthly * 12, children, taxNow, DEPOT_START);
386    var subLabel = (yr1.gz > 0 ? fmt(yr1.gz).trim() + '\u00a0\u20ac\u00a0Grundzulage/J.' : 'keine Grundzulage')
387      + (yr1.kz > 0 ? ' + ' + fmt(yr1.kz).trim() + '\u00a0\u20ac\u00a0Kinder/J.' : '')
388      + (bonus200   ? ' + ' + BONUS_BETRAG.toFixed(0) + '\u00a0\u20ac\u00a0Einmalbonus' : '');
389    setAll('.r-zulagen-sub', subLabel);
390
391    // ── Berechnungsdetails ──
392    var txPct   = Math.round(taxNow * 100);
393    var bracket = root().querySelector('.v1-tax-btn.active .v1-tax-btn-range');
394    setAll('.cs-annual-own',  fmt(monthly * 12));
395    setAll('.cs-age-years',   age + ' Jahre / ' + years + ' Jahre Laufzeit');
396    var eligibleKinder = children.filter(function(c) {
397      if (!c.by) return false;
398      var childAge = DEPOT_START - c.by;
399      return childAge >= 0 && childAge < c.ta;
400    });
401    // cs-kinder: BERECHNUNG column — per-child breakdown
402    var kinderDetail = root().querySelector('#cs-kinder-detail');
403    if (kinderDetail) {
404      if (children.length === 0) {
405        kinderDetail.innerHTML = '<span class="cs-kinder">\u2013</span>';
406      } else {
407        var retireYearK = DEPOT_START + years;
408        var rows = children.map(function (c, ci) {
409          var label = 'Kind\u00a0' + (ci + 1);
410          if (!c.by) {
411            return '<span class="cs-kinder-row cs-kinder-missing">' + label + '\u00a0\u2014 Geburtsjahr fehlt</span>';
412          }
413          var wS  = Math.max(DEPOT_START, c.by);
414          var wE  = Math.min(retireYearK, c.by + c.ta);
415          var eY  = Math.max(0, wE - wS);
416          var line = label + '\u00a0\u2014 geb.\u00a0' + c.by + '\u00a0\u00b7\u00a0Kindergeld bis\u00a0' + c.ta;
417          if (eY > 0) {
418            var total = eY * 300;
419            line += '\u00a0\u00b7\u00a0300\u00a0\u20ac/Jahr\u00a0\u00b7\u00a0'
420              + eY + '\u00a0Jahre\u00a0(' + wS + '\u2013' + wE + ')\u00a0=\u00a0'
421              + new Intl.NumberFormat('de-DE').format(total) + '\u00a0\u20ac';
422          } else {
423            line += '\u00a0\u00b7\u00a0kein Anspruch ab\u00a0' + DEPOT_START;
424          }
425          return '<span class="cs-kinder-row">' + line + '</span>';
426        });
427        kinderDetail.innerHTML = rows.join('');
428      }
429    }
430    setAll('.cs-kinder', children.length === 0 ? '\u2013' : children.length + '\u00a0Kind' + (children.length > 1 ? 'er' : ''));
431    setAll('.cs-rendite',     (retRate * 100).toLocaleString('de-DE', { minimumFractionDigits: 1 }) + '\u00a0%');
432    setAll('.cs-einkommen',   bracket ? bracket.textContent.trim() : '\u2013');
433    setAll('.cs-steuersatz',  txPct + '\u00a0%');
434    setAll('.cs-retax',       (effectiveRetaxBlended * 100).toFixed(1) + '\u00a0%');
435    setAll('.cs-grundzulage', fmt(yr1.gz).trim() + '/Jahr');
436    setAll('.cs-kindzulage',  yr1.kz > 0 ? fmt(yr1.kz) + '/Jahr' : '0\u00a0\u20ac');
437    setAll('.cs-bonus',       bonus200 ? '200\u00a0\u20ac' : '0\u00a0\u20ac');
438    setAll('.cs-zul-pa',      fmt(yr1.zul));
439    setAll('.cs-tax-pa',      fmt(acc.tT));
440    setAll('.cs-foerderquote', monthly * 12 > 0
441      ? (yr1.zul / (monthly * 12) * 100).toFixed(1) + '\u00a0%'
442      : '0\u00a0%');
443
444    // ── Berechnungsdetails: 3-column BERECHNUNG + ERGEBNIS attrs ──────────────
445
446    // ── Section 01 attrs ──
447    setAll('.cs-annual-calc', new Intl.NumberFormat('de-DE',{maximumFractionDigits:0}).format(monthly) + '\u00a0\u20ac \u00d7 12');
448    setAll('.cs-age-calc', 'Bis Renteneintritt mit\u00a0' + RETIRE_AGE);
449    // cs-age-val: just the current age (ERGEBNIS for "Alter zu Beginn" row)
450    setAll('.cs-age-val', age + '\u00a0Jahre');
451    // cs-anspar-val: laufzeit in years (ERGEBNIS for "Ansparphase" row)
452    setAll('.cs-anspar-val', years + '\u00a0Jahre\u00a0lang');
453    // cs-kinder (used as BERECHNUNG col): already set above as detail string
454    // cs-kinder-val: ERGEBNIS — Kindergeldanspruch end-year
455    var kinderValStr;
456    if (children.length === 0) {
457      kinderValStr = '\u2013';
458    } else {
459      var lastKgYear = 0;
460      children.forEach(function (c) {
461        if (c.by) { var end = c.by + c.ta; if (end > lastKgYear) lastKgYear = end; }
462      });
463      kinderValStr = eligibleKinder.length === 0
464        ? 'Kein Kindergeldanspruch in 2027'
465        : eligibleKinder.length === 1
466          ? 'Kindergeldanspruch bis\u00a0' + lastKgYear
467          : eligibleKinder.length + ' Kinder \u2013 Kindergeldanspruch bis\u00a0' + lastKgYear;
468    }
469    setAll('.cs-kinder-val', kinderValStr);
470
471    // ── Section 02 attrs ──
472    // Grundzulage calc formula (dynamic based on capped contribution)
473    var capped02 = Math.min(monthly * 12, 1800);
474    var tier1 = Math.min(capped02, 360);
475    var tier2 = Math.max(0, Math.min(capped02 - 360, 1440));
476    var gz02Str = yr1.gz > 0
477      ? (tier2 > 0
478        ? '50\u00a0%\u00a0\u00d7\u00a0' + new Intl.NumberFormat('de-DE').format(tier1)
479        + '\u00a0\u20ac\u00a0+\u00a025\u00a0%\u00a0\u00d7\u00a0' + new Intl.NumberFormat('de-DE').format(tier2) + '\u00a0\u20ac'
480        : '50\u00a0%\u00a0\u00d7\u00a0' + new Intl.NumberFormat('de-DE').format(tier1) + '\u00a0\u20ac')
481      : '\u2013';
482    setAll('.cs-grundzulage-calc', gz02Str);
483    // Bonus calc label
484    setAll('.cs-bonus-calc', bonus200 ? 'Einmalig\u00a0200\u00a0\u20ac zu Beginn' : '\u2013');
485
486    // ── Section 03 attrs ──
487    // Eigene Einzahlungen total: annual × years
488    setAll('.cs-eigen-years-calc',
489      new Intl.NumberFormat('de-DE',{maximumFractionDigits:0}).format(monthly * 12) + '\u00a0\u20ac\u00a0\u00d7\u00a0' + years + '\u00a0Jahre');
490    // Staatliche Zulagen detailed breakdown
491    var retireYear03 = DEPOT_START + years;
492    var zulParts03 = [];
493    // Grundzulage: yr1.gz × years (gz is same every year since contribution fixed)
494    if (yr1.gz > 0) {
495      var gzTotal = Math.round(yr1.gz * years);
496      zulParts03.push(
497        'Grundzulage ('+years+'\u00a0J.\u00a0\u00d7\u00a0'
498        + new Intl.NumberFormat('de-DE').format(Math.round(yr1.gz))
499        + '\u00a0\u20ac/J.\u00a0=\u00a0'
500        + new Intl.NumberFormat('de-DE').format(gzTotal) + '\u00a0\u20ac)'
501      );
502    }
503    // Per-child Kinderzulage totals
504    children.forEach(function (c, ci) {
505      if (!c.by) return;
506      var wS = Math.max(DEPOT_START, c.by);
507      var wE = Math.min(retireYear03, c.by + c.ta);
508      var eY = Math.max(0, wE - wS);
509      if (eY > 0) {
510        zulParts03.push(
511          'Kinderzulage Kind\u00a0' + (ci + 1)
512          + '\u00a0(' + eY + '\u00a0J.\u00a0\u00d7\u00a0'
513          + new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(Math.min(monthly * 12, KZ_MAX))
514          + '\u00a0\u20ac/J.\u00a0=\u00a0'
515          + new Intl.NumberFormat('de-DE').format(eY * Math.min(monthly * 12, KZ_MAX)) + '\u00a0\u20ac)'
516        );
517      }
518    });
519    if (bonus200) zulParts03.push('Berufseinsteiger-Bonus (einmalig\u00a0200\u00a0\u20ac)');
520    setAll('.cs-zulagen-detail-calc',
521      zulParts03.length > 0 ? zulParts03.join('\u00a0+\u00a0') : '\u2013');
522    // Förderquote formula
523    setAll('.cs-foerderquote-calc', monthly * 12 > 0
524      ? fmt(acc.tZ).trim() + '\u00a0/\u00a0' + fmt(acc.tO).trim()
525      : '\u2013');
526    // Steuerersparnis label
527    setAll('.cs-steuersparnis-calc', years > 0
528      ? 'Gesch\u00e4tzte Erstattung \u00fcber\u00a0' + years + '\u00a0Jahre\u00a0(' + txPct + '\u00a0% Steuersatz)'
529      : '\u2013');
530
531    // ── Section 04 attrs ──
532    // retaxCapital and netCapital already computed above (before capital outputs)
533    setAll('.cs-payout-method', 'inkl. Zinseszins auf Zulagen');
534    setAll('.cs-self-pct-formula',
535      fmt(acc.tO).trim() + '\u00a0\u00f7\u00a0' + fmt(acc.cap).trim());
536    setAll('.cs-self-pct-pct',
537      'ca.\u00a0' + (acc.cap > 0 ? Math.round(acc.tO / acc.cap * 100) : 0) + '\u00a0%');
538    setAll('.cs-retax-formula',
539      'Prognostiziertes Brutto-Verm\u00f6gen\u00a0(' + fmt(acc.cap).trim()
540      + ')\u00a0\u00d7\u00a0Rentensteuersatz\u00a0(' + retaxPctActual + '\u00a0%)');
541    setEuro('.cs-retax-amount', retaxCapital);
542    setAll('.cs-netto-formula',
543      'Prognostiziertes Brutto-Verm\u00f6gen\u00a0(' + fmt(acc.cap).trim()
544      + ')\u00a0\u2013\u00a0Rentenbesteuerung\u00a0(' + fmt(retaxCapital).trim() + ')');
545
546    // ── Section 05 attrs that don't need etf (moved here) ──
547    setAll('.cs-msci-eigen-calc',
548      new Intl.NumberFormat('de-DE',{maximumFractionDigits:0}).format(monthly * 12)
549      + '\u00a0\u20ac\u00a0\u00d7\u00a0' + years + '\u00a0Jahre');
550    setAll('.cs-msci-ertrag-label',
551      'Wertzuwachs inkl. Zinseszinseffekt \u00fcber\u00a0' + years + '\u00a0Jahren'
552      + ' mit\u00a0' + (retRate * 100).toLocaleString('de-DE', {minimumFractionDigits: 1})
553      + '\u00a0% j\u00e4hrlicher Nettorendite');
554    // cs-msci-vap-neg and cs-msci-liq-tax-neg are set AFTER etf is computed below
555
556    // ── Legacy compat (kept for PDF/other uses) ──
557    setAll('.cs-eigen-calc', fmt(monthly) + '\u00a0\u00d7 12 \u00d7 ' + years + '\u00a0J.');
558    setAll('.cs-retax-applied-calc', 'Brutto \u00d7 (1 \u2212 ' + retaxPctActual + '\u00a0%)');
559    setAll('.cs-retax-monthly-calc', 'Brutto \u00d7 (1 \u2212 ' + retaxPctActual + '\u00a0%)');
560    setAll('.cs-zul-calc', yr1.zul > 0
561      ? fmt(yr1.gz).trim() + '\u00a0GZ'
562      + (yr1.kz > 0 ? '\u00a0+\u00a0' + fmt(yr1.kz).trim() + '\u00a0KZ' : '')
563      : '\u2013');
564
565    // ── Update Section 02 dynamic per-child rows ──
566    updateKinderSection02();
567
568    // ── Comparison ──────────────────────────────────────────────────────────
569    // ETF:       Vorabpauschale annually + Abgeltungssteuer at liquidation (Frey method)
570    // Sparkonto: Abgeltungssteuer on annual interest (Frey method)
571    // Both: no additional income tax during payout (taxes already paid)
572    var msciReturn  = retRate;                   // gross → buildETFCapital deducts ETF_KOSTEN internally
573    var msciPayRate = retRate - ETF_KOSTEN;      // net rate for payout annuity (costs continue)
574    var bankPayRate = BANK_RATE;                 // Sparkonto: no cost deduction
575    var etf         = buildETFCapital(years, monthly * 12, msciReturn, abgst);
576    var bank        = buildBankCapital(years, monthly * 12, BANK_RATE);
577    var msciPension = calcPension(etf.cap,    msciPayRate, 0); // netto PMT from netto capital
578    var msciPensionB= calcPension(etf.brutto, msciPayRate, 0); // brutto PMT from brutto capital
579    var bankPension = calcPension(bank.cap,   bankPayRate, 0); // netto PMT
580    var bankPensionB= calcPension(bank.brutto,bankPayRate, 0); // brutto PMT
581
582    setAll('.cs-monthly-contrib', Math.round(monthly));
583    setAll('.cs-laufzeit',        years);
584    setAll('.cs-rendite-pct',     (retRate * 100).toLocaleString('de-DE', { minimumFractionDigits: 1 }) + '\u00a0%');
585
586    // ── AVR comparison row ──
587    setEuro('.cmp-avr-cap',      acc.cap);
588    setEuro('.cmp-avr-mon',      p.b);
589
590    // ── MSCI World / ETF ──
591    setEuro('.cmp-msci-cap',          etf.cap);        // Endkapital netto (Berechnungsdetails)
592    setEuro('.cmp-msci-gross',        etf.brutto);     // Endkapital brutto (Vergleich, matches XLS)
593    setEuro('.cmp-msci-mon',          msciPensionB.b);  // Monatliche Rente brutto
594    setEuro('.cmp-msci-gross-fv',     etf.grossFV);    // Future Value brutto (kein Steuer)
595    setEuro('.cmp-msci-ertrag-gross', etf.ertragGross); // Ertrag brutto (Zinseszins)
596    setEuro('.cmp-msci-brutto',       etf.brutto);     // Endkapital nach VAP
597    setEuro('.cmp-msci-ertrag',       etf.ertrag);      // Kapitalertrag = depot - eigenBeiträge
598    setEuro('.cmp-msci-vap-sum',      etf.cumVAP);     // VAP kumuliert
599    setEuro('.cmp-msci-vap-tax',      etf.cumVAPTax);  // Steuer auf VAP
600    setEuro('.cmp-msci-taxable',      etf.taxableGain); // Steuerpfl. Auflösungsgewinn
601    setEuro('.cmp-msci-liq-tax',      etf.liqTax);     // Abgeltungssteuer Auflösung
602    setEuro('.cmp-msci-total-tax',    etf.totalTax);   // Steuern gesamt
603
604    // ── Sparkonto ──
605    setEuro('.cmp-bank-cap',          bank.cap);       // Endkapital netto (Berechnungsdetails)
606    setEuro('.cmp-bank-gross',        bank.brutto);    // Endkapital brutto (Vergleich, matches XLS)
607    setEuro('.cmp-bank-mon',          bankPensionB.b);  // Monatliche Rente brutto
608    setEuro('.cmp-bank-gross-fv',     bank.grossFV);   // Endkapital brutto (kein Steuer)
609    setEuro('.cmp-bank-ertrag-gross', bank.ertragGross); // Ertrag brutto
610    setEuro('.cmp-bank-ertrag',       bank.ertrag);    // Ertrag netto
611    setEuro('.cmp-bank-abgst',        -bank.cumTax);   // Abgeltungssteuer gesamt (negativ)
612
613    // ── Section 06: Sparkonto Berechnung labels (dynamic) ──
614    var fmtN = function(n) { return new Intl.NumberFormat('de-DE',{maximumFractionDigits:0}).format(n); };
615    setAll('.cs-bank-eigen-calc',
616      fmtN(monthly * 12) + '\u00a0\u20ac\u00a0\u00d7\u00a0' + years + '\u00a0Jahre');
617    setAll('.cs-bank-ertrag-label',
618      'Wertzuwachs inkl. Zinseszinseffekt \u00fcber\u00a0' + years + '\u00a0Jahren'
619      + ' mit\u00a02,0\u00a0% j\u00e4hrlicher Rendite');
620    setAll('.cs-bank-abgst-label',
621      'Zinsen werden bei Gutschrift mit Abgeltungssteuer i.H.v.\u00a025\u00a0% sowie'
622      + ' Solidarit\u00e4tszuschlag (insgesamt 26,375\u00a0%) versteuert.'
623      + ' Die tats\u00e4chlich anfallende Steuer kann je nach pers\u00f6nlicher'
624      + ' steuerlicher Situation abweichen.');
625    setAll('.cs-bank-netto-label',
626      'Endverm\u00f6gen (Einzahlungen\u00a0+\u00a0Zinsertrag)'
627      + '\u00a0\u2013\u00a0j\u00e4hrlich anfallende Abgeltungssteuer bei Zinszahlungen');
628
629    // ── AVR advantage ──
630    setEuro('.cmp-avr-vs-msci',  acc.cap - etf.cap);
631
632    // ── Section 05: etf-dependent Berechnung attrs (must be after etf is computed) ──
633    setAll('.cs-msci-vap-neg',
634      etf.cumVAP > 0 ? '\u2212\u00a0' + fmt(etf.cumVAP) : '0\u00a0\u20ac');
635    setAll('.cs-msci-liq-tax-neg',
636      etf.liqTax > 0 ? '\u2212\u00a0' + fmt(etf.liqTax) : '0\u00a0\u20ac');
637    setAll('.cs-msci-vap-label',
638      'Vorabpauschale kumuliert unter Ber\u00fccksichtigung der Teilfreistellung\u00a0'
639      + fmt(acc.tZ).trim() + '\u00a0/\u00a0' + fmt(acc.tO).trim());
640    setAll('.cs-msci-liq-label',
641      'Kursgewinne von regul\u00e4ren ETFs werden\u00a0\u2013 nach Abzug der 30\u00a0% Teilfreistellung\u00a0\u2013 bei Ver\u00e4u\u00dferung mit Abgeltungssteuer i.H.v.\u00a025\u00a0% sowie Solidarit\u00e4tszuschlag (insgesamt 26,375\u00a0%) versteuert. Die tats\u00e4chlich anfallende Steuer kann je nach pers\u00f6nlicher steuerlicher Situation abweichen. Hierbei wird die bereits gezahlte Vorabpauschale ber\u00fccksichtigt und f\u00e4llt nicht doppelt an.');
642    setAll('.cs-msci-netto-label',
643      'Endverm\u00f6gen (Einzahlungen\u00a0+\u00a0Ertrag) \u2013 Vorabpauschale \u2013 Abgeltungssteuer bei Verkauf');
644
645    // ── Chart aria descriptions ──
646    updateChartAria(age, monthly, acc, growth);
647
648    // ── Charts ──
649    var lbl = [], dO = [], dO2 = [], dT = [];
650    for (var i = 0; i < years; i++) {
651      lbl.push(age + i + 1);
652      dO.push(acc.own[i]);
653      dO2.push(acc.own[i] + acc.zul[i]);
654      dT.push(acc.bal[i]);
655    }
656    lastChartData = { lbl: lbl, dO: dO, dO2: dO2, dT: dT };
657    renderChart(lbl, dO, dO2, dT);
658    if (root().classList.contains('is-view2')) {
659      renderChart2(lbl, dO, dO2, dT);
660    }
661
662    updateKinderDetail();
663
664    window.AVR_STATE = {
665      sparrate:        monthly,
666      alter:           age,
667      einkommen: bruttoJahr,
668      rendite:         parseFloat((retRate * 100).toFixed(1)),
669      kinder_geboren:  children
670        .map(function(c) { return c.by; })
671        .filter(function(by) { return by !== null; })
672        .join(',')
673    };
674  }
675
676  /* ─────────────────────────────────────────────────────────
677   * 6. Charts
678   * ───────────────────────────────────────────────────────── */
679  function makeDataset(label, data, color, fill) {
680    return { label: label, data: data, borderColor: color, backgroundColor: color,
681      borderWidth: 1.5, fill: fill, tension: 0.35, pointRadius: 0 };
682  }
683
684  function makeChartOptions() {
685    return {
686      responsive: true,
687      maintainAspectRatio: false,
688      interaction: { mode: 'index', intersect: false },
689      plugins: {
690        legend: {
691          display: true, position: 'bottom', align: 'end',
692          labels: {
693            boxWidth: 10,
694            boxHeight: 10,
695            borderRadius: 5,
696            font: {
697              size: 12,
698              family: 'Aeonik, sans-serif'
699            },
700            color: '#080707',
701            padding: 16,
702            useBorderRadius: true
703          }
704        },
705        tooltip: {
706          titleFont: {
707            family: 'Aeonik, sans-serif',
708            size: 12,
709            weight: '700'
710          },
711          bodyFont: {
712            family: 'Aeonik, sans-serif',
713            size: 11
714          },
715          callbacks: {
716            label: function (ctx) {
717              var fmt = function(v) {
718                return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(v) + '\u00a0\u20ac';
719              };
720              // 'Zulagen' dataset uses stacked values (own + zulagen).
721              // Show only the actual Zulagen portion by subtracting the
722              // 'Einzahlung' value at the same index.
723              if (ctx.dataset.label === 'Zulagen') {
724                var chart    = ctx.chart;
725                var einzMeta = chart.getDatasetMeta(0);
726                var einzVal  = einzMeta.hidden ? 0 : chart.data.datasets[0].data[ctx.dataIndex];
727                var zulagenOnly = ctx.parsed.y - einzVal;
728                return ' Zulagen: ' + fmt(zulagenOnly);
729              }
730              return ' ' + ctx.dataset.label + ': ' + fmt(ctx.parsed.y);
731            }
732          }
733        }
734      },
735      scales: {
736        x: {
737          grid: { display: false },
738          ticks: {
739            font: { size: 12, family: 'Aeonik, sans-serif' },
740            color: '#080707',
741            maxRotation: 0,
742            minRotation: 0,
743            callback: function (value) {
744              return this.getLabelForValue(value) + '\u00a0J';
745            }
746          }
747        },
748        y: {
749          border: { display: false },
750          ticks: {
751            font: { size: 12, family: 'Aeonik, sans-serif' },
752            color: '#080707',
753            maxRotation: 0,
754            minRotation: 0,
755            callback: function (v) {
756              return new Intl.NumberFormat('de-DE', { notation: 'compact' }).format(v) + '\u00a0\u20ac';
757            }
758          }
759        }
760      }
761    };
762  }
763
764  function updateChartAria(age, monthly, acc, growth) {
765    var years = RETIRE_AGE - age;
766    var fmtK = function (n) {
767      return new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(Math.round(n));
768    };
769    var title = 'Vermögensentwicklung von Alter ' + age + ' bis ' + RETIRE_AGE;
770    var eigenZulagen = acc.tO + acc.tZ;
771    var summary =
772      'Bei einer monatlichen Sparrate von ' + Math.round(monthly) + ' € '
773      + 'wächst das prognostizierte Brutto-Endvermögen '
774      + 'über ' + years + ' Jahre auf rund ' + fmtK(acc.cap) + ' €. '
775      + 'Davon entfallen ' + fmtK(eigenZulagen) + ' € auf eigene Einzahlungen und staatliche Zulagen '
776      + 'sowie ' + fmtK(growth) + ' € auf Kapitalwachstum.';
777
778    ['growthChart-title', 'growthChart2-title'].forEach(function (id) {
779      var el = document.getElementById(id);
780      if (el) el.textContent = title;
781    });
782    ['growthChart-summary', 'growthChart2-summary'].forEach(function (id) {
783      var el = document.getElementById(id);
784      if (el) el.textContent = summary;
785    });
786  }
787
788  function renderChart(lbl, dO, dO2, dT) {
789    var canvas = $id('growthChart');
790    if (!canvas) return;
791    if (gc) gc.destroy();
792    gc = new Chart(canvas.getContext('2d'), {
793      type: 'line',
794      data: { labels: lbl, datasets: [
795          makeDataset('Einzahlung',            dO,  '#F9BC86', 'origin'),
796          makeDataset('Zulagen',               dO2, '#DB500B', '-1'),
797          makeDataset('Gesamtverm\u00f6gen',   dT,  '#F47323', '-1')
798        ]},
799      options: makeChartOptions()
800    });
801  }
802
803  function renderChart2(lbl, dO, dO2, dT) {
804    var canvas = document.getElementById('growthChart2');
805    if (!canvas) return;
806    if (gc2) { gc2.destroy(); gc2 = null; }
807    gc2 = new Chart(canvas.getContext('2d'), {
808      type: 'line',
809      data: { labels: lbl, datasets: [
810          makeDataset('Einzahlung',            dO,  '#F9BC86', 'origin'),
811          makeDataset('Zulagen',               dO2, '#DB500B', '-1'),
812          makeDataset('Gesamtverm\u00f6gen',   dT,  '#F47323', '-1')
813        ]},
814      options: makeChartOptions()
815    });
816  }
817
818  /* ─────────────────────────────────────────────────────────
819   * 7. Kinder UI
820   * ───────────────────────────────────────────────────────── */
821  function renderKinder() {
822    var count  = $id('kinder-count');
823    var btnMin = $id('kinder-minus');
824    var btnPls = $id('kinder-plus');
825    var hint   = $id('kinder-step-hint');
826    var yWrap  = root().querySelector('.kinder-years-wrap');
827    var yList  = $id('kinder-years-list');
828    if (!yList) return;
829
830    if (count)  count.textContent  = children.length;
831    if (btnMin) btnMin.disabled    = children.length === 0;
832    if (btnPls) btnPls.disabled    = children.length >= MAX_KINDER;
833    if (hint)   hint.textContent   = children.length >= MAX_KINDER ? 'Maximum (6) erreicht' : '';
834    if (yWrap)  yWrap.classList.toggle('visible', children.length > 0);
835
836    var existing = yList.querySelectorAll('.kinder-year-input');
837    var diff = children.length - existing.length;
838
839    if (diff > 0) {
840      for (var i = 0; i < diff; i++) {
841        var kindIdx = existing.length + i;
842        var wrap = document.createElement('div');
843        wrap.className = 'kinder-year-input-wrap';
844
845        var lbl = document.createElement('span');
846        lbl.className   = 'kinder-kind-label';
847        lbl.textContent = 'Kind ' + (kindIdx + 1);
848
849        var inp = document.createElement('input');
850        inp.type        = 'text';
851        inp.inputMode   = 'numeric';
852        inp.pattern     = '[0-9]*';
853        inp.className   = 'kinder-year-input';
854        inp.placeholder = 'Geburtsjahr';
855        inp.min         = BIRTH_MIN;
856        inp.max         = BIRTH_MAX;
857        inp.dataset.idx = kindIdx;
858        inp.setAttribute('aria-label', 'Geburtsjahr Kind ' + (kindIdx + 1));
859
860        var err = document.createElement('div');
861        err.className = 'kinder-year-error';
862
863        // Kindergeld always bis 25 (KINDER_MAX_ALTER) — no toggle
864        wrap.appendChild(lbl);
865        wrap.appendChild(inp);
866        wrap.appendChild(err);
867        yList.appendChild(wrap);
868      }
869    } else if (diff < 0) {
870      var rows = yList.querySelectorAll('.kinder-year-input-wrap');
871      for (var i = rows.length - 1; i >= children.length; i--) {
872        rows[i].remove();
873      }
874    }
875
876    // Sync birth years, labels and toggle states
877    yList.querySelectorAll('.kinder-year-input').forEach(function (inp, i) {
878      inp.dataset.idx = i;
879      if (children[i] && children[i].by && !inp.matches(':focus')) {
880        inp.value = children[i].by;
881      }
882      var lbl = inp.previousElementSibling;
883      if (lbl && lbl.classList.contains('kinder-kind-label')) {
884        lbl.textContent = 'Kind ' + (i + 1);
885      }
886      // ta always KINDER_MAX_ALTER — no toggle buttons to sync
887    });
888  }
889
890  /* ─────────────────────────────────────────────────────────
891   * 8. updateKinderDetail()
892   * Populates section 06 in Berechnungsdetails with per-child
893   * Kinderzulage info: birth year, age at depot start, until
894   * which year Kindergeld is paid, and how many years qualify.
895   * ───────────────────────────────────────────────────────── */
896  function updateKinderDetail() {
897    var container = document.getElementById('cs-kinder-detail');
898    if (!container) return;
899
900    var age          = parseInt($id('age').value, 10);
901    var retireYear   = DEPOT_START + (RETIRE_AGE - age); // year user turns 67
902
903    if (children.length === 0) {
904      container.innerHTML =
905        '<div class="calc-row">' +
906        '<span class="calc-row-label">Kinder</span>' +
907        '<span class="calc-row-value">Keine Kinder eingetragen \u2013 keine Kinderzulage</span>' +
908        '</div>';
909      return;
910    }
911
912    // Fixed-rate info row
913    var html =
914      '<div class="calc-row">' +
915      '<span class="calc-row-label">Kinderzulage p.a.</span>' +
916      '<span class="calc-row-value accent">bis zu 300\u00a0\u20ac pro Kind / Jahr \u2013 1:1 Sparrate (max. 300\u00a0\u20ac/J. bei \u2265\u00a025\u00a0\u20ac/Monat)</span>' +
917      '</div>';
918
919    children.forEach(function (c, i) {
920      var kindLabel = 'Kind\u00a0' + (i + 1);
921
922      if (!c.by) {
923        html +=
924          '<div class="calc-row">' +
925          '<span class="calc-row-label">' + kindLabel + '</span>' +
926          '<span class="calc-row-value">Geburtsjahr noch nicht eingetragen</span>' +
927          '</div>';
928        return;
929      }
930
931      var kindergeldBisJahr = c.by + c.ta; // e.g. 2010 + 18 = 2028
932      var ageAt2027         = DEPOT_START - c.by; // age the child has in 2027
933
934      // Eligible window within the depot/savings period
935      var windowStart = Math.max(DEPOT_START, c.by);       // first year child exists & depot runs
936      var windowEnd   = Math.min(retireYear, kindergeldBisJahr); // earlier of retirement or end of Kindergeld
937      var eligibleYears = Math.max(0, windowEnd - windowStart);
938
939      // Human-readable age-at-depot-start
940      var ageStr;
941      if (ageAt2027 < 0) {
942        ageStr = 'wird ' + c.by + ' geboren (nach Depot-Start)';
943      } else if (ageAt2027 === 0) {
944        ageStr = 'wird 2027 geboren';
945      } else {
946        ageStr = ageAt2027 + (ageAt2027 === 1 ? '\u00a0Jahr' : '\u00a0Jahre') + ' alt in 2027';
947      }
948
949      // Time frame label
950      var rangeStr;
951      if (eligibleYears <= 0) {
952        rangeStr = 'Anspruch bereits vor 2027 abgelaufen';
953      } else {
954        rangeStr = windowStart + '\u00a0\u2013\u00a0' + (windowEnd - 1) +
955          '\u00a0(' + eligibleYears + (eligibleYears === 1 ? '\u00a0Jahr' : '\u00a0Jahre') + ')';
956      }
957
958      html +=
959        '<div class="calc-row">' +
960        '<span class="calc-row-label">' + kindLabel + '</span>' +
961        '<span class="calc-row-value">' +
962        'Geb.\u00a0' + c.by +
963        '\u00a0\u00b7\u00a0' + ageStr +
964        '\u00a0\u00b7\u00a0Kindergeld bis ' + c.ta + '\u00a0J.\u00a0(' + kindergeldBisJahr + ')' +
965        '\u00a0\u00b7\u00a0Zulage\u00a0' + rangeStr +
966        '</span>' +
967        '</div>';
968    });
969
970    container.innerHTML = html;
971  }
972
973  /* ─────────────────────────────────────────────────────────
974   * 9b. updateKinderSection02()
975   * Populates section 02 "Deine Förderung" with per-child
976   * Kinderzulage rows (label, formula, value) and toggles
977   * the bonus row visibility.
978   * ───────────────────────────────────────────────────────── */
979  function updateKinderSection02() {
980    var container = document.getElementById('cs-foerderung-kinder');
981    if (!container) return;
982
983    var age02      = parseInt($id('age').value, 10);
984    var monthly02  = parseFloat($id('contrib').value);
985    var kzPerYear  = Math.min(monthly02 * 12, KZ_MAX); // 1:1 match, capped at 300 €/yr
986    var retYear02  = DEPOT_START + (RETIRE_AGE - age02);
987    var html       = '';
988
989    children.forEach(function (c, i) {
990      if (!c.by) return;
991      var wS = Math.max(DEPOT_START, c.by);
992      var wE = Math.min(retYear02, c.by + c.ta);
993      var eY = Math.max(0, wE - wS);
994      if (eY <= 0) return; // not eligible in depot period
995
996      var rangeLabel  = wS + ' – ' + (wE - 1);
997      var kzFormatted = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(kzPerYear);
998      var calcLabel   = kzPerYear < KZ_MAX
999        ? new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }).format(monthly02 * 12)
1000        + ' €/J. (1:1 Sparrate, unter 300 €/J.)'
1001        : '100 % × 300 €';
1002      html +=
1003        '<div class="calc-row">'
1004        + '<span class="calc-row-label">Kinderzulage für Kind ' + (i + 1)
1005        + ' (Zeitraum ' + rangeLabel + ')</span>'
1006        + '<span class="calc-row-calc">' + calcLabel + '</span>'
1007        + '<span class="calc-row-value">' + kzFormatted + ' €/Jahr</span>'
1008        + '</div>';
1009    });
1010
1011    container.innerHTML = html;
1012  }
1013
1014  /* ─────────────────────────────────────────────────────────
1015   * 9. init()
1016   * ───────────────────────────────────────────────────────── */
1017  function init() {
1018    var r = document.querySelector('.avr');
1019    if (!r || r.dataset.init) return;
1020    r.dataset.init = '1';
1021
1022    // ── Sliders ──
1023    [
1024      { id: 'age',    cls: '.v-age',    dec: 0 },
1025      { id: 'contrib',cls: '.v-contrib',dec: 0 },
1026      { id: 'retax',  cls: '.v-retax',  dec: 0 }
1027    ].forEach(function (cfg) {
1028      var el = $id(cfg.id);
1029      paintSlider(el);
1030      el.addEventListener('input', function () {
1031        paintSlider(el);
1032        syncSliderDisplay(el, cfg.cls, cfg.dec);
1033        calc();
1034      });
1035    });
1036
1037    // ── Steppers (contrib, age) ──
1038    [
1039      { minusId: 'contrib-minus', plusId: 'contrib-plus', sliderId: 'contrib', cls: '.v-contrib', min: 10,  max: 570, step: 5 },
1040      { minusId: 'age-minus',     plusId: 'age-plus',     sliderId: 'age',     cls: '.v-age',     min: 18,  max: 64,  step: 1 }
1041    ].forEach(function (cfg) {
1042      var sliderEl = $id(cfg.sliderId);
1043      if (!sliderEl) return;
1044
1045      var step = function (dir) {
1046        var val = parseFloat(sliderEl.value) + dir * cfg.step;
1047        val = Math.min(cfg.max, Math.max(cfg.min, val));
1048        sliderEl.value = val;
1049        paintSlider(sliderEl);
1050        $all(cfg.cls).forEach(function (el) { el.textContent = Math.round(val); });
1051        calc();
1052      };
1053
1054      var minusBtn = $id(cfg.minusId);
1055      var plusBtn  = $id(cfg.plusId);
1056      if (minusBtn) minusBtn.addEventListener('click', function () { step(-1); });
1057      if (plusBtn)  plusBtn.addEventListener('click',  function () { step(+1); });
1058    });
1059
1060    // ── Tax buttons ──
1061    root().addEventListener('click', function (e) {
1062      var btn = e.target.closest('[data-action="tax"]');
1063      if (!btn) return;
1064      $all('.tax-btn, .v1-tax-btn').forEach(function (b) {
1065        b.classList.remove('active');
1066        b.setAttribute('aria-pressed', 'false');
1067      });
1068      btn.classList.add('active');
1069      btn.setAttribute('aria-pressed', 'true');
1070      $id('taxrate').value = btn.dataset.tax;
1071      var retaxPct = Math.max(0, Math.round(parseFloat(btn.dataset.tax) * 100) - 10);
1072      var retaxEl  = $id('retax');
1073      retaxEl.value = retaxPct;
1074      paintSlider(retaxEl);
1075      syncSliderDisplay(retaxEl, '.v-retax', 0);
1076      calc();
1077    });
1078
1079    // ── Rendite pills ──
1080    $all('.v1-ret-pill').forEach(function (pill) {
1081      pill.addEventListener('click', function () {
1082        var preset = pill.dataset.preset;
1083        $id('ret').value = preset;
1084        $all('.v-return').forEach(function (el) {
1085          el.textContent = parseFloat(preset).toLocaleString('de-DE', { minimumFractionDigits: 1 });
1086        });
1087        $all('.v1-ret-pill, .ret-preset-btn').forEach(function (b) {
1088          b.classList.remove('active');
1089          b.setAttribute('aria-pressed', 'false');
1090        });
1091        $all('.v1-ret-pill[data-preset="' + preset + '"]').forEach(function (b) {
1092          b.classList.add('active');
1093          b.setAttribute('aria-pressed', 'true');
1094        });
1095        calc();
1096      });
1097    });
1098
1099    // ── Rendite buttons (legacy) ──
1100    $all('.ret-preset-btn').forEach(function (btn) {
1101      btn.dataset.preset = parseFloat(btn.textContent);
1102      btn.addEventListener('click', function () {
1103        var preset = btn.dataset.preset;
1104        $id('ret').value = preset;
1105        $all('.v-return').forEach(function (el) {
1106          el.textContent = parseFloat(preset).toLocaleString('de-DE', { minimumFractionDigits: 1 });
1107        });
1108        $all('.ret-preset-btn').forEach(function (b) { b.classList.remove('active'); });
1109        btn.classList.add('active');
1110        calc();
1111      });
1112    });
1113
1114    // ── Kinder stepper ──
1115    $id('kinder-plus').addEventListener('click', function () {
1116      if (children.length < MAX_KINDER) {
1117        children.push({ by: null, ta: KINDER_MAX_ALTER });
1118        renderKinder();
1119        calc();
1120      }
1121    });
1122    $id('kinder-minus').addEventListener('click', function () {
1123      if (children.length > 0) {
1124        children.pop();
1125        renderKinder();
1126        calc();
1127      }
1128    });
1129
1130    // Kindergeld toggle removed — always bis 25
1131
1132
1133    // ── Birth-year input validation ──
1134    $id('kinder-years-list').addEventListener('input', function (e) {
1135      var inp = e.target.closest('.kinder-year-input');
1136      if (!inp) return;
1137      var idx = parseInt(inp.dataset.idx, 10);
1138      var raw = inp.value.replace(/[^0-9]/g, '');
1139      inp.value = raw;
1140      var err = inp.nextElementSibling;
1141      var yr  = parseInt(raw, 10);
1142      if (!raw) {
1143        inp.classList.remove('invalid');
1144        err.textContent = '';
1145        err.classList.remove('visible');
1146        return;
1147      }
1148      if (isNaN(yr) || yr < BIRTH_MIN || yr > BIRTH_MAX) {
1149        inp.classList.add('invalid');
1150        err.textContent = yr > BIRTH_MAX
1151          ? 'Geburtsjahr darf nicht nach ' + BIRTH_MAX + ' liegen.'
1152          : 'Geburtsjahr darf nicht vor ' + BIRTH_MIN + ' liegen.';
1153        err.classList.add('visible');
1154      } else {
1155        if (children[idx]) children[idx].by = yr;
1156        var ageAtDepot = DEPOT_START - yr;
1157        var currentTa = children[idx] ? children[idx].ta : KINDER_MAX_ALTER;
1158        if (ageAtDepot >= currentTa) {
1159          inp.classList.add('invalid');
1160          err.textContent = ageAtDepot >= 25
1161            ? 'Kind ist ' + ageAtDepot + ' J. bei Depot-Start – kein Kindergeldanspruch möglich.'
1162            : 'Kind ist ' + ageAtDepot + ' J. bei Depot-Start – bitte „Kindergeld bis 25“ wählen.';
1163          err.classList.add('visible');
1164        } else {
1165          inp.classList.remove('invalid');
1166          err.textContent = '';
1167          err.classList.remove('visible');
1168        }
1169        calc();
1170      }
1171    });
1172
1173    // ── PDF button ──
1174    $id('pdf-btn').addEventListener('click', window.AVR_PDF.generatePDF);
1175
1176    // ── E-Mail modal ──
1177    var emailBtn = document.getElementById('email-btn');
1178    if (emailBtn) emailBtn.addEventListener('click', window.AVR_MODAL.openEmailModal);
1179    var modalClose = document.getElementById('avr-modal-close');
1180    if (modalClose) modalClose.addEventListener('click', window.AVR_MODAL.closeEmailModal);
1181    var modalOverlay = document.getElementById('avr-email-modal');
1182    if (modalOverlay) modalOverlay.addEventListener('click', function (e) {
1183      if (e.target === this) window.AVR_MODAL.closeEmailModal();
1184    });
1185    var emailForm = document.getElementById('avr-email-form');
1186    if (emailForm) emailForm.addEventListener('submit', window.AVR_MODAL.submitEmailForm);
1187
1188    // ── View switching ──
1189    $id('cta-btn').addEventListener('click',  function () { showView(2); });
1190    $id('back-btn').addEventListener('click', function () { showView(1); });
1191
1192    // ── Accordion ──
1193    function toggleAccordionHeader(hdr) {
1194      var acc = hdr.closest('.accordion');
1195      if (!acc) return;
1196      var isOpen = acc.classList.toggle('open');
1197      hdr.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
1198    }
1199    document.addEventListener('click', function (e) {
1200      var hdr = e.target.closest('.accordion-header');
1201      if (hdr) toggleAccordionHeader(hdr);
1202    });
1203    document.addEventListener('keydown', function (e) {
1204      if (e.key === 'Enter' && e.target.type === 'checkbox') {
1205        e.preventDefault();
1206        e.target.checked = !e.target.checked;
1207        e.target.dispatchEvent(new Event('change', { bubbles: true }));
1208        return;
1209      }
1210      if (e.key !== 'Enter' && e.key !== ' ') return;
1211      var hdr = e.target.closest('.accordion-header');
1212      if (hdr) {
1213        e.preventDefault();
1214        toggleAccordionHeader(hdr);
1215      }
1216    });
1217
1218    // ── Berechnungsdetails: per-section mobile accordion ──
1219    // On mobile (< 1024px) each calc-section header toggles its body open/closed.
1220    // On desktop the bodies are always visible via CSS (mq(large) { display: block }).
1221    $all('.calc-section-header-row').forEach(function (hdr) {
1222      hdr.addEventListener('click', function () {
1223        if (window.matchMedia('(max-width: 820px)').matches) {
1224          var section = hdr.closest('.calc-section');
1225          if (!section) return;
1226          var isOpen = section.classList.toggle('cs-open');
1227          var btn = section.querySelector('.calc-section-toggle');
1228          if (btn) btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
1229        }
1230      });
1231    });
1232
1233    // ── Initial state ──
1234    var activeBtn = root().querySelector('.v1-tax-btn.active');
1235    if (activeBtn) {
1236      var retaxInit = Math.max(0, Math.round(parseFloat(activeBtn.dataset.tax) * 100) - 10);
1237      var retaxEl   = $id('retax');
1238      retaxEl.value = retaxInit;
1239      paintSlider(retaxEl);
1240      syncSliderDisplay(retaxEl, '.v-retax', 0);
1241    }
1242
1243    // ── Sync slider display values on init (populates .v-age, .v-contrib etc.) ──
1244    [
1245      { id: 'age',    cls: '.v-age',    dec: 0 },
1246      { id: 'contrib',cls: '.v-contrib',dec: 0 }
1247    ].forEach(function (cfg) {
1248      var el = $id(cfg.id);
1249      if (el) syncSliderDisplay(el, cfg.cls, cfg.dec);
1250    });
1251
1252    // ── Mobile (≤820px): pre-open berechnungsdetails + riester ──
1253    if (window.matchMedia('(max-width: 820px)').matches) {
1254      ['acc-berechnungsdetails', 'acc-riester-vergleich'].forEach(function (id) {
1255        var el = $id(id);
1256        if (el) el.classList.add('open');
1257      });
1258    }
1259
1260    renderKinder();
1261    calc();
1262  }
1263
1264  // ── Debounced resize ──
1265  var resizeTimer = null;
1266  window.addEventListener('resize', function () {
1267    clearTimeout(resizeTimer);
1268    resizeTimer = setTimeout(function () {
1269      if (lastChartData.lbl.length) {
1270        renderChart(lastChartData.lbl, lastChartData.dO, lastChartData.dO2, lastChartData.dT);
1271        if (root().classList.contains('is-view2')) {
1272          renderChart2(lastChartData.lbl, lastChartData.dO, lastChartData.dO2, lastChartData.dT);
1273        }
1274      }
1275    }, 150);
1276  });
1277
1278  // ── Boot ──
1279  if (document.readyState === 'loading') {
1280    document.addEventListener('DOMContentLoaded', init);
1281  } else {
1282    init();
1283  }
1284
1285}());

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.