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));
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
gap: 12px;
|
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 {
|
.budget-card {
|
||||||
background: rgba(11, 13, 20, 0.76);
|
background: rgba(11, 13, 20, 0.76);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
@@ -1535,6 +1620,7 @@
|
|||||||
render();
|
render();
|
||||||
schedulePriceRefresh();
|
schedulePriceRefresh();
|
||||||
renderGuestAuthOptions();
|
renderGuestAuthOptions();
|
||||||
|
fetchGuestRoster();
|
||||||
restoreGuestSession();
|
restoreGuestSession();
|
||||||
|
|
||||||
// Number keys 1-6 to switch tabs
|
// 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 = '') {
|
function showAuthModal(message = '') {
|
||||||
document.getElementById('authModal')?.classList.remove('hidden');
|
document.getElementById('authModal')?.classList.remove('hidden');
|
||||||
const err = document.getElementById('authError');
|
const err = document.getElementById('authError');
|
||||||
if (err) err.textContent = message;
|
if (err) err.textContent = message;
|
||||||
renderGuestAuthOptions();
|
renderGuestAuthOptions();
|
||||||
|
if (!state.guestRoster.length) fetchGuestRoster();
|
||||||
const pinInput = document.getElementById('guestPinInput');
|
const pinInput = document.getElementById('guestPinInput');
|
||||||
if (pinInput) pinInput.value = '';
|
if (pinInput) pinInput.value = '';
|
||||||
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
|
setTimeout(() => document.getElementById('guestNameSelect')?.focus(), 0);
|
||||||
@@ -2563,36 +2663,154 @@
|
|||||||
function renderBudgetBoard() {
|
function renderBudgetBoard() {
|
||||||
if (!state.budgetScenarios.length) return '';
|
if (!state.budgetScenarios.length) return '';
|
||||||
|
|
||||||
const tierOrder = { Budget: 0, Balanced: 1, Splurge: 2 };
|
const groupedScenarios = getBudgetScenarioGroups();
|
||||||
const scenarios = [...state.budgetScenarios].sort((a, b) => {
|
const groupSizeLimit = getBudgetGroupSizeLimit();
|
||||||
if (a.groupSize !== b.groupSize) return a.groupSize - b.groupSize;
|
const rosterCount = Array.isArray(state.guestRoster) ? state.guestRoster.length : 0;
|
||||||
return (tierOrder[a.tier] ?? 99) - (tierOrder[b.tier] ?? 99);
|
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 `
|
return `
|
||||||
<section class="budget-board">
|
<section class="budget-board">
|
||||||
<h2>💸 Budget Cheat Sheet</h2>
|
<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>
|
<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">Automation pricing last refreshed ${state.priceUpdatedAt || 'recently'}</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-grid">
|
<div class="budget-table-wrap">
|
||||||
${scenarios.map(scenario => `
|
<table class="budget-table">
|
||||||
<article class="budget-card">
|
<thead>
|
||||||
<div class="budget-meta">
|
<tr>
|
||||||
<span>${scenario.groupSize} guys</span>
|
<th>Attendees</th>
|
||||||
<span class="budget-tier ${scenario.tier.toLowerCase()}">${scenario.tier}</span>
|
<th>Budget Track</th>
|
||||||
</div>
|
<th>Balanced Track</th>
|
||||||
<h3>${scenario.tier} Track</h3>
|
<th>Splurge Track</th>
|
||||||
<div class="budget-price">$${scenario.perPerson.toLocaleString()}</div>
|
</tr>
|
||||||
<div class="budget-total">$${scenario.groupTotal.toLocaleString()} group total</div>
|
</thead>
|
||||||
<div class="budget-summary">${scenario.summary}</div>
|
<tbody>
|
||||||
<ul>${scenario.notes.map(note => `<li>${note}</li>`).join('')}</ul>
|
${rows.map((row) => `
|
||||||
</article>
|
<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('')}
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ────────────────────────────────────────────────
|
// ── Voting ────────────────────────────────────────────────
|
||||||
function toggleVote(optionId) {
|
function toggleVote(optionId) {
|
||||||
openVoteConfirm(optionId);
|
openVoteConfirm(optionId);
|
||||||
|
|||||||
Reference in New Issue
Block a user