Make budget tab scale with roster size
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user