Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m22s Details

This commit is contained in:
Jeff Emmett 2026-04-06 18:31:15 -04:00
commit 218ee73993
1 changed files with 134 additions and 33 deletions

View File

@ -95,6 +95,9 @@ class FolkMortgageSimulator extends HTMLElement {
private playing = false;
private speed = 1;
private playInterval: ReturnType<typeof setInterval> | null = null;
private advancedMode = false;
private _listenersAttached = false;
private _rafId: number | null = null;
// Calculator state
private lenderInvestment = DEFAULT_CONFIG.trancheSize;
@ -116,6 +119,7 @@ class FolkMortgageSimulator extends HTMLElement {
this.space = this.getAttribute('space') || 'demo';
this._loadFromHash();
this._recompute();
this._attachListeners();
this.render();
}
@ -175,14 +179,19 @@ class FolkMortgageSimulator extends HTMLElement {
if (this.currentMonth >= this.maxMonth) {
this._stopPlayback();
this.playing = false;
this.render();
this._updatePlayBtn();
return;
}
this.currentMonth = Math.min(this.currentMonth + this.speed, this.maxMonth);
this.render();
this._updateView();
}, 100);
}
private _updatePlayBtn() {
const btn = this.shadow.querySelector('[data-action="play-toggle"]') as HTMLElement | null;
if (btn) btn.textContent = this.playing ? '\u23F8' : '\u25B6';
}
private _stopPlayback() {
if (this.playInterval) {
clearInterval(this.playInterval);
@ -217,17 +226,42 @@ class FolkMortgageSimulator extends HTMLElement {
${this._renderHeader(state, numTranches)}
<div class="layout">
${this._renderControls(numTranches)}
<main class="main">
<main class="main" id="viz">
${this._renderActiveView(state)}
${this._renderLenderDetail(state)}
</main>
<aside class="comparison">
<aside class="comparison" id="cmp">
${this._renderComparison(state)}
</aside>
</div>
</div>
`;
this._attachListeners();
}
/** Partial update: just viz + comparison + timeline (no controls rebuild) */
private _updateView() {
const state = this.currentState;
const viz = this.shadow.getElementById('viz');
const cmp = this.shadow.getElementById('cmp');
if (!viz || !cmp) { this.render(); return; }
viz.innerHTML = this._renderActiveView(state) + this._renderLenderDetail(state);
cmp.innerHTML = this._renderComparison(state);
this._syncTimeline();
}
/** Sync timeline slider + label without rebuilding controls */
private _syncTimeline() {
const scrub = this.shadow.querySelector('[data-action="month-scrub"]') as HTMLInputElement | null;
if (scrub && scrub !== this.shadow.activeElement) {
scrub.value = String(this.currentMonth);
scrub.max = String(this.maxMonth);
}
const label = this.shadow.getElementById('tl-label');
if (label) {
const y = Math.floor(this.currentMonth / 12);
const m = this.currentMonth % 12;
label.textContent = `Month ${this.currentMonth} (${y}y ${m}m)`;
}
}
// ─── Styles ───────────────────────────────────────────
@ -247,6 +281,9 @@ class FolkMortgageSimulator extends HTMLElement {
.view-tab.active { background: #0284c7; color: #fff; }
.view-tab:not(.active) { background: #1e293b; color: #64748b; }
.view-tab:not(.active):hover { background: #334155; color: #94a3b8; }
.mode-toggle { padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer; border: 1px solid #334155; transition: all 0.15s; margin-left: 8px; background: #1e293b; color: #64748b; font-weight: 600; }
.mode-toggle:hover { border-color: #475569; color: #94a3b8; }
.mode-toggle.advanced { background: #7c3aed; color: #fff; border-color: #7c3aed; }
/* Layout */
.layout { max-width: 1600px; margin: 0 auto; display: flex; gap: 0; min-height: calc(100vh - 57px); }
@ -446,6 +483,9 @@ class FolkMortgageSimulator extends HTMLElement {
${labels[mode]}
</button>
`).join('')}
<button class="mode-toggle ${this.advancedMode ? 'advanced' : ''}" data-action="toggle-advanced">
${this.advancedMode ? 'Advanced' : 'Basic'}
</button>
</div>
</header>
`;
@ -472,6 +512,7 @@ class FolkMortgageSimulator extends HTMLElement {
<div class="info-text">Loan: ${fmtCurrency(totalPrincipal)}</div>
</div>
${this.advancedMode ? `
<div class="section">
<div class="section-title">Tranches</div>
${this._slider('trancheSize', 'Size', c.trancheSize, 1000, 25000, 1000, fmtCurrency)}
@ -537,6 +578,7 @@ class FolkMortgageSimulator extends HTMLElement {
</div>
` : ''}
</div>
` : ''}
<div class="section">
<div class="section-title">Timeline</div>
@ -550,7 +592,7 @@ class FolkMortgageSimulator extends HTMLElement {
</div>
</div>
<input type="range" min="0" max="${this.maxMonth}" value="${this.currentMonth}" data-action="month-scrub">
<div class="timeline-label">Month ${this.currentMonth} (${years}y ${months}m)</div>
<div class="timeline-label" id="tl-label">Month ${this.currentMonth} (${years}y ${months}m)</div>
</div>
</div>
</aside>
@ -803,7 +845,7 @@ class FolkMortgageSimulator extends HTMLElement {
if (!lenderNodes.has(lid)) lenderNodes.set(lid, []);
lenderNodes.get(lid)!.push(i);
});
for (const [, indices] of lenderNodes) {
for (const [, indices] of Array.from(lenderNodes)) {
if (indices.length < 2) continue;
for (let j = 1; j < indices.length; j++) {
if (!relendLinks.some(l => (l.from === indices[0] && l.to === indices[j]) || (l.from === indices[j] && l.to === indices[0]))) {
@ -844,7 +886,7 @@ class FolkMortgageSimulator extends HTMLElement {
interface TierGroup { label: string; color: string; tranches: MortgageTranche[]; totalPrincipal: number; totalPrincipalRemaining: number; totalMonthlyPayment: number; totalMonthlyPrincipal: number; totalMonthlyInterest: number; activeCount: number; repaidCount: number; reinvestCount: number; reinvestPool: number; }
const tiers: TierGroup[] = [];
for (const [label, tranches] of groups) {
for (const [label, tranches] of Array.from(groups)) {
const active = tranches.filter(t => t.status === 'active');
const repaid = tranches.filter(t => t.status === 'repaid');
const reinvesting = tranches.filter(t => t.reinvestmentRate != null);
@ -1496,14 +1538,29 @@ class FolkMortgageSimulator extends HTMLElement {
// ─── Event handling ───────────────────────────────────
private _attachListeners() {
if (this._listenersAttached) return;
this._listenersAttached = true;
this.shadow.addEventListener('click', (e: Event) => {
const target = (e.target as HTMLElement).closest('[data-action]') as HTMLElement | null;
if (!target) return;
const action = target.dataset.action;
switch (action) {
case 'view':
case 'view': {
this.viewMode = target.dataset.view as ViewMode;
// Update tab highlights in-place
const tabs = this.shadow.querySelectorAll('.view-tab');
tabs.forEach((tab: Element) => {
const t = tab as HTMLElement;
t.className = `view-tab ${t.dataset.view === this.viewMode ? 'active' : ''}`;
});
this._updateView();
break;
}
case 'toggle-advanced':
this.advancedMode = !this.advancedMode;
this.render();
break;
@ -1515,20 +1572,27 @@ class FolkMortgageSimulator extends HTMLElement {
case 'play-toggle':
if (this.playing) { this._stopPlayback(); this.playing = false; }
else this._startPlayback();
this.render();
this._updatePlayBtn();
break;
case 'reset-month':
this.currentMonth = 0;
this._stopPlayback();
this.playing = false;
this.render();
this._updatePlayBtn();
this._updateView();
break;
case 'speed':
case 'speed': {
this.speed = Number(target.dataset.speed);
this.render();
// Update speed button highlights
const btns = this.shadow.querySelectorAll('[data-action="speed"]');
btns.forEach((b: Element) => {
const s = Number((b as HTMLElement).dataset.speed);
(b as HTMLElement).className = `btn ${this.speed === s ? 'btn-sky' : 'btn-inactive'}`;
});
break;
}
case 'toggle-variable':
this._updateConfig({ useVariableTerms: !this.config.useVariableTerms });
@ -1542,11 +1606,12 @@ class FolkMortgageSimulator extends HTMLElement {
this._updateConfig({ reinvestmentRates: JSON.parse(target.dataset.rates!) });
break;
case 'select-tranche':
case 'select-tranche': {
const id = target.dataset.trancheId!;
this.selectedTrancheId = this.selectedTrancheId === id ? null : id;
this.render();
this._updateView();
break;
}
case 'select-tier': {
const tierLabel = target.dataset.tierLabel!;
@ -1555,46 +1620,45 @@ class FolkMortgageSimulator extends HTMLElement {
const first = tierTranches.find(t => t.status === 'active') ?? tierTranches[0];
if (first) {
this.selectedTrancheId = this.selectedTrancheId === first.id ? null : first.id;
this.render();
this._updateView();
}
break;
}
case 'close-detail':
this.selectedTrancheId = null;
this.render();
this._updateView();
break;
case 'toggle-sale': {
// Toggle for-sale in simulation state (local only, visual feedback)
this.render();
case 'toggle-sale':
this._updateView();
break;
}
case 'grid-filter':
this.gridFilter = target.dataset.filter as any;
this.render();
this._updateView();
break;
case 'grid-sort':
this.gridSortBy = target.dataset.sort as any;
this.render();
this._updateView();
break;
case 'lender-strategy':
this.lenderStrategy = target.dataset.strategy as any;
this.render();
this._updateView();
break;
case 'borrower-expand':
case 'borrower-expand': {
const idx = Number(target.dataset.idx);
this.borrowerExpandedIdx = this.borrowerExpandedIdx === idx ? null : idx;
this.render();
this._updateView();
break;
}
}
});
// Range inputs
// Range inputs — debounced to avoid full DOM rebuilds during drag
this.shadow.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
if (!target.dataset.action) return;
@ -1605,31 +1669,68 @@ class FolkMortgageSimulator extends HTMLElement {
case 'config-slider': {
const key = target.dataset.key as keyof MortgageSimulatorConfig;
const isPercent = target.dataset.percent === 'true';
this._updateConfig({ [key]: isPercent ? val / 100 : val } as any);
this.config = { ...this.config, [key]: isPercent ? val / 100 : val } as any;
// Update the value label next to the slider immediately
const valEl = target.closest('.slider-row')?.querySelector('.slider-value') as HTMLElement;
if (valEl) {
// Re-derive display from current config value
const v = isPercent ? val : val;
if (key === 'propertyValue') valEl.textContent = fmtCurrency(val);
else if (key === 'downPaymentPercent') valEl.textContent = `${val}% (${fmtCurrency(this.config.propertyValue * val / 100)})`;
else if (key === 'interestRate') valEl.textContent = `${val.toFixed(2)}%`;
else if (key === 'rateVariation') valEl.textContent = val === 0 ? 'None' : `+/- ${val.toFixed(2)}%`;
else if (key === 'termYears') valEl.textContent = `${val} years`;
else if (key === 'overpayment') valEl.textContent = fmtCurrency(val);
else if (key === 'reinvestorPercent') valEl.textContent = `${val}%`;
else if (key === 'trancheSize') valEl.textContent = fmtCurrency(val);
else if (key === 'fundingPercent') {
const nt = Math.ceil(this.config.propertyValue * (1 - this.config.downPaymentPercent / 100) / this.config.trancheSize);
valEl.textContent = `${val}% (${Math.round(nt * val / 100)} / ${nt})`;
}
}
// Debounce the expensive recompute + view update
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
this._recompute();
this.currentMonth = Math.min(this.config.startMonth, this.maxMonth);
this._saveToHash();
this._updateView();
this._rafId = null;
});
break;
}
case 'tier-alloc': {
const tierIdx = Number(target.dataset.tier);
const newTiers = [...this.config.lendingTiers];
newTiers[tierIdx] = { ...newTiers[tierIdx], allocation: val / 100 };
this._updateConfig({ lendingTiers: newTiers });
this.config = { ...this.config, lendingTiers: newTiers };
// Update alloc label
const allocEl = target.closest('.tier-row')?.querySelector('.tier-alloc') as HTMLElement;
if (allocEl) allocEl.textContent = `${val.toFixed(0)}%`;
if (this._rafId) cancelAnimationFrame(this._rafId);
this._rafId = requestAnimationFrame(() => {
this._recompute();
this._saveToHash();
this._updateView();
this._rafId = null;
});
break;
}
case 'month-scrub':
this.currentMonth = val;
this.render();
this._updateView();
break;
case 'lender-investment':
this.lenderInvestment = val;
this.render();
this._updateView();
break;
case 'borrower-budget':
this.borrowerBudget = val;
this.render();
this._updateView();
break;
case 'borrower-down':
this.borrowerDownPayment = val;
this.render();
this._updateView();
break;
}
});