Switch budget tab to attendee dropdown

This commit is contained in:
TopherMayor
2026-04-30 20:34:33 -07:00
parent 1e0a072231
commit 538de0039c

View File

@@ -930,6 +930,39 @@
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 12px; gap: 12px;
} }
.budget-controls {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.budget-controls label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.8px;
color: #fbc08c;
font-weight: 700;
}
.budget-controls select {
min-width: 180px;
padding: 8px 12px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 13, 20, 0.9);
color: #fff;
font-size: 0.84rem;
outline: none;
cursor: pointer;
}
.budget-controls select:focus {
border-color: rgba(251, 191, 36, 0.55);
box-shadow: 0 0 0 2px rgba(251, 191, 36, 0.08);
}
.budget-selected-note {
font-size: 0.72rem;
color: #ffcf9c;
}
.budget-table-wrap { .budget-table-wrap {
margin-top: 16px; margin-top: 16px;
overflow-x: auto; overflow-x: auto;
@@ -1586,6 +1619,7 @@
priceUpdatedAt: '', priceUpdatedAt: '',
priceHistoryRunCount: 0, priceHistoryRunCount: 0,
sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc', sortMode: localStorage.getItem('cabo_sort_mode') || 'vote-desc',
budgetGuestCount: Number(localStorage.getItem('cabo_budget_guest_count') || 0),
priceSourceSelections: (() => { priceSourceSelections: (() => {
try { try {
return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}'); return JSON.parse(localStorage.getItem('cabo_price_source_selections') || '{}');
@@ -1672,6 +1706,7 @@
if (!res.ok) return; if (!res.ok) return;
const payload = await res.json(); const payload = await res.json();
state.guestRoster = Array.isArray(payload.guests) ? payload.guests : []; state.guestRoster = Array.isArray(payload.guests) ? payload.guests : [];
syncBudgetGuestCount();
renderGuestAuthOptions(); renderGuestAuthOptions();
render(); render();
} catch { } catch {
@@ -2665,6 +2700,7 @@
const groupedScenarios = getBudgetScenarioGroups(); const groupedScenarios = getBudgetScenarioGroups();
const groupSizeLimit = getBudgetGroupSizeLimit(); const groupSizeLimit = getBudgetGroupSizeLimit();
const selectedGroupSize = getSelectedBudgetGuestCount(groupSizeLimit);
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0; const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
const groomCount = Array.isArray(state.guestRoster) const groomCount = Array.isArray(state.guestRoster)
? state.guestRoster.filter((guest) => guest.role === 'groom').length ? state.guestRoster.filter((guest) => guest.role === 'groom').length
@@ -2672,40 +2708,32 @@
const bestManCount = Array.isArray(state.guestRoster) const bestManCount = Array.isArray(state.guestRoster)
? state.guestRoster.filter((guest) => guest.role === 'best-man').length ? state.guestRoster.filter((guest) => guest.role === 'best-man').length
: 0; : 0;
const rows = Array.from({ length: groupSizeLimit }, (_, index) => index + 1) const selectedScenarios = {
.map((groupSize) => ({ budget: getDynamicBudgetScenario('Budget', selectedGroupSize, groupedScenarios),
groupSize, balanced: getDynamicBudgetScenario('Balanced', selectedGroupSize, groupedScenarios),
budget: getDynamicBudgetScenario('Budget', groupSize, groupedScenarios), splurge: getDynamicBudgetScenario('Splurge', selectedGroupSize, groupedScenarios),
balanced: getDynamicBudgetScenario('Balanced', groupSize, groupedScenarios), };
splurge: getDynamicBudgetScenario('Splurge', groupSize, groupedScenarios),
}));
return ` return `
<section class="budget-board"> <section class="budget-board">
<h2>💸 Budget Cheat Sheet</h2> <h2>💸 Budget Cheat Sheet</h2>
<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> <p>Pick the attendee count you want to model. The app will show only the live Budget, Balanced, and Splurge data for that one group size.</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-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"> <div class="budget-controls">
<table class="budget-table"> <label for="budgetGuestCountSelect">Attendees</label>
<thead> <select id="budgetGuestCountSelect" onchange="setBudgetGuestCount(this.value)">
<tr> ${Array.from({ length: groupSizeLimit }, (_, index) => index + 1).map((groupSize) => `
<th>Attendees</th> <option value="${groupSize}"${groupSize === selectedGroupSize ? ' selected' : ''}>${groupSize} attendee${groupSize === 1 ? '' : 's'}</option>
<th>Budget Track</th> `).join('')}
<th>Balanced Track</th> </select>
<th>Splurge Track</th> <div class="budget-selected-note">Showing live pricing for ${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}.</div>
</tr> </div>
</thead> <div class="budget-grid">
<tbody> ${[
${rows.map((row) => ` selectedScenarios.budget,
<tr${row.groupSize === groupSizeLimit ? ' class="is-max"' : ''}> selectedScenarios.balanced,
<td class="budget-attendee-count">${row.groupSize}</td> selectedScenarios.splurge,
${renderBudgetCell(row.budget)} ].map((scenario) => renderBudgetCard(scenario, selectedGroupSize)).join('')}
${renderBudgetCell(row.balanced)}
${renderBudgetCell(row.splurge)}
</tr>
`).join('')}
</tbody>
</table>
</div> </div>
</section> </section>
`; `;
@@ -2728,6 +2756,29 @@
return Math.max(rosterCount || 0, seededMax || 0, 1); return Math.max(rosterCount || 0, seededMax || 0, 1);
} }
function getSelectedBudgetGuestCount(groupSizeLimit = getBudgetGroupSizeLimit()) {
const parsed = Number(state.budgetGuestCount);
if (Number.isFinite(parsed) && parsed >= 1) {
return Math.min(Math.floor(parsed), groupSizeLimit);
}
return groupSizeLimit;
}
function syncBudgetGuestCount() {
const nextCount = getSelectedBudgetGuestCount(getBudgetGroupSizeLimit());
state.budgetGuestCount = nextCount;
localStorage.setItem('cabo_budget_guest_count', String(nextCount));
}
function setBudgetGuestCount(value) {
const nextCount = Number.parseInt(value, 10);
if (!Number.isFinite(nextCount) || nextCount < 1) return;
state.budgetGuestCount = Math.min(nextCount, getBudgetGroupSizeLimit());
localStorage.setItem('cabo_budget_guest_count', String(state.budgetGuestCount));
render();
}
function getDynamicBudgetScenario(tier, groupSize, groupedScenarios) { function getDynamicBudgetScenario(tier, groupSize, groupedScenarios) {
const scenarios = groupedScenarios[tier] || []; const scenarios = groupedScenarios[tier] || [];
if (!scenarios.length) { if (!scenarios.length) {
@@ -2811,6 +2862,44 @@
`; `;
} }
function renderBudgetCard(scenario, selectedGroupSize) {
if (!scenario) {
return `
<article class="budget-card">
<div class="budget-meta">
<span>${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}</span>
<span class="budget-tier budget-tier-pill">n/a</span>
</div>
<h3>No live budget</h3>
<div class="budget-price">n/a</div>
</article>
`;
}
const note = scenario.derived
? 'Interpolated from the latest live budget anchors.'
: 'From the latest live automation run.';
return `
<article class="budget-card">
<div class="budget-meta">
<span>${selectedGroupSize} attendee${selectedGroupSize === 1 ? '' : 's'}</span>
<span class="budget-tier ${scenario.tier.toLowerCase()}">${escapeHtml(scenario.tier)}</span>
</div>
<h3>${escapeHtml(scenario.tier)} Track</h3>
<div class="budget-price">${escapeHtml(formatCurrency(scenario.perPerson))}</div>
<div class="budget-total">${escapeHtml(formatCurrency(scenario.groupTotal))} group total</div>
<div class="budget-summary">${escapeHtml(scenario.summary || '')}</div>
<ul>
${[
...(Array.isArray(scenario.notes) ? scenario.notes.slice(0, 4) : []),
note,
].filter(Boolean).map((item) => `<li>${escapeHtml(item)}</li>`).join('')}
</ul>
</article>
`;
}
// ── Voting ──────────────────────────────────────────────── // ── Voting ────────────────────────────────────────────────
function toggleVote(optionId) { function toggleVote(optionId) {
openVoteConfirm(optionId); openVoteConfirm(optionId);