Compare commits

..

1 Commits

Author SHA1 Message Date
43a466f7e8 feat: map tab with Leaflet + seed-data.js refactor + Yelp proxy
- Add Leaflet map tab in public/index.html with CARTO dark tiles, category
  toggles, vote-count markers, and external venue search
- Extract seed data to seed-data.js with CATEGORY_META, buildSeedData(),
  mergeSeedData() helpers
- Refactor server.js: approvedOptionsWithVoteSummary(), buildRealtimeSnapshot(),
  createUserOption() helpers; Yelp API proxy at /api/yelp; /api/budgets endpoint
- Extract inline seed data from server.js to seed-data.js module
- Add budgetScenarios and priceUpdatedAt to realtime snapshot
2026-04-29 22:16:47 -07:00
16 changed files with 164 additions and 4617 deletions

View File

@@ -5,7 +5,7 @@ Real-time group voting for the bachelor party — hotels, golf, nightlife, excur
## Quick Start
```bash
cd cabo-voting-app
cd voting_app
npm install
node server.js
# → http://localhost:3001
@@ -14,13 +14,7 @@ node server.js
## Features
- **Real-time WebSocket voting** — all clients update instantly
- **6 planning categories** — Hotels, Flights, Golf, Nightlife, Excursions, and Full Itineraries
- **Budget planner tab** — compares 8, 10, and 12 guys across Budget, Balanced, and Splurge tracks
- **Price trend graphs** — each option shows a live line graph from price-watch automation runs
- **Source-selectable price tracking** — switch each option between Apple, Costco, KAYAK, and other tracked sources
- **Package vs standalone labels** — bundled flight+hotel quotes stay distinct from room-only, flight-only, tee-time, table, charter, and excursion prices
- **Decision detail cards** — automation-enriched pricing, features, amenities, and tradeoffs appear on each option
- **Guest authentication** — bachelor-party voters sign in with their name and the last 4 digits of their phone number
- **5 categories** — Hotels, Golf, Nightlife, Excursions, Full Itineraries
- **Add suggestions** — anyone can propose new venues
- **Admin approval** — pending options require approval before going live
- **Responsive** — works on desktop and mobile
@@ -28,23 +22,10 @@ node server.js
## Data
Votes are stored in `data/votes.json` (created on first run). Edit directly or use the admin panel.
System seed data auto-refreshes researched options while preserving existing votes and user-added options.
Price-watch automation runs append time-series snapshots in `price-watch/history.jsonl`, which the app turns into per-option trend lines and decision detail cards. Automation output should cover hotels, flights, golf, nightlife, and excursions, with `bookingType` and `priceBasis` separating package quotes from standalone booking prices.
When a run includes calculated `budgetScenarios` or `derivedItineraries`, the app uses those fresh automation calculations instead of the static seed budget scenarios.
Guest access is rostered in `seed-data.js` and `data/votes.json`; Jon is marked as groom and Toph as best man.
For hosted deployments, set `DATA_DIR` or `DATA_FILE` so mutable vote data lives outside the Git checkout.
When price-watch automation updates tracked data files in the repository, commit/push those changes and refresh the Ubuntu deployment so the hosted app picks up the latest option details, price history, itinerary calculations, and budget scenarios.
## Deployment
The app can run directly under `systemd` with:
```bash
PORT=3021 DATA_DIR=/srv/state/cabo-voting node server.js
```
Traefik can then reverse proxy to the chosen host port.
Deployed on `ice:3001` via Node.js directly (not Docker). Routed through Traefik on `ubuntu` via `cabo-voting.yml`.
See [Gitea Issues](https://gitea.tophermayor.com/TopherMayor/cabo-voting-app/issues) for the UI/UX roadmap.

View File

@@ -1,256 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import Header from './components/Header'
import NameModal from './components/NameModal'
import TabBar from './components/TabBar'
import OptionList from './components/OptionList'
import ResultsTab from './components/ResultsTab'
import AddOption from './components/AddOption'
import Toast from './components/Toast'
import WsOverlay from './components/WsOverlay'
import OptionModal from './components/OptionModal'
import YourVotesModal from './components/YourVotesModal'
import SocialToast from './components/SocialToast'
import MapTab from './components/MapTab'
import BudgetTab from './components/BudgetTab'
import { useWebSocket } from './hooks/useWebSocket'
import { useVoterSession } from './hooks/useVoterSession'
import { useSound } from './hooks/useSound'
import './App.css'
const SOUND_KEY = 'cabo-voting-sound'
const THEME_KEY = 'cabo-voting-theme'
export default function App() {
const [categories, setCategories] = useState([])
const [options, setOptions] = useState([])
const [pollsOpen, setPollsOpen] = useState(true)
const [totalVoters, setTotalVoters] = useState(0)
const [activeTab, setActiveTab] = useState('hotel')
const [toast, setToast] = useState(null)
const [selectedOption, setSelectedOption] = useState(null) // detail modal
const [yourVotesOpen, setYourVotesOpen] = useState(false) // your votes modal
const [socialToast, setSocialToast] = useState(null) // floating vote toast
const [soundEnabled, setSoundEnabled] = useState(() => localStorage.getItem(SOUND_KEY) !== 'off')
const [theme, setTheme] = useState(() => localStorage.getItem(THEME_KEY) || 'dark')
const [onlineCount, setOnlineCount] = useState(0)
const [pollDeadline, setPollDeadline] = useState(null)
const socialToastTimer = useRef(null)
const { voterName, setVoterName, clearVoter } = useVoterSession()
const { playVoteSound, playRemoveSound } = useSound()
const pollsExpired = pollDeadline && Date.now() > new Date(pollDeadline).getTime()
const { wsRef, wsConnected, reconnect } = useWebSocket({
setCategories, setOptions, setPollsOpen, setTotalVoters,
setOnlineCount, setPollDeadline,
})
// Apply theme to body
useEffect(() => {
document.body.dataset.theme = theme
localStorage.setItem(THEME_KEY, theme)
}, [theme])
const showToast = useCallback((msg, type = '') => {
setToast({ msg, type })
setTimeout(() => setToast(null), 3000)
}, [])
const handleVote = useCallback((option, removed = false) => {
if (!voterName) return
if (!pollsOpen || pollsExpired) return
const opt = options.find(o => o.id === option.id)
if (!opt) return
const alreadyVoted = opt.votes?.some(v => v.name === voterName)
// Optimistic update
setOptions(prev => prev.map(o => {
if (o.id !== option.id) return o
if (removed || alreadyVoted) {
return { ...o, votes: o.votes.filter(v => v.name !== voterName) }
} else {
return { ...o, votes: [...(o.votes || []), { name: voterName, timestamp: Date.now() }] }
}
}))
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'vote', optionId: option.id, voterName, remove: removed || alreadyVoted }))
}
if (removed || alreadyVoted) {
playRemoveSound()
showToast(`Removed vote for ${opt.name}`)
} else {
playVoteSound()
showToast(`Voted for ${opt.name}!`)
}
// Social toast for others (shows voter name + option name)
setSocialToast({ voterName, optionName: opt.name, categoryId: opt.categoryId })
clearTimeout(socialToastTimer.current)
socialToastTimer.current = setTimeout(() => setSocialToast(null), 3000)
}, [voterName, options, pollsOpen, pollsExpired, wsRef, playVoteSound, playRemoveSound, showToast])
const handleRemoveVote = useCallback((optionId) => {
const opt = options.find(o => o.id === optionId)
if (opt) handleVote(opt, true)
}, [options, handleVote])
// Check URL params on load
useEffect(() => {
const params = new URLSearchParams(location.search)
if (params.get('view') === 'results') setActiveTab('results')
const optionId = params.get('option')
if (optionId) {
// Wait for options to load, then open modal
const check = () => {
const opt = options.find(o => o.id === optionId)
if (opt) { setSelectedOption(opt); } else if (options.length > 0) { setSelectedOption(null) }
}
check()
}
}, []) // eslint-disable-line
const handleAddSubmit = useCallback((data) => {
if (!voterName) { showToast('Enter your name first', 'error'); return }
const ws = wsRef.current
if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'add_option', ...data, voterName }))
}
document.getElementById('add-name').value = ''
document.getElementById('add-desc').value = ''
document.getElementById('add-url').value = ''
showToast(`Submitted "${data.name}" for approval!`, 'success')
}, [voterName, wsRef, showToast])
const toggleSound = useCallback(() => {
setSoundEnabled(prev => {
const next = !prev
localStorage.setItem(SOUND_KEY, next ? 'on' : 'off')
return next
})
}, [])
const toggleTheme = useCallback(() => {
setTheme(prev => prev === 'dark' ? 'light' : 'dark')
}, [])
const votingCats = categories.filter(c => c.id !== 'results')
const optionCounts = votingCats.reduce((acc, cat) => {
acc[cat.id] = options.filter(o => o.categoryId === cat.id).length
return acc
}, {})
// "Your Votes" — all options this voter voted for
const yourVotes = options.filter(o => o.votes?.some(v => v.name === voterName))
return (
<>
<a className="skip-link" href="#main">Skip to content</a>
<Header
voterName={voterName}
pollsOpen={pollsOpen}
totalVoters={totalVoters}
wsConnected={wsConnected}
onChangeName={clearVoter}
soundEnabled={soundEnabled}
onToggleSound={toggleSound}
theme={theme}
onToggleTheme={toggleTheme}
onlineCount={onlineCount}
pollDeadline={pollDeadline}
pollsExpired={pollsExpired}
/>
{!voterName && <NameModal onSubmit={setVoterName} />}
<TabBar
categories={categories}
activeTab={activeTab}
onTab={setActiveTab}
optionCounts={optionCounts}
onYourVotes={() => setYourVotesOpen(true)}
voterName={voterName}
yourVotesCount={yourVotes.length}
/>
<main id="main">
{activeTab === 'results' ? (
<ResultsTab
categories={categories}
options={options}
pollsOpen={pollsOpen}
totalVoters={totalVoters}
/>
) : activeTab === 'map' ? (
<MapTab
options={options}
categories={categories}
onSelectOption={setSelectedOption}
/>
) : activeTab === 'budget' ? (
<BudgetTab
options={options}
categories={categories}
/>
) : (
<OptionList
options={options.filter(o => o.categoryId === activeTab && o.approved)}
voterName={voterName}
pollsOpen={pollsOpen}
pollsExpired={pollsExpired}
onVote={handleVote}
onCardClick={setSelectedOption}
categoryId={activeTab}
/>
)}
{activeTab !== 'results' && activeTab !== 'map' && activeTab !== 'budget' && (
<AddOption
categories={categories}
voterName={voterName}
onSubmit={handleAddSubmit}
onNeedsName={() => showToast('Enter your name first', 'error')}
/>
)}
</main>
{/* Option detail modal */}
{selectedOption && (
<OptionModal
option={selectedOption}
voterName={voterName}
onClose={() => setSelectedOption(null)}
onVote={handleVote}
categories={categories}
/>
)}
{/* Your votes modal */}
{yourVotesOpen && (
<YourVotesModal
votes={yourVotes}
voterName={voterName}
onClose={() => setYourVotesOpen(false)}
onRemoveVote={handleRemoveVote}
onViewOption={setSelectedOption}
categories={categories}
/>
)}
<WsOverlay connected={wsConnected} onReconnect={reconnect} />
{toast && <Toast msg={toast.msg} type={toast.type} />}
{socialToast && (
<SocialToast
voterName={socialToast.voterName}
optionName={socialToast.optionName}
categoryId={socialToast.categoryId}
/>
)}
</>
)
}

View File

@@ -1,218 +0,0 @@
.budget-tab {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.budget-header {
text-align: center;
margin-bottom: 1.5rem;
}
.budget-header h2 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.budget-header p {
margin: 0;
opacity: 0.7;
font-size: 0.875rem;
}
.budget-leaders {
margin-bottom: 1.5rem;
}
.budget-leaders h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
opacity: 0.8;
}
.leaders-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.leader-card {
background: var(--card-bg, #1e1e2e);
border: 1px solid var(--border, #2a2a3a);
border-radius: 10px;
padding: 0.875rem;
text-align: center;
}
.leader-cat {
font-size: 0.75rem;
opacity: 0.6;
margin-bottom: 0.375rem;
font-weight: 600;
}
.leader-name {
font-size: 0.9rem;
font-weight: 700;
margin-bottom: 0.25rem;
line-height: 1.3;
}
.leader-name.no-votes {
opacity: 0.4;
font-weight: 400;
font-style: italic;
}
.leader-tier {
font-size: 0.75rem;
font-weight: 600;
margin-bottom: 0.25rem;
}
.leader-price {
font-size: 0.72rem;
color: #00d4ff;
font-weight: 600;
margin-bottom: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.leader-votes {
font-size: 0.7rem;
opacity: 0.5;
}
/* Tier pills */
.budget-tiers {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.tier-card {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.6rem;
border-radius: 8px;
border: 2px solid var(--border, #2a2a3a);
opacity: 0.5;
transition: all 0.2s;
position: relative;
}
.tier-card.active {
opacity: 1;
border-color: var(--tier-color);
background: color-mix(in srgb, var(--tier-color) 10%, transparent);
}
.tier-label {
font-size: 0.8rem;
font-weight: 700;
}
.tier-badge {
position: absolute;
top: -8px;
right: -8px;
background: var(--tier-color);
color: #fff;
font-size: 0.6rem;
font-weight: 700;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
}
/* Breakdown table */
.budget-breakdown h3 {
margin: 0 0 0.75rem;
font-size: 1rem;
}
.breakdown-table {
background: var(--card-bg, #1e1e2e);
border: 1px solid var(--border, #2a2a3a);
border-radius: 10px;
overflow: hidden;
margin-bottom: 0.75rem;
}
.breakdown-row {
display: grid;
grid-template-columns: 1fr 1.4fr repeat(3, 70px);
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
border-bottom: 1px solid var(--border, #2a2a3a);
}
.source-cell {
font-size: 0.65rem;
opacity: 0.6;
text-align: left !important;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.breakdown-row:last-child {
border-bottom: none;
}
.breakdown-row.header-row {
font-weight: 700;
opacity: 0.6;
font-size: 0.75rem;
background: var(--bg-secondary, #16161e);
}
.breakdown-row span {
text-align: right;
}
.breakdown-row span:first-child {
text-align: left;
}
.breakdown-row.total-row {
background: color-mix(in srgb, var(--accent) 8%, transparent);
font-weight: 700;
}
.breakdown-row.group-row {
background: var(--bg-secondary, #16161e);
font-weight: 700;
font-size: 0.875rem;
}
.budget-note {
font-size: 0.7rem;
opacity: 0.5;
margin: 0;
text-align: center;
}
/* Placeholder */
.budget-placeholder {
text-align: center;
padding: 3rem 1rem;
opacity: 0.7;
}
.placeholder-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
.budget-placeholder p {
max-width: 300px;
margin: 0 auto;
font-size: 0.875rem;
line-height: 1.5;
}

View File

@@ -1,371 +0,0 @@
import { useMemo } from 'react'
import './BudgetTab.css'
// ─── Price parsers ────────────────────────────────────────────────────────────
function extractPrices(details) {
if (!details || !details.length) return {}
const text = details.join(' ')
const prices = {}
// Per-person package: "Apple exact-date quote: $2,016 pp" / "Costco package: $1,678.99 pp"
const ppMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person|per-person)/i)
if (ppMatch) prices.perPerson = parseFloat(ppMatch[1].replace(/,/g, ''))
// Per-night: "KAYAK from $212/night" / "KAYAK exact-date room rate: $335/night"
const pnMatch = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:\/night|per night|per-night)/i)
if (pnMatch) prices.perNight = parseFloat(pnMatch[1].replace(/,/g, ''))
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
// Group-total with size: "Pool island for 4: $884" / "Gold package for 16 guests: $1,700"
const groupMatch = text.match(/(?:for|at)\s+(\d+)[^\d$]*\$([\d,]+(?:\.\d{2})?)/i)
|| text.match(/\$([\d,]+(?:\.\d{2})?)\s+(?:total|for\s+\d+)/i)
if (groupMatch) {
prices.groupTotal = parseFloat((groupMatch[2] || groupMatch[1]).replace(/,/g, ''))
const sizeMatch = text.match(/for\s+(\d+)\s*[^\d$]*/i)
if (sizeMatch) prices.groupSize = parseInt(sizeMatch[1])
}
// Simple "From $1,504 total"
if (!prices.groupTotal) {
const totalMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\s+total/i)
if (totalMatch) prices.groupTotal = parseFloat(totalMatch[1].replace(/,/g, ''))
}
// Per-person from group total: "About $188 pp at 8"
const ppAtMatch = text.match(/\$\d+[\d,.]*\s*(?:pp|per person).*?at\s+(\d+)/i)
|| text.match(/about?\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person).*?at\s+(\d+)/i)
if (ppAtMatch) {
prices.perPerson = parseFloat(ppAtMatch[1].replace(/,/g, ''))
prices.atGroupSize = parseInt(ppAtMatch[2])
}
// Planning number: "Use about $180 as current planning number"
const planMatch = text.match(/use\s+(?:about\s+)?\$([\d,]+(?:\.\d{2})?)\s+(?:as\s+)?(?:the\s+)?(?:current\s+)?planning/i)
if (planMatch && !prices.perPerson) prices.planningRate = parseFloat(planMatch[1].replace(/,/g, ''))
// Flat per-person: "From $76" — only if no other signals
if (!prices.perPerson && !prices.perNight && !prices.groupTotal && !prices.planningRate) {
const flatMatch = text.match(/(?:from\s+)?\$([\d,]+(?:\.\d{2})?)\b(?!.*\b(?:pp|per|total|for\b)/i)
if (flatMatch) {
const val = parseFloat(flatMatch[1].replace(/,/g, ''))
if (val > 20) prices.perPerson = val
}
}
return prices
}
// ─── Check whether an option has real scraped pricing ─────────────────────────
function hasRealPricing(option) {
const p = extractPrices(option.details || [])
// Has at least one real price signal (not just a planning estimate or generic text)
return !!(p.perPerson || p.perNight || p.groupTotal || p.planningRate)
}
// ─── Resolve per-person cost for an option ───────────────────────────────────
function resolvePerPerson(option, fallback) {
const p = extractPrices(option.details || [])
if (p.perPerson) return p.perPerson
if (p.perNight) return p.perNight * 3 // assume 3-night stay
if (p.groupTotal && p.groupSize) return Math.round(p.groupTotal / p.groupSize)
if (p.groupTotal && p.atGroupSize) {
// scale to 8-person group as baseline
return Math.round(p.groupTotal / p.atGroupSize)
}
if (p.groupTotal) return Math.round(p.groupTotal / 8)
if (p.planningRate) return p.planningRate
return fallback
}
// ─── Fixed costs ─────────────────────────────────────────────────────────────
const FLIGHT_PP = 350
const FOOD_BUFFER = 275
// ─── Per-person costs from real scraped data (used when details are empty) ──
const FALLBACK_HOTEL = { budget: 450, balanced: 850, splurge: 1250 }
const FALLBACK_GOLF = { budget: 130, balanced: 180, splurge: 250 }
const FALLBACK_NIGHTLIFE = { budget: 40, balanced: 100, splurge: 200 }
const FALLBACK_EXCURSION = { budget: 76, balanced: 110, splurge: 188 }
const FALLBACK_TRANSFER = { budget: 33, balanced: 83, splurge: 183 }
// ─── Tier classification by hotel price signal ──────────────────────────────
function classifyTier(optionId, perPerson) {
// Use the actual scraped per-person package prices as ground truth
const KNOWN_PP = {
// Hotels — package prices
'hotel-corazon': null, // no live rate
'hotel-breathless': 1678.99, // Costco package
'hotel-grand-fiesta': null, // no Costco live, Apple $2,111
'hotel-secrets': 2005.80, // Costco package
'hotel-pacifica': null, // no live rate
'hotel-dreams': null,
// Hotels — nightly rates → scaled to 3-night
// KAYAK per-night rates × 3 nights
}
if (KNOWN_PP[optionId] !== undefined && KNOWN_PP[optionId] !== null) {
const pp = KNOWN_PP[optionId]
if (pp <= 1000) return 'budget'
if (pp <= 1900) return 'balanced'
return 'splurge'
}
// Nightly rate proxies (×3 nights)
if (perPerson <= 300) return 'budget'
if (perPerson <= 900) return 'balanced'
return 'splurge'
}
// ─── Category label / emoji ──────────────────────────────────────────────────
const CAT_LABELS = {
hotel: '🏨 Hotel',
golf: '⛳ Golf',
nightlife: '🎧 Nightlife',
excursion: '🚤 Excursion',
}
const GROUP_SIZES = [8, 10, 12]
// ─── Main component ─────────────────────────────────────────────────────────
function getWinningOption(options, categoryId) {
const catOpts = options.filter(o => o.categoryId === categoryId && o.approved)
if (!catOpts.length) return null
return catOpts.reduce((best, o) =>
(o.votes?.length || 0) > (best.votes?.length || 0) ? o : best
, catOpts[0])
}
function dominantTier(tiers) {
const counts = { budget: 0, balanced: 0, splurge: 0 }
Object.values(tiers).forEach(t => { if (t) counts[t] = (counts[t] || 0) + 1 })
if (counts.splurge >= 2) return 'splurge'
if (counts.budget >= 2) return 'budget'
return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'balanced'
}
function formatPrice(v) {
if (!v) return '—'
return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
}
function getPriceLabel(option) {
const p = extractPrices(option.details || [])
const text = (option.details || []).join(' ')
if (p.perPerson) {
// Prefer package price label
const pkgMatch = text.match(/(Costco|Apple).*?\$[\d,]+(?:\.\d{2})?\s*(?:pp|per person)/i)
if (pkgMatch) {
const m = text.match(/\$([\d,]+(?:\.\d{2})?)\s*(?:pp|per person)/i)
if (m) return `pkg ${formatPrice(parseFloat(m[1].replace(/,/g, '')))}/pp`
}
return formatPrice(p.perPerson) + '/pp'
}
if (p.perNight) {
const nights = 3
return formatPrice(p.perNight) + '/night → ' + formatPrice(p.perNight * nights) + ' for 3 nights'
}
if (p.groupTotal && p.groupSize) {
const pp = Math.round(p.groupTotal / p.groupSize)
return formatPrice(p.groupTotal) + ' total ÷ ' + p.groupSize + ' = ' + formatPrice(pp) + '/pp'
}
if (p.groupTotal) {
const pp = Math.round(p.groupTotal / 8)
return formatPrice(p.groupTotal) + ' group total (~' + formatPrice(pp) + '/pp)'
}
if (p.planningRate) return '~$' + Math.round(p.planningRate) + '/pp planning'
return null
}
export default function BudgetTab({ options, categories }) {
const votingCategories = ['hotel', 'golf', 'nightlife', 'excursion']
const leaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
result[cat] = getWinningOption(options, cat)
})
return result
}, [options])
// Only include leaders that have actual scraped pricing
const pricedLeaders = useMemo(() => {
const result = {}
votingCategories.forEach(cat => {
const leader = leaders[cat]
if (leader && hasRealPricing(leader)) result[cat] = leader
})
return result
}, [leaders])
// Determine tier per category based on actual prices
const tiers = useMemo(() => {
const t = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) { t[cat] = 'balanced'; return }
const pp = resolvePerPerson(leader, null)
t[cat] = classifyTier(leader.id, pp)
})
return t
}, [pricedLeaders])
const overallTier = useMemo(() => dominantTier(tiers), [tiers])
// Build the cost breakdown from real prices only
const costRows = useMemo(() => {
const rows = {}
votingCategories.forEach(cat => {
const leader = pricedLeaders[cat]
if (!leader) return // skip categories without priced leaders
const pp = resolvePerPerson(leader, null)
rows[cat] = { label: CAT_LABELS[cat], pp, source: leader.name }
})
// Transfer depends on tier (only show if we have at least a hotel)
if (Object.keys(pricedLeaders).length > 0) {
rows.transfer = { label: '🚗 Transfers', pp: FALLBACK_TRANSFER[overallTier], source: 'shared shuttle estimate' }
}
rows.flight = { label: '✈️ Flights', pp: FLIGHT_PP, source: 'group average estimate' }
rows.food = { label: '🍽️ Food + drinks', pp: FOOD_BUFFER, source: 'buffer for 3 days' }
return rows
}, [pricedLeaders, overallTier])
const totals = useMemo(() => {
return GROUP_SIZES.map(size => {
const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0)
return { size, perPerson: Math.round(sum), total: Math.round(sum * size) }
})
}, [costRows])
const hasPricedLeaders = Object.keys(pricedLeaders).length > 0
const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' }
const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' }
return (
<div className="budget-tab">
<div className="budget-header">
<h2>Trip Budget Calculator</h2>
<p>Prices sourced from live travel data updates as votes come in</p>
</div>
{/* Category leaders with real prices */}
<div className="budget-leaders">
<h3>Current Leaders Live Prices</h3>
{Object.keys(pricedLeaders).length > 0 ? (
<div className="leaders-grid">
{votingCategories.map(cat => {
const leader = pricedLeaders[cat]
if (!leader) return null
const catTier = tiers[cat]
const priceLabel = getPriceLabel(leader)
return (
<div key={cat} className="leader-card" style={{ '--cat-color': tierColor[catTier] }}>
<div className="leader-cat">{CAT_LABELS[cat]}</div>
<div className="leader-name">{leader.name}</div>
{priceLabel && <div className="leader-price">{priceLabel}</div>}
<div className="leader-tier" style={{ color: tierColor[catTier] }}>
{tierLabel[catTier]}
</div>
<div className="leader-votes">
{leader.votes?.length > 0
? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}`
: 'no votes yet'}
</div>
</div>
)
})}
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon"></div>
<p>Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.</p>
</div>
)}
</div>
{/* Tier summary pills */}
<div className="budget-tiers">
{['budget', 'balanced', 'splurge'].map(tier => (
<div
key={tier}
className={`tier-card ${overallTier === tier ? 'active' : ''}`}
style={{ '--tier-color': tierColor[tier] }}
>
<div className="tier-label">{tierLabel[tier]}</div>
{overallTier === tier && <div className="tier-badge">Current</div>}
</div>
))}
</div>
{/* Dynamic cost breakdown */}
{hasPricedLeaders ? (
<div className="budget-breakdown">
<h3>Estimated Cost {tierLabel[overallTier]} Tier</h3>
<div className="breakdown-table">
<div className="breakdown-row header-row">
<span>Line Item</span>
<span>Source</span>
{GROUP_SIZES.map(s => <span key={s}>{s} guys</span>)}
</div>
{[
costRows.flight,
costRows.hotel,
costRows.golf,
costRows.nightlife,
costRows.excursion,
costRows.transfer,
costRows.food,
].map(row => row && (
<div className="breakdown-row" key={row.label}>
<span>{row.label}</span>
<span className="source-cell">{row.source || '—'}</span>
{GROUP_SIZES.map(s => (
<span key={s}>{formatPrice(row.pp)}</span>
))}
</div>
))}
<div className="breakdown-row total-row">
<span>Est. Per Person</span>
<span className="source-cell">sum</span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.perPerson)}
</span>
))}
</div>
<div className="breakdown-row group-row">
<span>Est. Group Total</span>
<span className="source-cell"></span>
{totals.map(t => (
<span key={t.size} style={{ color: tierColor[overallTier], fontWeight: 700 }}>
{formatPrice(t.total)}
</span>
))}
</div>
</div>
<p className="budget-note">
* Prices from live scraped data. Hotel rates are package or nightly × 3 nights.
Per-person costs shown are the same for all group sizes. Add bar tabs and tips separately.
</p>
</div>
) : (
<div className="budget-placeholder">
<div className="placeholder-icon">🗳</div>
<p>No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.</p>
</div>
)}
</div>
)
}

View File

@@ -1,100 +0,0 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
width: 360px;
max-width: 90vw;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.modal h2 { font-size: 1.3rem; margin-bottom: 6px; color: var(--accent); }
.modal p { color: var(--text-muted); font-size: 0.85rem; margin-bottom: 20px; line-height: 1.5; }
.modal form { display: flex; flex-direction: column; gap: 12px; }
.modal input {
width: 100%;
padding: 10px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.modal input:focus { border-color: var(--accent); }
.modal button {
width: 100%;
padding: 10px;
background: var(--accent);
color: var(--bg);
border: none;
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-weight: 700;
transition: opacity 0.2s;
}
.modal button:hover:not(:disabled) { opacity: 0.85; }
.modal button:disabled { opacity: 0.4; cursor: not-allowed; }
.name-error {
color: #f87171;
font-size: 0.8rem;
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.3);
border-radius: 6px;
padding: 6px 10px;
text-align: center;
}
.pin-input {
letter-spacing: 0.3em !important;
font-size: 1.5rem !important;
text-align: center !important;
font-family: monospace !important;
}
.back-btn {
background: transparent !important;
color: var(--text-muted) !important;
border: 1px solid var(--border) !important;
font-weight: 400 !important;
font-size: 0.8rem !important;
}
@media (max-width: 640px) {
.modal-overlay { align-items: flex-end; justify-content: stretch; padding: 0; }
.modal {
width: 100%;
border-radius: 20px 20px 0 0;
padding: 12px 24px 32px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 -8px 40px rgba(0,0,0,0.6);
animation: slideUp 0.3s ease-out;
}
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
.drag-handle {
width: 40px; height: 4px;
background: var(--border);
border-radius: 2px;
margin: 0 auto 14px;
}
.modal h2 { font-size: 1.15rem; }
.modal p { font-size: 0.8rem; margin-bottom: 14px; }
}

View File

@@ -1,107 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { GROOMSMEN, validateGroomsman } from '../groommen'
import './NameModal.css'
const STORAGE_KEY_PIN = 'cabo_voter_pin'
export default function NameModal({ onSubmit }) {
const [name, setName] = useState('')
const [pin, setPin] = useState('')
const [error, setError] = useState('')
const [step, setStep] = useState(1) // 1 = name, 2 = pin
const inputRef = useRef(null)
useEffect(() => {
if (step === 1) inputRef.current?.focus()
}, [step])
const handleNameNext = (e) => {
e.preventDefault()
if (!name.trim()) return
const key = name.trim().toLowerCase().replace(/\s+/g, '')
if (!GROOMSMEN[key]) {
setError(`"${name}" is not on the guest list. Check with the groom.`)
return
}
setError('')
setStep(2)
// Pre-fill pin from localStorage if saved
const saved = localStorage.getItem(STORAGE_KEY_PIN)
if (saved) setPin(saved)
}
const handlePinSubmit = (e) => {
e.preventDefault()
if (pin.length !== 4) {
setError('Enter the last 4 digits of your phone number.')
return
}
const key = name.trim().toLowerCase().replace(/\s+/g, '')
if (!validateGroomsman(name, pin)) {
setError('Wrong PIN. Make sure you\'re using the correct name + last 4 digits.')
return
}
localStorage.setItem(STORAGE_KEY_PIN, pin)
onSubmit(name.trim())
}
const handleBack = () => {
setStep(1)
setPin('')
setError('')
}
return (
<div className="modal-overlay">
<div className="modal">
<div className="drag-handle"></div>
{step === 1 ? (
<>
<h2>🏄 Who's Voting?</h2>
<p>Enter your <strong>full name</strong> as it appears on the guest list.</p>
<form onSubmit={handleNameNext}>
<input
ref={inputRef}
type="text"
value={name}
onChange={e => { setName(e.target.value); setError('') }}
placeholder="e.g. Jon, Toph, Hans…"
maxLength={30}
autoComplete="off"
autoCapitalize="words"
/>
{error && <div className="name-error">{error}</div>}
<button type="submit" disabled={!name.trim()}>Next →</button>
</form>
</>
) : (
<>
<h2>🔐 Verify with PIN</h2>
<p>Enter the <strong>last 4 digits</strong> of your phone number, {name.split(' ')[0]}.</p>
<form onSubmit={handlePinSubmit}>
<input
ref={inputRef}
type="password"
inputMode="numeric"
pattern="[0-9]*"
maxLength={4}
value={pin}
onChange={e => { setPin(e.target.value.replace(/\D/g, '')); setError('') }}
placeholder="••••"
autoComplete="off"
className="pin-input"
/>
{error && <div className="name-error">{error}</div>}
<button type="submit" disabled={pin.length !== 4}>Join the Vote </button>
<button type="button" className="back-btn" onClick={handleBack}> Back</button>
</form>
</>
)}
</div>
</div>
)
}

View File

@@ -1,43 +0,0 @@
// Groomsmen: name (lowercase) → last 4 digits of phone number
export const GROOMSMEN = {
jon: '7506',
toph: '8116',
hans: '6681',
janno: '2809',
jt: '3286',
Cordero: '0379', // no last name given
lester: '8014',
nick: '6044',
david: '5993',
poalo: '9922', // likely "Paolo"
justin: '2329',
benstewart: '1957',
joseph: '4976',
francis: '4934',
chris: '1584', // Chris Mayor — admin
}
// Reverse map: last4 → display name
export const GROOMSMEN_BY_PIN = Object.fromEntries(
Object.entries(GROOMSMEN).map(([name, pin]) => [pin, name])
)
/**
* Validate a name + 4-digit PIN combination.
* Name must match a key in GROOMSMEN (case-insensitive).
* PIN must match that entry's value.
*/
export function validateGroomsman(name, pin) {
const key = name.trim().toLowerCase().replace(/\s+/g, '')
const entry = GROOMSMEN[key]
if (!entry) return false
return entry === pin.trim()
}
/**
* Resolve the canonical display name for a validated groomsman.
*/
export function getCanonicalName(name) {
const key = name.trim().toLowerCase().replace(/\s+/g, '')
return GROOMSMEN[key] ? key : name.trim()
}

908
package-lock.json generated
View File

@@ -1,908 +0,0 @@
{
"name": "cabo-voting-app",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cabo-voting-app",
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"uuid": "^11.1.0",
"ws": "^8.18.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.5",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "~1.2.0",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/destroy": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
"license": "MIT",
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"statuses": "~2.0.2",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/methods": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"license": "MIT",
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT",
"dependencies": {
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.1",
"mime": "1.6.0",
"ms": "2.1.3",
"on-finished": "~2.4.1",
"range-parser": "~1.2.1",
"statuses": "~2.0.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/send/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/serve-static": {
"version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT",
"dependencies": {
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"parseurl": "~1.3.3",
"send": "~0.19.1"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

View File

@@ -1,19 +0,0 @@
{
"name": "cabo-voting-app",
"version": "1.0.0",
"private": true,
"description": "Real-time Cabo bachelor party voting with budgets, packages, and activity planning.",
"main": "server.js",
"scripts": {
"start": "node server.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2",
"uuid": "^11.1.0",
"ws": "^8.18.2"
}
}

View File

@@ -1 +0,0 @@
latest-report.md

File diff suppressed because one or more lines are too long

View File

@@ -1,49 +0,0 @@
# Cabo Price Watch
Checked: 2026-04-30 (America/Los_Angeles)
## Biggest price changes
- Added exact-date KAYAK flights for LAX to SJD. Cheapest is $273 per person, best nonstop is $380, and Delta nonstop is $377. Source: [KAYAK LAX search](https://www.kayak.com/flights/LAX-SJD/2027-02-03/2027-02-07?sort=bestflight_a)
- Added exact-date KAYAK flights for ONT to SJD. Cheapest is $412 per person, best is $413, and quickest is $827. Source: [KAYAK ONT search](https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a)
- 12-person budget scenario moved from $1,392 to $1,139 per person after replacing placeholders with live flight, hotel, golf, excursion, and nightlife inputs.
- 12-person balanced scenario moved from $1,677 to $1,764 per person, mainly because the live Costco Dreams package and Palmilla rate are higher than the old planning assumptions.
- 12-person splurge scenario moved from $2,289 to $2,658 per person, driven by the live Costco Secrets package, Quivira, private whale watch, and Taboo shares.
## Missing prices
- Cabo del Sol public pricing is still not visible from the official booking pages checked today.
- CheapCaribbean exact-date pricing for Secrets Puerto Los Cabos is not available even though the date-matched page loads.
## Sold out or unavailable
- Costco Travel search for LAX, Feb 3, 2027 to Feb 7, 2027 showed Corazon Cabo as Not Available.
- Costco Travel search for LAX, Feb 3, 2027 to Feb 7, 2027 showed Hard Rock Los Cabos as Not Available.
## Login-required sources
- Costco Travel
## New options worth adding
- LAX flight tracking option on KAYAK for the Feb 3, 2027 to Feb 7, 2027 window. Cheapest $273, best nonstop $380. Source: [KAYAK LAX search](https://www.kayak.com/flights/LAX-SJD/2027-02-03/2027-02-07?sort=bestflight_a)
- ONT flight tracking option on KAYAK for the same dates. Cheapest $412, best $413. Source: [KAYAK ONT search](https://www.kayak.com/flights/ONT-SJD/2027-02-03/2027-02-07?sort=bestflight_a)
- Hacienda del Mar Los Cabos remains worth adding as a Costco package candidate because the results flow surfaced it as a live package path with savings messaging, even though todays run did not capture a visible final total.
## Current package and standalone signals
- Costco package prices carried into this run: Breathless $1,678.99 pp, Dreams $1,447.80 pp, Secrets $2,005.80 pp, Zoetry $1,717.42 pp.
- Apple Vacations exact-date package prices carried into this run include Breathless $2,016 pp, Grand Fiesta $2,111 pp, Dreams $1,757 pp, Hyatt Ziva $2,178 pp, Riu Palace Cabo San Lucas $1,529 pp, and Hard Rock $3,343 pp.
- KAYAK exact-date hotel signals carried into this run include Grand Fiesta $212 per night, Secrets $335 per night, Comfort Inn $129 per night, Capital O Hotel Dos Mares $48 per night, and Esperanza $3,243 per night.
- Public activity pricing carried into this run includes Palmilla $194.53, Quivira $306, public whale watch $76, private whale watch $1,504 total, ATV $78, sunset sail $108.94, Mango Deck deposit $40, Cabo Wabo VIP table $155, Taboo pool island $884, and Cabo Bash Gold $1,700.
## Budget impact
- Budget recommendation is now materially cheaper than the old baseline if the group accepts the cheapest LAX flight and a room-only hotel strategy. New per-person totals: 8 guys $1,150, 10 guys $1,143, 12 guys $1,139.
- Balanced recommendation stays the cleanest value package mix. New per-person totals: 8 guys $1,771, 10 guys $1,767, 12 guys $1,764.
- Splurge recommendation is now clearly premium-tier. New per-person totals: 8 guys $2,721, 10 guys $2,727, 12 guys $2,658.
## Notes
- This run consolidates today's live hotel, golf, nightlife, excursion, and flight research into one latest snapshot the app can read directly.
- Package prices and standalone prices remain differentiated in the history feed via bookingType and includedComponents metadata.

View File

@@ -1,177 +0,0 @@
{
"reporting": {
"latestReport": "price-watch/latest-report.md",
"historyLog": "price-watch/history.jsonl"
},
"comparison": {
"materialPriceChangeUsd": 100,
"highlightNewOptions": true,
"markLoginRequiredSources": true
},
"tripDates": {
"checkIn": "2027-02-03",
"checkOut": "2027-02-07",
"nights": 4,
"note": "All per-night or per-day rates should be converted to the full check-in/check-out total for comparison, while preserving the unit rate in the display label."
},
"trackedSources": [
{
"id": "hotel-packages",
"label": "Flight + Hotel Packages",
"categories": ["hotel"],
"bookingType": "package",
"requiredChecks": [
"Costco Travel package results",
"Apple Vacations package search",
"CheapCaribbean package search",
"other date-matched package providers found during research"
]
},
{
"id": "standalone-hotels",
"label": "Standalone Hotels",
"categories": ["hotel"],
"bookingType": "standalone",
"requiredChecks": [
"KAYAK hotel search",
"official hotel booking engine when public rates are visible",
"other OTA hotel-only rates found during research"
]
},
{
"id": "flights",
"label": "Standalone Flights",
"categories": ["flight"],
"bookingType": "standalone",
"requiredChecks": [
"LAX to SJD date-matched round trip",
"ONT to SJD date-matched round trip",
"capture airline, stops, schedule window, baggage caveats, and total price per traveler"
]
},
{
"id": "golf",
"label": "Golf",
"categories": ["golf"],
"bookingType": "standalone",
"requiredChecks": [
"official course tee-time pages when available",
"public tee-time marketplaces",
"resort/package golf inclusions when attached to hotel packages"
]
},
{
"id": "nightlife",
"label": "Nightlife and Day Clubs",
"categories": ["nightlife"],
"bookingType": "standalone",
"requiredChecks": [
"VIP table packages",
"bottle service minimums",
"day-club and beach-club package pricing",
"cover charges or ticketed events when visible"
]
},
{
"id": "excursions",
"label": "Excursions and Water Activities",
"categories": ["excursion"],
"bookingType": "standalone",
"requiredChecks": [
"yacht and private charter quotes",
"whale-watch and sunset sail pricing",
"ATV/off-road packages",
"bachelor-party-relevant group excursions"
]
},
{
"id": "derived-itineraries",
"label": "Derived Itineraries",
"categories": ["itinerary"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate budget, balanced, and splurge itinerary totals from the current hotel/package, flight, golf, nightlife, and excursion results",
"include component breakdowns and assumptions for each itinerary"
]
},
{
"id": "derived-budgets",
"label": "Derived Budget Tracks",
"categories": ["budget"],
"bookingType": "calculated",
"requiredChecks": [
"recalculate 8, 10, and 12 person totals from current results",
"prefer exact package prices when the itinerary uses a flight+hotel package",
"avoid double-counting flights or hotels already included in package prices",
"include per-person and group-total math plus assumptions"
]
}
],
"bookingTypeRules": {
"package": "Use for bundled products such as flight+hotel packages or hotel+transfer packages. Include included components and do not mix directly with standalone room-only rates.",
"standalone": "Use for individual bookings such as hotel-only rates, flights, golf tee times, nightlife tables, yacht charters, and excursions.",
"calculated": "Use for automation-derived itinerary and budget totals built from current package or standalone components."
},
"outputSchema": {
"optionPrices": [
{
"seedKey": "stable app option key when available",
"price": "numeric price only, or null when unavailable",
"displayLabel": "human-readable price label",
"category": "hotel | flight | golf | nightlife | excursion | itinerary | budget",
"source": "travel site or vendor",
"sourceUrl": "exact result or source URL",
"bookingType": "package | standalone | calculated",
"priceBasis": "perTraveler | perNight | perDay | perPerson | perGroup | totalPackage | perRound | perTable",
"unitPrice": "original unit price when source quotes per night or per day",
"tripTotalPrice": "calculated full-stay/check-in-to-check-out price when source quotes per night or per day",
"includedComponents": ["flight", "hotel", "transfer", "golf", "nightlife", "excursion"],
"excludedComponents": ["components that must be budgeted separately"],
"origin": "airport code for flight/package quotes when applicable",
"destination": "airport or destination code when applicable",
"availability": "available | unavailable | sold-out | login-required | request-quote",
"features": ["structured decision features"],
"amenities": ["hotel or venue amenities"],
"inclusions": ["included items"],
"limitations": ["tradeoffs, caveats, restrictions"],
"decisionNote": "short decision note"
}
],
"derivedItineraries": [
{
"seedKey": "itinerary-budget | itinerary-balanced | itinerary-splurge | itinerary-concierge or new stable key",
"tier": "Budget | Balanced | Splurge | Concierge",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total when group size is known",
"groupSize": "8 | 10 | 12 or selected party size",
"components": ["component price keys used"],
"assumptions": ["calculation assumptions"],
"summary": "short recommendation summary"
}
],
"budgetScenarios": [
{
"id": "stable scenario id",
"tier": "Budget | Balanced | Splurge",
"groupSize": "numeric group size",
"perPerson": "numeric calculated per-person total",
"groupTotal": "numeric calculated group total",
"summary": "short scenario summary",
"notes": ["component breakdown and assumptions"]
}
]
},
"notes": [
"Use seed-data.js as the current baseline for names, links, and budget assumptions.",
"Check hotels, flights, golf, nightlife, and excursions on every run before updating itinerary or budget recommendations.",
"Differentiate bundled package prices from standalone booking prices using bookingType and priceBasis on every price point.",
"For package quotes, list the included and excluded components so budgets do not double-count flights, hotels, transfers, or resort credits.",
"For standalone quotes, list the exact unit being priced: per night, per traveler, per person, per group, per round, or per table.",
"For per-night or per-day hotel/activity rates, calculate the total for the expected check-in/check-out dates and preserve the unit rate in unitPrice or displayLabel.",
"Itinerary and budget options are calculated outputs. Recompute them from the freshest current package and standalone component prices instead of treating seed-data.js totals as current.",
"Write a human-readable report to price-watch/latest-report.md on every run.",
"Append one machine-readable summary line per run to price-watch/history.jsonl, including per-option price points, derivedItineraries, and budgetScenarios keyed by stable option ids or seed keys.",
"Capture structured option details when available: current price, availability, source, sourceUrl, highlights, features, amenities, inclusions, limitations, and a short decision note.",
"If a source is gated behind login or membership, note that clearly in both outputs."
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
const SEED_VERSION = 6;
const SEED_VERSION = 2;
const PRICE_UPDATED_AT = '2026-04-29';
const CATEGORY_META = {
hotel: { emoji: '🏨', color: '#3b82f6' },
flight: { emoji: '✈️', color: '#38bdf8' },
golf: { emoji: '⛳', color: '#22c55e' },
nightlife: { emoji: '🎧', color: '#a855f7' },
excursion: { emoji: '🚤', color: '#06b6d4' },
@@ -12,23 +11,6 @@ const CATEGORY_META = {
results: { emoji: '🏆', color: '#facc15' },
};
const GUEST_ROSTER = [
{ name: 'Jon', last4: '7506', role: 'groom' },
{ name: 'Toph', last4: '8116', role: 'best-man' },
{ name: 'Hans', last4: '6681', role: 'guest' },
{ name: 'Janno', last4: '2809', role: 'guest' },
{ name: 'JT', last4: '3286', role: 'guest' },
{ name: 'Cordero', last4: '0379', role: 'guest' },
{ name: 'Lester', last4: '8014', role: 'guest' },
{ name: 'Nick', last4: '6044', role: 'guest' },
{ name: 'David', last4: '5993', role: 'guest' },
{ name: 'Poalo', last4: '9922', role: 'guest' },
{ name: 'Justin', last4: '2329', role: 'guest' },
{ name: 'Ben Stewart', last4: '1957', role: 'guest' },
{ name: 'Joseph', last4: '4976', role: 'guest' },
{ name: 'Francis', last4: '4934', role: 'guest' },
];
const BUDGET_SCENARIOS = [
{
id: 'budget-8',
@@ -199,7 +181,6 @@ function buildSeedData() {
priceUpdatedAt: PRICE_UPDATED_AT,
categories: [
{ id: 'hotel', name: 'Hotels', emoji: '🏨' },
{ id: 'flight', name: 'Flights', emoji: '✈️' },
{ id: 'golf', name: 'Golf', emoji: '⛳' },
{ id: 'nightlife', name: 'Nightlife', emoji: '🎧' },
{ id: 'excursion', name: 'Excursions', emoji: '🚤' },
@@ -207,7 +188,6 @@ function buildSeedData() {
{ id: 'budget', name: 'Budget', emoji: '💸' },
{ id: 'results', name: 'Results', emoji: '🏆' },
],
guestRoster: GUEST_ROSTER,
budgetScenarios: BUDGET_SCENARIOS,
options: [
createOption({
@@ -218,7 +198,7 @@ function buildSeedData() {
desc: 'Best party-first base on Medano Beach. Walkable to downtown and Costco package pages currently show transfer-inclusive offers plus 4th or 5th night promos.',
lat: 23.0639,
lng: -109.6991,
details: ['Costco package availability only', 'KAYAK no fresh rates', 'Walk to marina nightlife'],
details: ['KAYAK recent rooms $173-$551/night', 'Costco package', 'Walk to marina nightlife'],
links: [
{ label: 'Official', url: 'https://www.corazoncabo.com/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSCORAZON20230510' },
@@ -233,12 +213,12 @@ function buildSeedData() {
desc: 'Adults-only, marina-facing, and the easiest all-inclusive pick for a true bachelor-party vibe.',
lat: 23.0628,
lng: -109.6981,
details: ['Apple exact-date quote: $2,016 pp', 'Costco package: $1,678.99 pp', 'Adults-only'],
details: ['Apple Vacations from $942 pp / 3 nights', 'KAYAK from $393/night', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/breathless/mexico/cabo-san-lucas-resort-spa/' },
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/breathless-cabo-san-lucas/brcsl' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSBREATHE20230330' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=4&mode=0&onsaleid=1398047&traveldate=2027-02-03' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/hotels/breathless-cabo-san-lucas-resort-spa-all-inclusive-adults-only?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&los=3&mode=0&onsaleid=1398047&traveldate=2026-05-10' },
],
}),
createOption({
@@ -249,11 +229,11 @@ function buildSeedData() {
desc: 'Best overall balance for golf + all-inclusive + quality. Strong fit if the group wants one easy answer without going full splurge.',
lat: 23.0949,
lng: -109.7067,
details: ['Apple exact-date quote: $2,111 pp', 'KAYAK from $212/night', 'Golf-friendly'],
details: ['Apple Vacations from $859 pp / 3 nights', 'KAYAK from $209/night', 'Golf-friendly'],
links: [
{ label: 'Official', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Official', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=4&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=2027-02-03&vendorcode=APV' },
{ label: 'Apple Vacations', url: 'https://www.applevacations.com/HotelById?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=43000&los=3&mode=0&onsaleid=1614780&redirect=false&remotesourcecode=HBSHotel&traveldate=&vendorcode=APV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Grand-Fiesta-Americana-Los-Cabos-Golf-Spa.331383.ksp' },
],
}),
@@ -265,11 +245,11 @@ function buildSeedData() {
desc: 'Upscale adults-only pick with strong group-trip polish. Better for a luxe weekend than a chaos-first party hotel.',
lat: 23.0227,
lng: -109.7062,
details: ['KAYAK exact-date room rate: $335/night', 'Costco package: $2,005.80 pp', 'Adults-only'],
details: ['CheapCaribbean from $885 pp / 3 nights', '4-night examples from $1,108 pp', 'Adults-only'],
links: [
{ label: 'Official', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/secrets/mexico/puerto-los-cabos-golf-spa-resort/' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSSECRETSPUERT20230330' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=4&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=2027-02-03&vendorcode=CCV' },
{ label: 'CheapCaribbean', url: 'https://www.cheapcaribbean.com/HotelById/?cachefaretype=&destinationcode=SJD&dynamicpackageid=H01&hotelcode=SJDSCRT&los=3&mode=0&onsaleid=2173329&redirect=false&remotesourcecode=LtmsHotel&traveldate=&vendorcode=CCV' },
{ label: 'KAYAK', url: 'https://www.kayak.com/San-Jose-del-Cabo-Hotels-Secrets-Puerto-Los-Cabos-Adults-Only.551846.ksp' },
],
}),
@@ -287,171 +267,6 @@ function buildSeedData() {
{ label: 'Quivira FAQ', url: 'https://www.pueblobonito.com/resorts/pacifica/faq' },
],
}),
createOption({
id: 'hotel-dreams-los-cabos',
seedKey: 'hotel-dreams-los-cabos',
categoryId: 'hotel',
name: 'Dreams Los Cabos Suites Golf Resort & Spa',
desc: 'Balanced all-inclusive option with the cleanest Apple and Costco pricing signal from today.',
details: ['Apple exact-date quote: $1,757 pp', 'Costco package: $1,447.80 pp', 'All-inclusive'],
links: [
{ label: 'Hyatt Inclusive', url: 'https://www.hyattinclusivecollection.com/en/resorts-hotels/dreams/mexico/los-cabos-suites-golf-resort-spa/' },
],
}),
createOption({
id: 'hotel-zoetry-casa-del-mar',
seedKey: 'hotel-zoetry-casa-del-mar',
categoryId: 'hotel',
name: 'Zoetry Casa del Mar',
desc: 'Higher-end adults-only pick that sits in the luxe tier without going fully maxed out.',
details: ['Apple exact-date quote: $1,944 pp', 'Costco package: $1,717.42 pp', 'Adults-only'],
links: [
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/zoetry-casa-del-mar/zocdm' },
],
}),
createOption({
id: 'hotel-hyatt-ziva-los-cabos',
seedKey: 'hotel-hyatt-ziva-los-cabos',
categoryId: 'hotel',
name: 'Hyatt Ziva Los Cabos',
desc: 'Family-friendly luxury option that still works for a big group if the trip tilts more polished than rowdy.',
details: ['Apple exact-date quote: $2,178 pp', 'Beachfront', 'All-inclusive'],
links: [
{ label: 'Hyatt', url: 'https://www.hyatt.com/en-US/hotel/mexico/hyatt-ziva-los-cabos/sjdif' },
],
}),
createOption({
id: 'hotel-riu-palace-cabo-san-lucas',
seedKey: 'hotel-riu-palace-cabo-san-lucas',
categoryId: 'hotel',
name: 'Riu Palace Cabo San Lucas',
desc: 'Value-forward all-inclusive with a more party-friendly profile than the luxury adults-only resorts.',
details: ['Apple exact-date quote: $1,529 pp', 'Party-friendly all-inclusive', 'Value pick'],
links: [
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-cabo-san-lucas/' },
],
}),
createOption({
id: 'hotel-riu-palace-baja-california',
seedKey: 'hotel-riu-palace-baja-california',
categoryId: 'hotel',
name: 'Riu Palace Baja California',
desc: 'Adults-only RIU choice with a cleaner energy than Cabo San Lucas while staying in the value band.',
details: ['Apple exact-date quote: $1,597 pp', 'Adults-only', 'RIU all-inclusive'],
links: [
{ label: 'RIU', url: 'https://www.riu.com/en/hotel/mexico/los-cabos/hotel-riu-palace-baja-california/' },
],
}),
createOption({
id: 'hotel-me-cabo-by-melia',
seedKey: 'hotel-me-cabo-by-melia',
categoryId: 'hotel',
name: 'ME Cabo by Meliá',
desc: 'Beach-club leaning stay for the group that wants energy and location over quiet luxury.',
details: ['Apple exact-date quote: $1,533 pp', 'Beach club energy', 'Medano Beach'],
links: [
{ label: 'ME Cabo', url: 'https://www.hotelmecabo.com/' },
],
}),
createOption({
id: 'hotel-paradisus-los-cabos',
seedKey: 'hotel-paradisus-los-cabos',
categoryId: 'hotel',
name: 'Paradisus Los Cabos',
desc: 'Upscale all-inclusive with strong amenities and a better balance than the ultra-luxe splurge properties.',
details: ['Apple exact-date quote: $1,722 pp', 'Spa-forward', 'Adults-friendly luxury'],
links: [
{ label: 'Paradisus', url: 'https://www.paradisusloscabosresort.com/' },
],
}),
createOption({
id: 'hotel-hard-rock-los-cabos',
seedKey: 'hotel-hard-rock-los-cabos',
categoryId: 'hotel',
name: 'Hard Rock Hotel Los Cabos',
desc: 'The loudest splurge option from todays Apple search, with the highest quoted price on the list.',
details: ['Apple exact-date quote: $3,343 pp', 'Premium splurge', 'High-energy all-inclusive'],
links: [
{ label: 'Hard Rock', url: 'https://hotel.hardrock.com/los-cabos' },
],
}),
createOption({
id: 'hotel-solmar-resort',
seedKey: 'hotel-solmar-resort',
categoryId: 'hotel',
name: 'Solmar Resort',
desc: 'Low-cost KAYAK option if the group wants to keep the room line item very lean.',
details: ['KAYAK exact-date quote: $185/night', 'Budget-friendly', 'Downtown-adjacent'],
links: [
{ label: 'Solmar', url: 'https://www.solmar.com/en/hotels/cabo-san-lucas/solmar-resort/' },
],
}),
createOption({
id: 'hotel-tesoro-los-cabos',
seedKey: 'hotel-tesoro-los-cabos',
categoryId: 'hotel',
name: 'Tesoro Los Cabos',
desc: 'Marina-side value stay that landed in the middle of the KAYAK result set today.',
details: ['KAYAK exact-date quote: $250/night', 'Marina access', 'Low-mid budget'],
links: [
{ label: 'Tesoro', url: 'https://tesoroloscabos.com/' },
],
}),
createOption({
id: 'hotel-grand-solmar-lands-end',
seedKey: 'hotel-grand-solmar-lands-end',
categoryId: 'hotel',
name: "Grand Solmar Land's End Resort & Spa",
desc: 'Luxury Pacific-side resort with a stronger price tag than the value stays but below the ultra-splurge properties.',
details: ['KAYAK exact-date quote: $712/night', 'Luxury', 'Pacific-side'],
links: [
{ label: 'Grand Solmar', url: 'https://grandsolmarresort.solmar.com/' },
],
}),
createOption({
id: 'hotel-comfort-inn-suites-los-cabos',
seedKey: 'hotel-comfort-inn-suites-los-cabos',
categoryId: 'hotel',
name: 'Comfort Inn & Suites Los Cabos',
desc: 'Bare-bones KAYAK option if the group wants a practical bed-and-shower stay.',
details: ['KAYAK exact-date quote: $129/night', 'Budget stay', 'Practical'],
links: [
{ label: 'KAYAK', url: 'https://www.kayak.com/Cabo-San-Lucas-Hotels-Comfort-Inn-Suites-Los-Cabos.1071781145.ksp' },
],
}),
createOption({
id: 'hotel-capital-o-hotel-dos-mares',
seedKey: 'hotel-capital-o-hotel-dos-mares',
categoryId: 'hotel',
name: 'Capital O Hotel Dos Mares, Cabo San Lucas',
desc: 'Lowest visible KAYAK price of the day, useful only if the group is aggressively minimizing room cost.',
details: ['KAYAK exact-date quote: $48/night', 'Lowest visible price', 'Budget'],
links: [
{ label: 'OYO', url: 'https://www.oyorooms.com/mx/92226/' },
],
}),
createOption({
id: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
seedKey: 'hotel-villa-del-palmar-beach-resort-cabo-san-lucas',
categoryId: 'hotel',
name: 'Villa del Palmar Beach Resort Cabo San Lucas',
desc: 'Broad-appeal beach resort with a middle-of-the-road KAYAK room price today.',
details: ['KAYAK exact-date quote: $460/night', 'Beach resort', 'Family-friendly'],
links: [
{ label: 'Villa del Palmar', url: 'https://cabo.villadelpalmar.com/' },
],
}),
createOption({
id: 'hotel-esperanza-auberge-collection',
seedKey: 'hotel-esperanza-auberge-collection',
categoryId: 'hotel',
name: 'Esperanza, Auberge Collection',
desc: 'Top-end KAYAK splurge result from today, priced well above the other options in the set.',
details: ['KAYAK exact-date quote: $3,243/night', 'Luxury splurge', 'Auberge Collection'],
links: [
{ label: 'Auberge', url: 'https://aubergeresorts.com/esperanza/' },
],
}),
createOption({
id: 'golf-palmilla',
seedKey: 'golf-palmilla',
@@ -642,7 +457,7 @@ function buildSeedData() {
lng: -109.7067,
details: ['8 guys: about $1,688 pp', '10 guys: about $1,681 pp', '12 guys: about $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Package', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
{ label: 'Sailing', url: 'https://www.cabovillas.com/water-tours/cabo-sailing' },
],
@@ -697,7 +512,7 @@ function buildSeedData() {
desc: 'Grand Fiesta all-inclusive + better golf + sunset sail + one nightlife push. Strongest overall bachelor-weekend value.',
details: ['8: $1,688 pp', '10: $1,681 pp', '12: $1,677 pp'],
links: [
{ label: 'Grand Fiesta', url: 'https://www.fiestamericanatravelty.com/en/grand-fiesta-americana/hotels/grand-fiesta-americana-los-cabos-all-inclusive-golf-and-spa' },
{ label: 'Grand Fiesta', url: 'https://www.fiestamericana.com/en/hotels/grand-fiesta-americana-los-cabos' },
{ label: 'Costco Travel', url: 'https://www.costcotravel.com/Vacation-Packages/Offers/MEXLOSCABOSGRANDFIESTA20171011' },
],
}),
@@ -756,7 +571,6 @@ function mergeSeedData(existing = {}) {
priceUpdatedAt: seed.priceUpdatedAt,
categories: [...seed.categories, ...preservedCustomCategories],
budgetScenarios: seed.budgetScenarios,
guestRoster: seed.guestRoster,
options: [...mergedSeedOptions, ...preservedCustomOptions],
voters: Array.isArray(existing.voters) ? existing.voters : [],
pollsOpen: typeof existing.pollsOpen === 'boolean' ? existing.pollsOpen : true,

778
server.js
View File

@@ -1,7 +1,6 @@
const express = require('express');
const { WebSocketServer } = require('ws');
const cors = require('cors');
const crypto = require('crypto');
const http = require('http');
const path = require('path');
const fs = require('fs');
@@ -12,19 +11,8 @@ const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
const DEFAULT_DATA_DIR = path.join(__dirname, 'data');
const DATA_DIR = process.env.DATA_DIR
? path.resolve(process.env.DATA_DIR)
: DEFAULT_DATA_DIR;
const DATA_FILE = process.env.DATA_FILE
? path.resolve(process.env.DATA_FILE)
: path.join(DATA_DIR, 'votes.json');
const DEFAULT_PRICE_HISTORY_FILE = path.join(__dirname, 'price-watch', 'history.jsonl');
const PRICE_HISTORY_FILE = process.env.PRICE_HISTORY_FILE
? path.resolve(process.env.PRICE_HISTORY_FILE)
: DEFAULT_PRICE_HISTORY_FILE;
const TRIP_CHECK_IN = process.env.TRIP_CHECK_IN || '2027-02-03';
const TRIP_CHECK_OUT = process.env.TRIP_CHECK_OUT || '2027-02-07';
const DATA_DIR = path.join(__dirname, 'data');
const DATA_FILE = path.join(DATA_DIR, 'votes.json');
app.use(cors());
app.use(express.json());
@@ -58,613 +46,6 @@ function saveData(nextData) {
fs.writeFileSync(DATA_FILE, JSON.stringify(nextData, null, 2));
}
function normalizeKey(value) {
return String(value || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function normalizeGuestPin(value) {
return String(value || '').replace(/\D/g, '').slice(-4);
}
function normalizeGuestName(value) {
return normalizeKey(value).replace(/-/g, ' ');
}
function normalizeSourceLabel(value) {
return String(value || 'Unknown source').trim() || 'Unknown source';
}
function formatCurrencyValue(value, currency = 'USD') {
if (typeof value !== 'number' || !Number.isFinite(value)) return '';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value);
}
function normalizeBookingType(value) {
const normalized = normalizeKey(value || '');
if (['package', 'standalone', 'calculated'].includes(normalized)) return normalized;
if (normalized.includes('package') || normalized.includes('bundle')) return 'package';
if (normalized.includes('calculated') || normalized.includes('derived')) return 'calculated';
return 'standalone';
}
function inferBookingType(point, defaults = {}) {
if (point.bookingType || point.booking_type || point.productType || defaults.bookingType) {
return point.bookingType || point.booking_type || point.productType || defaults.bookingType;
}
const haystack = [
point.source,
point.sourceLabel,
point.vendor,
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
].filter(Boolean).join(' ').toLowerCase();
if (haystack.includes('costco') || haystack.includes('apple vacation') || haystack.includes('cheapcaribbean')) {
return 'package';
}
if (haystack.includes('package') || haystack.includes('flight+hotel') || haystack.includes('flight + hotel')) {
return 'package';
}
if (haystack.includes('automation calculation')) return 'calculated';
return 'standalone';
}
const GUEST_AUTH_SECRET = process.env.GUEST_AUTH_SECRET || 'cabo-bachelor-party-guest-auth';
function getGuestRoster() {
return Array.isArray(data.guestRoster) ? data.guestRoster : [];
}
function findGuestByNameAndPin(name, pin) {
const normalizedName = normalizeGuestName(name);
const normalizedPin = normalizeGuestPin(pin);
return getGuestRoster().find((guest) => (
normalizeGuestName(guest.name) === normalizedName
&& normalizeGuestPin(guest.last4) === normalizedPin
)) || null;
}
function signGuestToken(guest) {
const payload = {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
issuedAt: Date.now(),
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url');
const signature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
return `${encodedPayload}.${signature}`;
}
function verifyGuestToken(token) {
if (typeof token !== 'string' || !token.includes('.')) return null;
const [encodedPayload, signature] = token.split('.');
if (!encodedPayload || !signature) return null;
const expectedSignature = crypto
.createHmac('sha256', GUEST_AUTH_SECRET)
.update(encodedPayload)
.digest('base64url');
const sigA = Buffer.from(signature);
const sigB = Buffer.from(expectedSignature);
if (sigA.length !== sigB.length || !crypto.timingSafeEqual(sigA, sigB)) return null;
try {
const payload = JSON.parse(Buffer.from(encodedPayload, 'base64url').toString('utf8'));
const guest = findGuestByNameAndPin(payload.name, payload.last4);
if (!guest) return null;
return {
name: guest.name,
last4: normalizeGuestPin(guest.last4),
role: guest.role || 'guest',
};
} catch {
return null;
}
}
function readGuestAuthToken(req, body = {}) {
const bodyToken = body.authToken || body.guestToken || body.token;
if (typeof bodyToken === 'string' && bodyToken.trim()) return bodyToken.trim();
const headerToken = req.headers['x-guest-auth'];
if (typeof headerToken === 'string' && headerToken.trim()) return headerToken.trim();
const authorization = req.headers.authorization || '';
if (authorization.toLowerCase().startsWith('bearer ')) {
return authorization.slice(7).trim();
}
return null;
}
function requireGuestAuth(req, res, body = {}) {
const guest = verifyGuestToken(readGuestAuthToken(req, body));
if (!guest) {
res.status(401).json({ error: 'Guest authentication required' });
return null;
}
return guest;
}
const HISTORY_KEY_ALIASES = {
'costco-breathless': 'hotel-breathless',
'costco-grand-fiesta': 'hotel-grand-fiesta',
'costco-secrets': 'hotel-secrets',
'costco-corazon': 'hotel-corazon',
'costco-pacifica': 'hotel-pacifica',
'costco-dreams': 'hotel-dreams-los-cabos',
'costco-zoetry': 'hotel-zoetry-casa-del-mar',
'costco-hard-rock': 'hotel-hard-rock-los-cabos',
};
function toTextList(value) {
const items = Array.isArray(value) ? value : value == null ? [] : [value];
return [...new Set(items.flatMap((item) => {
if (Array.isArray(item)) {
return item;
}
if (item && typeof item === 'object') {
return [
item.label,
item.name,
item.text,
item.title,
item.value,
item.summary,
item.description,
].filter(Boolean);
}
return [item];
})
.map((item) => String(item).trim())
.filter(Boolean))];
}
function readJsonLines(filePath) {
if (!fs.existsSync(filePath)) return [];
return fs.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(Boolean);
}
function latestNonEmptyArray(runs, fieldNames) {
for (let index = runs.length - 1; index >= 0; index -= 1) {
for (const fieldName of fieldNames) {
if (Array.isArray(runs[index][fieldName]) && runs[index][fieldName].length) {
return runs[index][fieldName];
}
}
}
return null;
}
function getOptionHistoryKeys(option) {
const nameKey = normalizeKey(option.name);
const categoryNameKey = option.categoryId && nameKey ? `${option.categoryId}-${nameKey}` : '';
const rawKeys = [
option.seedKey,
option.id,
option.priceKey,
option.optionKey,
option.slug,
categoryNameKey,
nameKey,
].filter(Boolean).map(normalizeKey);
return [...new Set(rawKeys.flatMap((key) => [key, HISTORY_KEY_ALIASES[key] || null].filter(Boolean)))];
}
function extractNumericPrice(point) {
const candidates = [
point.price,
point.value,
point.amount,
point.perPerson,
point.groupTotal,
];
for (const candidate of candidates) {
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate;
if (typeof candidate === 'string') {
const parsed = parseNumericPriceFromText(candidate);
if (Number.isFinite(parsed)) return parsed;
}
}
const textCandidates = [
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
point.note,
point.description,
].filter(Boolean);
for (const candidate of textCandidates) {
const parsed = parseNumericPriceFromText(candidate, point.priceBasis || point.price_basis || point.unit);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function parseNumericPriceFromText(value, priceBasis = '') {
if (typeof value !== 'string') return null;
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized || /\b(no|not)\s+(fresh\s+)?(price|rates?|available|counted|visible|captured)\b/i.test(normalized)) {
return null;
}
const matches = [...normalized.matchAll(/\$?\s*([0-9]{1,3}(?:,[0-9]{3})*(?:\.[0-9]{1,2})?|[0-9]+(?:\.[0-9]{1,2})?)/g)]
.map((match) => ({
value: Number(match[1].replace(/,/g, '')),
index: match.index || 0,
}))
.filter((match) => Number.isFinite(match.value));
if (!matches.length) return null;
const basis = normalizeKey(priceBasis);
if (basis === 'totalpackage' || basis === 'pergroup') {
return matches.at(-1).value;
}
const travelerMatch = matches.find((match) => (
/per\s+(traveler|person|guest|adult|round|table|night)|pp|\/night/i.test(normalized.slice(match.index, match.index + 80))
));
if (travelerMatch) return travelerMatch.value;
return matches[0].value;
}
function inferPriceBasis(point, defaults = {}) {
if (point.priceBasis || point.price_basis || point.unit || defaults.priceBasis) {
return point.priceBasis || point.price_basis || point.unit || defaults.priceBasis;
}
const haystack = [
point.displayPrice,
point.displayLabel,
point.priceLabel,
point.label,
point.note,
point.description,
].filter(Boolean).join(' ').toLowerCase();
if (haystack.includes('/night') || haystack.includes('per night')) return 'perNight';
if (haystack.includes('per traveler')) return 'perTraveler';
if (haystack.includes('per person') || /\bpp\b/.test(haystack)) return 'perPerson';
if (haystack.includes('per round')) return 'perRound';
if (haystack.includes('per table')) return 'perTable';
if (haystack.includes('total') || haystack.includes('package')) return 'totalPackage';
const bookingType = normalizeBookingType(inferBookingType(point, defaults));
if (bookingType === 'package') return 'perTraveler';
if (bookingType === 'calculated') return 'perPerson';
return null;
}
function getTripNightCount() {
const checkInMs = Date.parse(`${TRIP_CHECK_IN}T00:00:00Z`);
const checkOutMs = Date.parse(`${TRIP_CHECK_OUT}T00:00:00Z`);
if (Number.isNaN(checkInMs) || Number.isNaN(checkOutMs) || checkOutMs <= checkInMs) return 1;
return Math.max(1, Math.round((checkOutMs - checkInMs) / (24 * 60 * 60 * 1000)));
}
function isStayUnitPrice(priceBasis) {
const normalized = normalizeKey(priceBasis);
return ['pernight', 'perday', 'nightly', 'daily'].includes(normalized);
}
function normalizeTripPrice({ price, priceBasis, currency, displayPrice }) {
if (!isStayUnitPrice(priceBasis)) {
return {
price,
unitPrice: price,
tripTotalPrice: price,
displayPrice,
tripNights: null,
};
}
const tripNights = getTripNightCount();
const tripTotalPrice = Number((price * tripNights).toFixed(2));
const unitLabel = displayPrice || `${formatCurrencyValue(price, currency)}/night`;
return {
price: tripTotalPrice,
unitPrice: price,
tripTotalPrice,
displayPrice: `${formatCurrencyValue(tripTotalPrice, currency)} stay total (${unitLabel} x ${tripNights} nights)`,
tripNights,
};
}
function loadPriceHistoryState() {
const runs = readJsonLines(PRICE_HISTORY_FILE)
.map((entry, index) => {
const checkedAtRaw = entry.checkedAt || entry.checked_at || entry.runAt || entry.timestamp || entry.date || null;
const checkedAtMs = checkedAtRaw ? Date.parse(checkedAtRaw) : Date.now() + index;
return {
...entry,
checkedAt: Number.isNaN(checkedAtMs) ? null : new Date(checkedAtMs).toISOString(),
checkedAtMs: Number.isNaN(checkedAtMs) ? Date.now() + index : checkedAtMs,
};
})
.sort((a, b) => a.checkedAtMs - b.checkedAtMs);
runs.forEach((run, index) => {
run.runIndex = index;
});
const seriesByKey = new Map();
const addPointToSeries = (run, point, defaults = {}) => {
const key = normalizeKey(
point.optionKey || point.optionId || point.seedKey || point.slug || point.key || point.name,
);
const price = extractNumericPrice({
...defaults,
...point,
});
if (!key || price === null) return;
const currency = point.currency || defaults.currency || 'USD';
const priceBasis = inferPriceBasis(point, defaults);
const rawDisplayPrice = point.displayPrice || point.priceLabel || point.displayLabel || point.label || defaults.displayPrice || null;
const tripPrice = normalizeTripPrice({
price,
priceBasis,
currency,
displayPrice: rawDisplayPrice,
});
const nextPoint = {
checkedAt: run.checkedAt,
checkedAtMs: run.checkedAtMs,
runIndex: run.runIndex,
price: tripPrice.price,
unitPrice: tripPrice.unitPrice,
tripTotalPrice: tripPrice.tripTotalPrice,
tripNights: tripPrice.tripNights,
tripCheckIn: tripPrice.tripNights ? TRIP_CHECK_IN : null,
tripCheckOut: tripPrice.tripNights ? TRIP_CHECK_OUT : null,
currency,
displayPrice: tripPrice.displayPrice,
unitDisplayPrice: rawDisplayPrice,
source: normalizeSourceLabel(point.source || point.sourceLabel || point.vendor || defaults.source || null),
sourceKey: normalizeKey(point.sourceKey || point.sourceId || point.source || point.sourceLabel || point.vendor || defaults.sourceKey || 'unknown-source'),
sourceUrl: point.sourceUrl || point.url || defaults.sourceUrl || null,
bookingType: normalizeBookingType(inferBookingType(point, defaults)),
priceBasis,
includedComponents: toTextList(point.includedComponents || point.includesComponents || point.componentsIncluded || defaults.includedComponents),
excludedComponents: toTextList(point.excludedComponents || point.componentsExcluded || defaults.excludedComponents),
origin: point.origin || point.originAirport || defaults.origin || null,
destination: point.destination || point.destinationAirport || defaults.destination || null,
note: point.note || point.description || null,
availability: point.availability || point.status || null,
decisionNote: point.decisionNote || point.note || point.description || defaults.decisionNote || null,
highlights: toTextList(point.highlights || point.summaryBullets || point.bullets || defaults.highlights),
features: toTextList(point.features || point.featureHighlights || point.featureLabels || defaults.features),
amenities: toTextList(point.amenities || point.amenityHighlights || point.amenityLabels || defaults.amenities),
inclusions: toTextList(point.inclusions || point.includes || point.perks || defaults.inclusions),
limitations: toTextList(point.limitations || point.tradeoffs || point.caveats || defaults.limitations),
};
if (!seriesByKey.has(key)) seriesByKey.set(key, []);
seriesByKey.get(key).push(nextPoint);
};
runs.forEach((run) => {
const pricePoints = Array.isArray(run.optionPrices)
? run.optionPrices
: Array.isArray(run.prices)
? run.prices
: Array.isArray(run.trackedPrices)
? run.trackedPrices
: [];
pricePoints.forEach((point) => {
addPointToSeries(run, point);
});
const derivedItineraries = Array.isArray(run.derivedItineraries)
? run.derivedItineraries
: Array.isArray(run.itineraryScenarios)
? run.itineraryScenarios
: [];
derivedItineraries.forEach((itinerary) => {
addPointToSeries(run, itinerary, {
bookingType: 'calculated',
priceBasis: 'perPerson',
source: 'Automation calculation',
sourceKey: 'automation-calculation',
displayPrice: typeof itinerary.perPerson === 'number' ? `$${itinerary.perPerson.toLocaleString()} pp` : null,
price: itinerary.perPerson,
decisionNote: itinerary.summary,
highlights: itinerary.assumptions,
inclusions: itinerary.components,
});
});
});
seriesByKey.forEach((series) => {
series.sort((a, b) => a.runIndex - b.runIndex);
});
return {
runs,
seriesByKey,
budgetScenarios: latestNonEmptyArray(runs, ['budgetScenarios', 'derivedBudgetScenarios']),
derivedItineraries: latestNonEmptyArray(runs, ['derivedItineraries', 'itineraryScenarios']),
latestCheckedAt: runs.at(-1)?.checkedAt || null,
totalRuns: runs.length,
};
}
function buildPriceHistoryBySource(priceHistory) {
const grouped = new Map();
priceHistory.forEach((point) => {
const sourceKey = normalizeKey(point.sourceKey || point.source || 'unknown-source');
const sourceLabel = normalizeSourceLabel(point.source || point.sourceLabel || sourceKey);
if (!grouped.has(sourceKey)) {
grouped.set(sourceKey, {
sourceKey,
sourceLabel,
sourceUrl: point.sourceUrl || null,
points: [],
});
}
const bucket = grouped.get(sourceKey);
bucket.points.push({
...point,
sourceKey,
source: sourceLabel,
});
if (!bucket.sourceUrl && point.sourceUrl) bucket.sourceUrl = point.sourceUrl;
if (bucket.sourceLabel === 'Unknown source' && sourceLabel) bucket.sourceLabel = sourceLabel;
});
const seriesBySource = {};
const sourceSummaries = [...grouped.values()].map((bucket) => {
bucket.points.sort((a, b) => a.runIndex - b.runIndex);
seriesBySource[bucket.sourceKey] = bucket.points;
const latestPoint = bucket.points.at(-1) || null;
return {
sourceKey: bucket.sourceKey,
sourceLabel: bucket.sourceLabel,
sourceUrl: bucket.sourceUrl,
bookingType: latestPoint?.bookingType || null,
priceBasis: latestPoint?.priceBasis || null,
pointCount: bucket.points.length,
latestCheckedAt: latestPoint?.checkedAt || null,
latestCheckedAtMs: latestPoint?.checkedAtMs || 0,
latestPrice: latestPoint?.price ?? null,
latestDisplayPrice: latestPoint?.displayPrice || null,
currency: latestPoint?.currency || 'USD',
};
}).sort((a, b) => {
const aMs = a.latestCheckedAtMs || 0;
const bMs = b.latestCheckedAtMs || 0;
if (aMs !== bMs) return bMs - aMs;
return a.sourceLabel.localeCompare(b.sourceLabel);
});
return {
seriesBySource,
sourceSummaries,
};
}
function getPriceHistoryForOption(option, priceHistoryState) {
const optionKeys = getOptionHistoryKeys(option);
for (const key of optionKeys) {
const series = priceHistoryState.seriesByKey.get(key);
if (series && series.length) {
return series;
}
}
return [];
}
function decorateOptionWithPriceHistory(option, priceHistoryState) {
const priceHistory = getPriceHistoryForOption(option, priceHistoryState);
const { seriesBySource, sourceSummaries } = buildPriceHistoryBySource(priceHistory);
const defaultSourceSummary = sourceSummaries[0] || null;
const defaultSourceKey = defaultSourceSummary?.sourceKey || null;
const defaultPriceHistory = defaultSourceKey ? seriesBySource[defaultSourceKey] || [] : priceHistory;
const latestPricePoint = defaultPriceHistory.at(-1) || null;
const optionDetails = toTextList(option.details);
const automationHighlights = toTextList(latestPricePoint?.highlights);
const automationFeatures = toTextList(latestPricePoint?.features);
const automationAmenities = toTextList(latestPricePoint?.amenities);
const automationInclusions = toTextList(latestPricePoint?.inclusions);
const automationLimitations = toTextList(latestPricePoint?.limitations);
const decisionDetails = [
...optionDetails,
...automationHighlights,
...automationFeatures,
...automationAmenities,
...automationInclusions,
...automationLimitations,
];
return {
...option,
priceHistory: defaultPriceHistory,
priceHistoryBySource: seriesBySource,
availableSources: sourceSummaries,
defaultSourceKey,
currentSourceKey: defaultSourceKey,
latestPricePoint,
currentPrice: latestPricePoint?.price ?? null,
decisionDetails: [...new Set(decisionDetails)],
automationInsights: latestPricePoint ? {
currentPrice: latestPricePoint.price,
currency: latestPricePoint.currency || 'USD',
displayPrice: latestPricePoint.displayPrice || null,
source: latestPricePoint.source || null,
sourceUrl: latestPricePoint.sourceUrl || null,
bookingType: latestPricePoint.bookingType || null,
priceBasis: latestPricePoint.priceBasis || null,
includedComponents: latestPricePoint.includedComponents || [],
excludedComponents: latestPricePoint.excludedComponents || [],
availability: latestPricePoint.availability || null,
decisionNote: latestPricePoint.decisionNote || null,
highlights: automationHighlights,
features: automationFeatures,
amenities: automationAmenities,
inclusions: automationInclusions,
limitations: automationLimitations,
} : null,
};
}
function decorateOptionsWithPriceHistory(options, priceHistoryState) {
return options.map((option) => decorateOptionWithPriceHistory(option, priceHistoryState));
}
function approvedOptionsWithVoteSummary() {
return data.options
.filter((option) => option.approved)
@@ -683,24 +64,15 @@ function broadcast(payload) {
}
function buildRealtimeSnapshot() {
const priceHistoryState = loadPriceHistoryState();
const approvedOptions = data.options.filter((option) => option.approved);
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
return {
type: 'init',
pollsOpen: data.pollsOpen,
categories: data.categories,
guestRoster: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
options: decorateOptionsWithPriceHistory(approvedOptions, priceHistoryState),
options: data.options.filter((option) => option.approved),
results: approvedOptionsWithVoteSummary(),
totalVoters: data.voters.length,
budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: data.priceUpdatedAt || null,
};
}
@@ -725,45 +97,6 @@ function createUserOption({ categoryId, name, desc, url, voterName, lat, lng, ap
let data = loadData();
app.get('/api/auth/guests', (req, res) => {
res.json({
guests: getGuestRoster().map((guest) => ({
name: guest.name,
role: guest.role || 'guest',
})),
});
});
app.get('/api/auth/me', (req, res) => {
const guest = verifyGuestToken(readGuestAuthToken(req));
if (!guest) {
return res.status(401).json({ authenticated: false });
}
res.json({
authenticated: true,
guest,
});
});
app.post('/api/auth/login', (req, res) => {
const { name, pin } = req.body || {};
const guest = findGuestByNameAndPin(name, pin);
if (!guest) {
return res.status(401).json({ error: 'Invalid guest name or code' });
}
res.json({
success: true,
token: signGuestToken(guest),
guest: {
name: guest.name,
role: guest.role || 'guest',
},
});
});
app.get('/api/categories', (req, res) => {
res.json(data.categories);
});
@@ -771,69 +104,43 @@ app.get('/api/categories', (req, res) => {
app.get('/api/options', (req, res) => {
const { category, includeUnapproved } = req.query;
let options = data.options;
const priceHistoryState = loadPriceHistoryState();
if (category) options = options.filter((option) => option.categoryId === category);
if (!includeUnapproved) options = options.filter((option) => option.approved);
res.json(decorateOptionsWithPriceHistory(options, priceHistoryState));
res.json(options);
});
app.get('/api/results', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const budgetScenarios = priceHistoryState.budgetScenarios || data.budgetScenarios || [];
const results = data.categories.map((category) => ({
...category,
options: data.options
.filter((option) => option.approved && option.categoryId === category.id)
.map((option) => ({
...decorateOptionWithPriceHistory(option, priceHistoryState),
voteCount: option.votes.length,
})),
.map((option) => ({ ...option, voteCount: option.votes.length })),
}));
res.json({
pollsOpen: data.pollsOpen,
results,
totalVoters: data.voters.length,
budgetScenarios,
priceUpdatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
priceHistoryRunCount: priceHistoryState.totalRuns,
budgetScenarios: data.budgetScenarios || [],
priceUpdatedAt: data.priceUpdatedAt || null,
});
});
app.get('/api/budgets', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
res.json({
updatedAt: priceHistoryState.latestCheckedAt || data.priceUpdatedAt || null,
scenarios: priceHistoryState.budgetScenarios || data.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
});
});
app.get('/api/price-history', (req, res) => {
const priceHistoryState = loadPriceHistoryState();
const seriesByOption = Object.fromEntries(
[...priceHistoryState.seriesByKey.entries()],
);
res.json({
latestCheckedAt: priceHistoryState.latestCheckedAt,
totalRuns: priceHistoryState.totalRuns,
seriesByOption,
budgetScenarios: priceHistoryState.budgetScenarios || [],
derivedItineraries: priceHistoryState.derivedItineraries || [],
updatedAt: data.priceUpdatedAt || null,
scenarios: data.budgetScenarios || [],
});
});
app.post('/api/vote', (req, res) => {
const { optionId } = req.body;
const guest = requireGuestAuth(req, res, req.body);
const { optionId, voterName } = req.body;
if (!optionId) {
if (!voterName || !optionId) {
return res.status(400).json({ error: 'Missing fields' });
}
if (!guest) return;
if (!data.pollsOpen) {
return res.status(403).json({ error: 'Polls are closed' });
}
@@ -845,17 +152,17 @@ app.post('/api/vote', (req, res) => {
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === guest.name)
&& candidate.votes.some((vote) => vote.name === voterName)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
}
option.votes.push({ name: guest.name, timestamp: Date.now() });
option.votes.push({ name: voterName, timestamp: Date.now() });
if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() });
if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: voterName, joinedAt: Date.now() });
}
saveData(data);
@@ -864,26 +171,23 @@ app.post('/api/vote', (req, res) => {
});
app.delete('/api/vote/:optionId', (req, res) => {
const guest = requireGuestAuth(req, res, req.body);
if (!guest) return;
const { voterName } = req.body;
const option = data.options.find((candidate) => candidate.id === req.params.optionId);
if (!option) return res.status(404).json({ error: 'Not found' });
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
option.votes = option.votes.filter((vote) => vote.name !== voterName);
saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
res.json({ success: true });
});
app.post('/api/options', (req, res) => {
const { categoryId, name, desc, url, lat, lng } = req.body;
const guest = requireGuestAuth(req, res, req.body);
const { categoryId, name, desc, url, voterName, lat, lng } = req.body;
if (!categoryId || !name) {
if (!categoryId || !name || !voterName) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!guest) return;
const category = data.categories.find((candidate) => candidate.id === categoryId);
if (!category) return res.status(404).json({ error: 'Category not found' });
@@ -893,7 +197,7 @@ app.post('/api/options', (req, res) => {
name,
desc,
url,
voterName: guest.name,
voterName,
lat,
lng,
approved: false,
@@ -994,12 +298,8 @@ wss.on('connection', (ws) => {
const msg = JSON.parse(raw);
if (msg.type === 'vote') {
const { optionId, remove } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
if (!guest || !optionId) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
const { optionId, voterName, remove } = msg;
if (!voterName || !optionId) return;
if (!data.pollsOpen) {
ws.send(JSON.stringify({ type: 'error', message: 'Polls closed' }));
return;
@@ -1009,39 +309,35 @@ wss.on('connection', (ws) => {
if (!option || !option.approved) return;
if (remove) {
option.votes = option.votes.filter((vote) => vote.name !== guest.name);
option.votes = option.votes.filter((vote) => vote.name !== voterName);
} else {
const previousVote = data.options.find((candidate) => (
candidate.categoryId === option.categoryId
&& candidate.votes.some((vote) => vote.name === guest.name)
&& candidate.votes.some((vote) => vote.name === voterName)
));
if (previousVote) {
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== guest.name);
previousVote.votes = previousVote.votes.filter((vote) => vote.name !== voterName);
}
option.votes.push({ name: guest.name, timestamp: Date.now() });
option.votes.push({ name: voterName, timestamp: Date.now() });
}
if (!data.voters.find((voter) => voter.name === guest.name)) {
data.voters.push({ name: guest.name, joinedAt: Date.now() });
if (!data.voters.find((voter) => voter.name === voterName)) {
data.voters.push({ name: voterName, joinedAt: Date.now() });
}
saveData(data);
broadcast({ type: 'vote_update', results: approvedOptionsWithVoteSummary() });
} else if (msg.type === 'add_option') {
const { categoryId, name, desc, url, lat, lng } = msg;
const guest = verifyGuestToken(msg.authToken || msg.guestToken || null);
if (!guest || !categoryId || !name) {
ws.send(JSON.stringify({ type: 'error', message: 'Guest authentication required' }));
return;
}
const { categoryId, name, desc, url, voterName, lat, lng } = msg;
if (!categoryId || !name || !voterName) return;
const newOption = createUserOption({
categoryId,
name,
desc,
url,
voterName: guest.name,
voterName,
lat,
lng,
approved: true,
@@ -1058,8 +354,6 @@ wss.on('connection', (ws) => {
});
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || '0.0.0.0';
server.listen(PORT, HOST, () => {
console.log(`🏄 Cabo Voting App → http://${HOST}:${PORT}`);
server.listen(PORT, '0.0.0.0', () => {
console.log(`🏄 Cabo Voting App → http://0.0.0.0:${PORT}`);
});