show both models at the same time

This commit is contained in:
Tobias Brunner 2025-07-23 11:16:15 +02:00
parent aa57082a1b
commit 4746cfac25
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ
6 changed files with 488 additions and 326 deletions

View file

@ -11,47 +11,56 @@ class UIManager {
try {
const enabledResults = Object.values(this.calculator.results);
if (enabledResults.length === 0) {
this.setElementText('net-position', 'CHF 0');
this.setElementText('csp-revenue', 'CHF 0');
this.setElementText('roi-percentage', '0%');
this.setElementText('breakeven-time', 'N/A');
this.setElementText('net-position-direct', 'CHF 0');
this.setElementText('net-position-loan', 'CHF 0');
this.setElementText('roi-percentage-direct', '0%');
this.setElementText('roi-percentage-loan', '0%');
return;
}
// Calculate averages across enabled scenarios with enhanced financial metrics
const avgNetPosition = enabledResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / enabledResults.length;
const avgCSPRevenue = enabledResults.reduce((sum, r) => sum + r.cspRevenue, 0) / enabledResults.length;
const avgROI = enabledResults.reduce((sum, r) => sum + r.roi, 0) / enabledResults.length;
const avgBreakeven = enabledResults.filter(r => r.breakEvenMonth).reduce((sum, r) => sum + r.breakEvenMonth, 0) / enabledResults.filter(r => r.breakEvenMonth).length;
// Separate direct and loan results
const directResults = enabledResults.filter(r => r.investmentModel === 'direct');
const loanResults = enabledResults.filter(r => r.investmentModel === 'loan');
// Update metrics with financial focus
this.setElementText('net-position', this.formatCurrency(avgNetPosition));
this.setElementText('csp-revenue', this.formatCurrency(avgCSPRevenue));
this.setElementText('roi-percentage', this.formatPercentage(avgROI));
this.setElementText('breakeven-time', isNaN(avgBreakeven) ? 'N/A' : `${Math.round(avgBreakeven)} months`);
// Update metric card styling based on performance
const netPositionElement = document.getElementById('net-position');
if (netPositionElement) {
if (avgNetPosition > 0) {
netPositionElement.className = 'metric-value text-success';
} else if (avgNetPosition < 0) {
netPositionElement.className = 'metric-value text-danger';
} else {
netPositionElement.className = 'metric-value';
// Calculate averages for direct investment
if (directResults.length > 0) {
const avgNetPositionDirect = directResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / directResults.length;
const avgROIDirect = directResults.reduce((sum, r) => sum + r.roi, 0) / directResults.length;
this.setElementText('net-position-direct', this.formatCurrency(avgNetPositionDirect));
this.setElementText('roi-percentage-direct', this.formatPercentage(avgROIDirect));
// Update styling for direct metrics
const netPositionDirectElement = document.getElementById('net-position-direct');
if (netPositionDirectElement) {
if (avgNetPositionDirect > 0) {
netPositionDirectElement.className = 'fw-bold text-success';
} else if (avgNetPositionDirect < 0) {
netPositionDirectElement.className = 'fw-bold text-danger';
} else {
netPositionDirectElement.className = 'fw-bold';
}
}
}
const roiElement = document.getElementById('roi-percentage');
if (roiElement) {
if (avgROI > 15) {
roiElement.className = 'metric-value text-success';
} else if (avgROI > 5) {
roiElement.className = 'metric-value text-warning';
} else if (avgROI < 0) {
roiElement.className = 'metric-value text-danger';
} else {
roiElement.className = 'metric-value';
// Calculate averages for loan investment
if (loanResults.length > 0) {
const avgNetPositionLoan = loanResults.reduce((sum, r) => sum + (r.netPosition || 0), 0) / loanResults.length;
const avgROILoan = loanResults.reduce((sum, r) => sum + r.roi, 0) / loanResults.length;
this.setElementText('net-position-loan', this.formatCurrency(avgNetPositionLoan));
this.setElementText('roi-percentage-loan', this.formatPercentage(avgROILoan));
// Update styling for loan metrics
const netPositionLoanElement = document.getElementById('net-position-loan');
if (netPositionLoanElement) {
if (avgNetPositionLoan > 0) {
netPositionLoanElement.className = 'fw-bold text-success';
} else if (avgNetPositionLoan < 0) {
netPositionLoanElement.className = 'fw-bold text-danger';
} else {
netPositionLoanElement.className = 'fw-bold';
}
}
}
} catch (error) {
@ -69,29 +78,42 @@ class UIManager {
tbody.innerHTML = '';
Object.values(this.calculator.results).forEach(result => {
const modelLabel = result.investmentModel === 'loan' ?
'<span class="badge bg-warning">Loan</span>' :
'<span class="badge bg-success">Direct</span>';
// Sort results by scenario first, then by model
const sortedResults = Object.values(this.calculator.results).sort((a, b) => {
const scenarioCompare = a.scenario.localeCompare(b.scenario);
if (scenarioCompare !== 0) return scenarioCompare;
return a.investmentModel.localeCompare(b.investmentModel);
});
sortedResults.forEach(result => {
const isDirect = result.investmentModel === 'direct';
const scenarioColor = this.getScenarioColor(result.scenario);
const performanceInfo = result.investmentModel === 'direct' ?
`<small class="text-muted d-block">Performance: ${result.performanceMultiplier.toFixed(2)}x</small>` +
`<small class="text-muted d-block">Grace: ${result.effectiveGracePeriod} months</small>` :
'<small class="text-muted">Fixed returns</small>';
// Model badge with better styling
const modelBadge = isDirect ?
'<span class="fw-bold text-success">Direct</span>' :
'<span class="fw-bold text-warning">Loan</span>';
// Key features based on model
const keyFeatures = isDirect ?
`Grace period: ${result.effectiveGracePeriod || 6} months<br>` +
`Performance multiplier: ${result.performanceMultiplier ? result.performanceMultiplier.toFixed(2) : '1.0'}x` :
`Fixed ${this.formatPercentage(this.calculator.getInputValues().loanInterestRate * 100)} rate<br>` +
'Predictable returns';
const row = tbody.insertRow();
row.innerHTML = `
<td><strong>${result.scenario}</strong><br>${performanceInfo}</td>
<td>${modelLabel}</td>
<td>${result.finalInstances.toLocaleString()}</td>
<td>${this.formatCurrencyDetailed(result.totalRevenue)}</td>
<td>${this.formatCurrencyDetailed(result.cspRevenue)}</td>
<td>${this.formatCurrencyDetailed(result.servalaRevenue)}</td>
<td class="${result.roi >= 0 ? 'text-success' : 'text-danger'}">
${this.formatPercentage(result.roi)}
${result.avgPerformanceBonus > 0 ? `<br><small class="text-info">+${this.formatPercentage(result.avgPerformanceBonus * 100)} bonus</small>` : ''}
<td><span class="fw-bold" style="color: ${scenarioColor}">${result.scenario}</span></td>
<td>${modelBadge}</td>
<td>${result.finalInstances ? result.finalInstances.toLocaleString() : 'N/A'}</td>
<td class="fw-bold ${result.netPosition >= 0 ? 'text-success' : 'text-danger'}">
${this.formatCurrencyDetailed(result.netPosition || 0)}
</td>
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : 'N/A'}</td>
<td class="fw-bold ${result.roi >= 15 ? 'text-success' : result.roi >= 5 ? 'text-warning' : result.roi < 0 ? 'text-danger' : ''}">
${this.formatPercentage(result.roi || 0)}
</td>
<td>${result.breakEvenMonth ? result.breakEvenMonth + ' months' : '<span class="text-muted">No break-even</span>'}</td>
<td><small class="text-muted">${keyFeatures}</small></td>
`;
});
} catch (error) {
@ -109,34 +131,52 @@ class UIManager {
tbody.innerHTML = '';
// Combine all monthly data and sort by month and scenario
// Combine all monthly data and sort by month, then scenario, then model
const allData = [];
Object.keys(this.calculator.monthlyData).forEach(scenario => {
this.calculator.monthlyData[scenario].forEach(monthData => {
allData.push(monthData);
Object.keys(this.calculator.monthlyData).forEach(resultKey => {
this.calculator.monthlyData[resultKey].forEach(monthData => {
const model = resultKey.includes('_loan') ? 'loan' : 'direct';
const scenario = resultKey.replace('_direct', '').replace('_loan', '');
allData.push({
...monthData,
model: model,
scenarioKey: scenario
});
});
});
allData.sort((a, b) => a.month - b.month || a.scenario.localeCompare(b.scenario));
// Sort by month first, then scenario, then model (direct first)
allData.sort((a, b) => {
const monthCompare = a.month - b.month;
if (monthCompare !== 0) return monthCompare;
const scenarioCompare = a.scenarioKey.localeCompare(b.scenarioKey);
if (scenarioCompare !== 0) return scenarioCompare;
return a.model === 'direct' ? -1 : 1; // Direct first
});
allData.forEach(data => {
const row = tbody.insertRow();
const scenarioColor = this.getScenarioColor(data.scenario);
const isDirect = data.model === 'direct';
// Enhanced monthly breakdown with financial focus
const performanceIcon = data.performanceBonus > 0 ? ' <i class="bi bi-star-fill text-warning" title="Performance Bonus Active"></i>' : '';
const graceIcon = data.month <= (data.effectiveGracePeriod || 6) ? ' <i class="bi bi-shield-fill-check text-success" title="Grace Period Active"></i>' : '';
// Model styling
const modelBadge = isDirect ?
'<span class="text-success fw-bold">Direct</span>' :
'<span class="text-warning fw-bold">Loan</span>';
// Net position styling
const netPositionClass = (data.netPosition || 0) >= 0 ? 'text-success' : 'text-danger';
row.innerHTML = `
<td><strong>${data.month}</strong>${graceIcon}</td>
<td><span class="badge bg-secondary">${data.scenario}</span>${performanceIcon}</td>
<td>+${data.newInstances}</td>
<td class="text-muted">-${data.churnedInstances}</td>
<td>${data.totalInstances.toLocaleString()}</td>
<td>${this.formatCurrencyDetailed(data.monthlyRevenue)}</td>
<td class="fw-bold">${this.formatCurrencyDetailed(data.cspRevenue)}</td>
<td class="text-muted">${this.formatCurrencyDetailed(data.servalaRevenue)}</td>
<td class="${netPositionClass} fw-bold">${this.formatCurrencyDetailed(data.netPosition || (data.cumulativeCSPRevenue - 500000))}</td>
<td class="text-center"><strong>${data.month}</strong></td>
<td><span style="color: ${scenarioColor}" class="fw-bold">${data.scenario}</span></td>
<td>${modelBadge}</td>
<td class="text-end">${data.totalInstances ? data.totalInstances.toLocaleString() : '0'}</td>
<td class="text-end">${this.formatCurrencyDetailed(data.monthlyRevenue || 0)}</td>
<td class="text-end fw-bold">${this.formatCurrencyDetailed(data.cspRevenue || 0)}</td>
<td class="text-end fw-bold ${netPositionClass}">${this.formatCurrencyDetailed(data.netPosition || 0)}</td>
`;
});
} catch (error) {
@ -203,4 +243,14 @@ class UIManager {
return `${value.toFixed(1)}%`;
}
}
getScenarioColor(scenarioName) {
switch(scenarioName.toLowerCase()) {
case 'conservative': return '#28a745';
case 'moderate': return '#ffc107';
case 'aggressive': return '#dc3545';
default: return '#6c757d';
}
}
}