Make budget tab scale with roster size

This commit is contained in:
TopherMayor
2026-04-30 20:26:49 -07:00
parent bdd2e5968f
commit 1e0a072231

View File

@@ -930,6 +930,91 @@
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px;
}
.budget-table-wrap {
margin-top: 16px;
overflow-x: auto;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(11, 13, 20, 0.58);
}
.budget-table {
width: 100%;
min-width: 620px;
border-collapse: collapse;
}
.budget-table thead th {
position: sticky;
top: 0;
background: rgba(12, 15, 24, 0.96);
color: #ffd7b0;
text-align: left;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.budget-table tbody td {
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: top;
}
.budget-table tbody tr:last-child td {
border-bottom: none;
}
.budget-table tbody tr.is-max {
background: rgba(249, 115, 22, 0.10);
}
.budget-table tbody tr.is-max td:first-child {
box-shadow: inset 3px 0 0 #f97316;
}
.budget-attendee-count {
width: 84px;
color: #fff3e8;
font-weight: 800;
white-space: nowrap;
}
.budget-cell {
min-width: 150px;
}
.budget-cell-label {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.6px;
color: #fbc08c;
}
.budget-tier-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-weight: 700;
}
.budget-cell-price {
font-size: 1.02rem;
font-weight: 800;
color: #fff;
line-height: 1.25;
}
.budget-cell-total {
margin-top: 4px;
font-size: 0.72rem;
color: #ffcf9c;
}
.budget-cell-note {
margin-top: 6px;
font-size: 0.68rem;
line-height: 1.35;
color: #b7c0d4;
}
.budget-card {
background: rgba(11, 13, 20, 0.76);
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -1535,6 +1620,7 @@
render();
schedulePriceRefresh();
renderGuestAuthOptions();
fetchGuestRoster();
restoreGuestSession();
// Number keys 1-6 to switch tabs
@@ -1580,11 +1666,25 @@
}
}
async function fetchGuestRoster() {
try {
const res = await fetch('/api/auth/guests');
if (!res.ok) return;
const payload = await res.json();
state.guestRoster = Array.isArray(payload.guests) ? payload.guests : [];
renderGuestAuthOptions();
render();
} catch {
// Keep the modal usable even if the roster endpoint is temporarily unavailable.
}
}
function showAuthModal(message = '') {
document.getElementById('authModal')?.classList.remove('hidden');
const err = document.getElementById('authError');
if (err) err.textContent = message;
renderGuestAuthOptions();
if (!state.guestRoster.length) fetchGuestRoster();
const pinInput = document.getElementById('guestPinInput');
if (pinInput) pinInput.value = '';
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
@@ -2563,36 +2663,154 @@
function renderBudgetBoard() {
if (!state.budgetScenarios.length) return '';
const tierOrder = { Budget: 0, Balanced: 1, Splurge: 2 };
const scenarios = [...state.budgetScenarios].sort((a, b) => {
if (a.groupSize !== b.groupSize) return a.groupSize - b.groupSize;
return (tierOrder[a.tier] ?? 99) - (tierOrder[b.tier] ?? 99);
});
const groupedScenarios = getBudgetScenarioGroups();
const groupSizeLimit = getBudgetGroupSizeLimit();
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
const groomCount = Array.isArray(state.guestRoster)
? state.guestRoster.filter((guest) => guest.role === 'groom').length
: 0;
const bestManCount = Array.isArray(state.guestRoster)
? state.guestRoster.filter((guest) => guest.role === 'best-man').length
: 0;
const rows = Array.from({ length: groupSizeLimit }, (_, index) => index + 1)
.map((groupSize) => ({
groupSize,
budget: getDynamicBudgetScenario('Budget', groupSize, groupedScenarios),
balanced: getDynamicBudgetScenario('Balanced', groupSize, groupedScenarios),
splurge: getDynamicBudgetScenario('Splurge', groupSize, groupedScenarios),
}));
return `
<section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2>
<p>These are planning numbers for the group to compare tracks quickly before anyone starts buying flights. They use current live price signals and bake in the shared-cost difference between 8, 10, and 12 guys.</p>
<div class="budget-stamp">Automation pricing last refreshed ${state.priceUpdatedAt || 'recently'}</div>
<div class="budget-grid">
${scenarios.map(scenario => `
<article class="budget-card">
<div class="budget-meta">
<span>${scenario.groupSize} guys</span>
<span class="budget-tier ${scenario.tier.toLowerCase()}">${scenario.tier}</span>
</div>
<h3>${scenario.tier} Track</h3>
<div class="budget-price">$${scenario.perPerson.toLocaleString()}</div>
<div class="budget-total">$${scenario.groupTotal.toLocaleString()} group total</div>
<div class="budget-summary">${scenario.summary}</div>
<ul>${scenario.notes.map(note => `<li>${note}</li>`).join('')}</ul>
</article>
<p>These numbers recalculate from the latest automation run and scale from 1 attendee up to the full confirmed roster so we can compare Budget, Balanced, and Splurge tracks side by side.</p>
<div class="budget-stamp">Confirmed roster: ${rosterCount || 'loading'} attendee${rosterCount === 1 ? '' : 's'}${groomCount ? ` · ${groomCount} groom${groomCount === 1 ? '' : 's'}` : ''}${bestManCount ? ` · ${bestManCount} best man${bestManCount === 1 ? '' : 's'}` : ''} · max size ${groupSizeLimit}</div>
<div class="budget-table-wrap">
<table class="budget-table">
<thead>
<tr>
<th>Attendees</th>
<th>Budget Track</th>
<th>Balanced Track</th>
<th>Splurge Track</th>
</tr>
</thead>
<tbody>
${rows.map((row) => `
<tr${row.groupSize === groupSizeLimit ? ' class="is-max"' : ''}>
<td class="budget-attendee-count">${row.groupSize}</td>
${renderBudgetCell(row.budget)}
${renderBudgetCell(row.balanced)}
${renderBudgetCell(row.splurge)}
</tr>
`).join('')}
</tbody>
</table>
</div>
</section>
`;
}
function getBudgetScenarioGroups() {
const tiers = { Budget: [], Balanced: [], Splurge: [] };
(Array.isArray(state.budgetScenarios) ? state.budgetScenarios : []).forEach((scenario) => {
if (!tiers[scenario.tier]) return;
tiers[scenario.tier].push(scenario);
});
Object.values(tiers).forEach((list) => list.sort((a, b) => a.groupSize - b.groupSize));
return tiers;
}
function getBudgetGroupSizeLimit() {
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
const seededMax = Math.max(...(Array.isArray(state.budgetScenarios) ? state.budgetScenarios : []).map((scenario) => scenario.groupSize || 0), 0);
return Math.max(rosterCount || 0, seededMax || 0, 1);
}
function getDynamicBudgetScenario(tier, groupSize, groupedScenarios) {
const scenarios = groupedScenarios[tier] || [];
if (!scenarios.length) {
return null;
}
const exact = scenarios.find((scenario) => scenario.groupSize === groupSize);
if (exact) {
return exact;
}
const [lower, upper] = getBudgetBrackets(scenarios, groupSize);
const lowerSize = lower?.groupSize || upper?.groupSize || groupSize;
const upperSize = upper?.groupSize || lower?.groupSize || groupSize;
const lowerPrice = typeof lower?.perPerson === 'number' ? lower.perPerson : (typeof upper?.perPerson === 'number' ? upper.perPerson : 0);
const upperPrice = typeof upper?.perPerson === 'number' ? upper.perPerson : lowerPrice;
const ratio = lowerSize === upperSize ? 0 : (groupSize - lowerSize) / (upperSize - lowerSize);
const perPerson = Math.max(0, lowerPrice + ((upperPrice - lowerPrice) * ratio));
const roundedPerPerson = Math.round(perPerson * 100) / 100;
const sourceScenario = upper || lower || scenarios[0];
const notes = [
`Scaled from live ${tier.toLowerCase()} automations to ${groupSize} attendees.`,
...(Array.isArray(sourceScenario?.notes) ? sourceScenario.notes.slice(0, 4) : []),
];
return {
id: `live-${tier.toLowerCase()}-${groupSize}`,
tier,
groupSize,
perPerson: roundedPerPerson,
groupTotal: Math.round(roundedPerPerson * groupSize),
summary: `${groupSize} guys about ${formatCurrency(roundedPerPerson)} pp`,
notes,
derived: true,
};
}
function getBudgetBrackets(scenarios, groupSize) {
if (scenarios.length === 1) {
return [scenarios[0], scenarios[0]];
}
if (groupSize <= scenarios[0].groupSize) {
return [scenarios[0], scenarios[1]];
}
if (groupSize >= scenarios[scenarios.length - 1].groupSize) {
return [scenarios[scenarios.length - 2], scenarios[scenarios.length - 1]];
}
for (let index = 0; index < scenarios.length - 1; index += 1) {
const lower = scenarios[index];
const upper = scenarios[index + 1];
if (groupSize >= lower.groupSize && groupSize <= upper.groupSize) {
return [lower, upper];
}
}
return [scenarios[0], scenarios[1]];
}
function renderBudgetCell(scenario) {
if (!scenario) {
return '<td class="budget-cell"><div class="budget-cell-price">n/a</div></td>';
}
const note = scenario.derived
? 'Interpolated from the latest live budget anchors.'
: 'From the latest live automation run.';
return `
<td class="budget-cell">
<div class="budget-cell-label">
<span class="budget-tier-pill">${escapeHtml(scenario.tier)}</span>
<span>${escapeHtml(scenario.summary || `${scenario.groupSize} guys`)}</span>
</div>
<div class="budget-cell-price">${escapeHtml(formatCurrency(scenario.perPerson))} pp</div>
<div class="budget-cell-total">${escapeHtml(formatCurrency(scenario.groupTotal))} total</div>
<div class="budget-cell-note">${escapeHtml(note)}</div>
</td>
`;
}
// ── Voting ────────────────────────────────────────────────
function toggleVote(optionId) {
openVoteConfirm(optionId);