From 1e0a072231d1bf5636b03b51ffd452d262d6333e Mon Sep 17 00:00:00 2001 From: TopherMayor Date: Thu, 30 Apr 2026 20:26:49 -0700 Subject: [PATCH] Make budget tab scale with roster size --- public/index.html | 260 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 239 insertions(+), 21 deletions(-) diff --git a/public/index.html b/public/index.html index 0d233d6..c9981fa 100644 --- a/public/index.html +++ b/public/index.html @@ -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 `

๐Ÿ’ธ Budget Cheat Sheet

-

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.

-
Automation pricing last refreshed ${state.priceUpdatedAt || 'recently'}
-
- ${scenarios.map(scenario => ` -
-
- ${scenario.groupSize} guys - ${scenario.tier} -
-

${scenario.tier} Track

-
$${scenario.perPerson.toLocaleString()}
-
$${scenario.groupTotal.toLocaleString()} group total
-
${scenario.summary}
-
    ${scenario.notes.map(note => `
  • ${note}
  • `).join('')}
-
- `).join('')} +

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.

+
Confirmed roster: ${rosterCount || 'loading'} attendee${rosterCount === 1 ? '' : 's'}${groomCount ? ` ยท ${groomCount} groom${groomCount === 1 ? '' : 's'}` : ''}${bestManCount ? ` ยท ${bestManCount} best man${bestManCount === 1 ? '' : 's'}` : ''} ยท max size ${groupSizeLimit}
+
+ + + + + + + + + + + ${rows.map((row) => ` + + + ${renderBudgetCell(row.budget)} + ${renderBudgetCell(row.balanced)} + ${renderBudgetCell(row.splurge)} + + `).join('')} + +
AttendeesBudget TrackBalanced TrackSplurge Track
${row.groupSize}
`; } + 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 '
n/a
'; + } + + const note = scenario.derived + ? 'Interpolated from the latest live budget anchors.' + : 'From the latest live automation run.'; + + return ` + +
+ ${escapeHtml(scenario.tier)} + ${escapeHtml(scenario.summary || `${scenario.groupSize} guys`)} +
+
${escapeHtml(formatCurrency(scenario.perPerson))} pp
+
${escapeHtml(formatCurrency(scenario.groupTotal))} total
+
${escapeHtml(note)}
+ + `; + } + // โ”€โ”€ Voting โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function toggleVote(optionId) { openVoteConfirm(optionId);