From 1926839a4b632bb7505b18db0fed227a884c1972 Mon Sep 17 00:00:00 2001 From: Christopher Mayor Date: Thu, 30 Apr 2026 18:12:27 -0700 Subject: [PATCH] [ice] budget tab: dynamic live prices from details, hide options without scraped data --- client/src/components/BudgetTab.css | 21 +- client/src/components/BudgetTab.jsx | 392 ++++++++++++++++++---------- 2 files changed, 280 insertions(+), 133 deletions(-) diff --git a/client/src/components/BudgetTab.css b/client/src/components/BudgetTab.css index f2ed28f..70aed02 100644 --- a/client/src/components/BudgetTab.css +++ b/client/src/components/BudgetTab.css @@ -70,6 +70,16 @@ 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; @@ -136,12 +146,21 @@ .breakdown-row { display: grid; - grid-template-columns: 1fr repeat(3, 80px); + 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; } diff --git a/client/src/components/BudgetTab.jsx b/client/src/components/BudgetTab.jsx index 9a3d887..4fc8997 100644 --- a/client/src/components/BudgetTab.jsx +++ b/client/src/components/BudgetTab.jsx @@ -1,69 +1,137 @@ import { useMemo } from 'react' import './BudgetTab.css' -// Map options to cost tiers based on seed data pricing signals -const TIER_BY_OPTION = { - // Hotels - 'hotel-corazon': 'budget', - 'hotel-grand-fiesta': 'balanced', - 'hotel-pacifica': 'splurge', - 'hotel-breathless': 'splurge', - 'hotel-secrets': 'splurge', - // Golf - 'golf-palmilla': 'budget', - 'golf-cabo-del-sol': 'balanced', - 'golf-quivira': 'splurge', - 'golf-puerto-los-cabos': 'balanced', - // Nightlife - 'nightlife-mango-deck': 'budget', - 'nightlife-cabo-bash': 'balanced', - 'nightlife-cabo-agency': 'balanced', - 'nightlife-taboo': 'splurge', - // Excursions - 'excursion-whale-public': 'budget', - 'excursion-atv': 'balanced', - 'excursion-sail': 'balanced', - 'excursion-whale-private': 'splurge', +// ─── 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 } -// Cost per person by category and tier (from seed data planning numbers) -const COST_PER_PERSON = { - hotel: { budget: 450, balanced: 850, splurge: 1250 }, - golf: { budget: 130, balanced: 180, splurge: 250 }, - nightlife: { budget: 40, balanced: 100, splurge: 200 }, - excursion: { budget: 76, balanced: 110, splurge: 188 }, - // Fixed costs - flight: 350, - transfers: { budget: 33, balanced: 83, splurge: 183 }, - foodBuffer: 275, +// ─── 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) } -const GROUP_SIZES = [8, 10, 12] +// ─── Resolve per-person cost for an option ─────────────────────────────────── -const CATEGORY_LABELS = { +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', } -function getOptionTier(optionId) { - return TIER_BY_OPTION[optionId] || 'balanced' -} +const GROUP_SIZES = [8, 10, 12] -function calcTierTotal(tier, groupSize) { - const hotel = COST_PER_PERSON.hotel[tier] - const golf = COST_PER_PERSON.golf[tier] - const nightlife = COST_PER_PERSON.nightlife[tier] - const excursion = COST_PER_PERSON.excursion[tier] - const flight = COST_PER_PERSON.flight - const transfer = COST_PER_PERSON.transfers[tier] - const food = COST_PER_PERSON.foodBuffer - - const total = flight + hotel + golf + nightlife + excursion + transfer + food - const perPerson = Math.round(total * 100) / 100 - return { total: perPerson * groupSize, perPerson } -} +// ─── Main component ───────────────────────────────────────────────────────── function getWinningOption(options, categoryId) { const catOpts = options.filter(o => o.categoryId === categoryId && o.approved) @@ -73,21 +141,48 @@ function getWinningOption(options, categoryId) { , catOpts[0]) } -function getTierFromCategoryVotes(options, categoryId) { - const winning = getWinningOption(options, categoryId) - if (!winning) return 'balanced' - return getOptionTier(winning.id) -} - 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' - // Find the most common tier 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'] @@ -99,67 +194,106 @@ export default function BudgetTab({ options, categories }) { 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 => { - t[cat] = getTierFromCategoryVotes(options, cat) + const leader = pricedLeaders[cat] + if (!leader) { t[cat] = 'balanced'; return } + const pp = resolvePerPerson(leader, null) + t[cat] = classifyTier(leader.id, pp) }) return t - }, [options]) + }, [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 tier = overallTier - const { perPerson, total } = calcTierTotal(tier, size) - return { size, tier, perPerson, total } + const sum = Object.values(costRows).reduce((acc, row) => acc + (row.pp || 0), 0) + return { size, perPerson: Math.round(sum), total: Math.round(sum * size) } }) - }, [overallTier]) + }, [costRows]) + + const hasPricedLeaders = Object.keys(pricedLeaders).length > 0 - const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' } const tierColor = { budget: '#22c55e', balanced: '#3b82f6', splurge: '#f97316' } - - const hasAnyVotes = Object.values(leaders).some(o => o && o.votes?.length > 0) + const tierLabel = { budget: '💰 Budget', balanced: '⚖️ Balanced', splurge: '💎 Splurge' } return (

Trip Budget Calculator

-

Costs update live based on leading votes in each category

+

Prices sourced from live travel data — updates as votes come in

- {/* Category leaders */} + {/* Category leaders with real prices */}
-

Current Leaders by Category

-
- {votingCategories.map(cat => { - const leader = leaders[cat] - const catTier = tiers[cat] - return ( -
-
{CATEGORY_LABELS[cat]}
- {leader && leader.votes?.length > 0 ? ( - <> -
{leader.name}
-
- {tierLabel[catTier]} -
-
{leader.votes.length} vote{leader.votes.length !== 1 ? 's' : ''}
- - ) : ( - <> -
No votes yet
-
- - )} -
- ) - })} -
+

Current Leaders — Live Prices

+ {Object.keys(pricedLeaders).length > 0 ? ( +
+ {votingCategories.map(cat => { + const leader = pricedLeaders[cat] + if (!leader) return null + const catTier = tiers[cat] + const priceLabel = getPriceLabel(leader) + return ( +
+
{CAT_LABELS[cat]}
+
{leader.name}
+ {priceLabel &&
{priceLabel}
} +
+ {tierLabel[catTier]} +
+
+ {leader.votes?.length > 0 + ? `${leader.votes.length} vote${leader.votes.length !== 1 ? 's' : ''}` + : 'no votes yet'} +
+
+ ) + })} +
+ ) : ( +
+
+

Waiting for pricing data. Options without live prices from travel sites are hidden until the scraper updates them.

+
+ )}
- {/* Tier selector — highlight dominant */} + {/* Tier summary pills */}
{['budget', 'balanced', 'splurge'].map(tier => (
- {/* Cost breakdown */} - {hasAnyVotes ? ( + {/* Dynamic cost breakdown */} + {hasPricedLeaders ? (

Estimated Cost — {tierLabel[overallTier]} Tier

Line Item + Source {GROUP_SIZES.map(s => {s} guys)}
-
- ✈️ Flight estimate - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.flight).toLocaleString()})} -
-
- 🏨 Hotel ({COST_PER_PERSON.hotel[overallTier]}/pp) - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.hotel[overallTier]).toLocaleString()})} -
-
- ⛳ Golf ({COST_PER_PERSON.golf[overallTier]}/pp) - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.golf[overallTier]).toLocaleString()})} -
-
- 🎧 Nightlife ({COST_PER_PERSON.nightlife[overallTier]}/pp) - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.nightlife[overallTier]).toLocaleString()})} -
-
- 🚤 Excursion ({COST_PER_PERSON.excursion[overallTier]}/pp) - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.excursion[overallTier]).toLocaleString()})} -
-
- 🚗 Transfers - {GROUP_SIZES.map(s => ${(COST_PER_PERSON.transfers[overallTier]).toLocaleString()})} -
-
- 🍽️ Food + drinks buffer - {GROUP_SIZES.map(s => $${COST_PER_PERSON.foodBuffer})} -
+ + {[ + costRows.flight, + costRows.hotel, + costRows.golf, + costRows.nightlife, + costRows.excursion, + costRows.transfer, + costRows.food, + ].map(row => row && ( +
+ {row.label} + {row.source || '—'} + {GROUP_SIZES.map(s => ( + {formatPrice(row.pp)} + ))} +
+ ))} +
- Est. Total / Person + Est. Per Person + sum {totals.map(t => ( - ${t.perPerson.toLocaleString()} + {formatPrice(t.perPerson)} ))}
Est. Group Total + {totals.map(t => ( - ${t.total.toLocaleString()} + {formatPrice(t.total)} ))}

- * Estimates based on current leading votes. Prices may vary. Add extra for bar tabs and tips. - Price data from {options.find(o => o.categoryId === 'hotel')?.details?.[0]?.match(/\d{4}-\d{2}-\d{2}/)?.[0] || 'April 2026'}. + * 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.

) : (
🗳️
-

No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically.

+

No votes cast yet. Vote in the Hotel, Golf, Nightlife, and Excursion tabs and the budget will update automatically using real scraped prices.

)}