feat: dynamic accommodation availability + overbooking alerts

- Add checkAvailability() with 2-min cache to booking-sheet.js
- Add GET /api/accommodation-availability endpoint (weeks-based)
- Fetch availability on accommodation toggle/week change, disable sold-out rooms
- Redirect booking notifications to jeff@jeffemmett.com for testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 11:48:31 -04:00
parent ccc79c0489
commit 78b2ba0499
4 changed files with 126 additions and 2 deletions

View File

@ -6,6 +6,10 @@ const fs = require('fs');
let sheetsClient = null;
// In-memory cache for parsed booking sheet (2-min TTL)
let sheetCache = { data: null, timestamp: 0 };
const CACHE_TTL_MS = 2 * 60 * 1000;
// Accommodation criteria mapping — maps accommodation_type to sheet matching rules
const ACCOMMODATION_CRITERIA = {
'ch-multi': {
@ -164,6 +168,38 @@ async function parseBookingSheet() {
return beds;
}
/**
* Cached wrapper around parseBookingSheet().
*/
async function getCachedBeds() {
const now = Date.now();
if (sheetCache.data && (now - sheetCache.timestamp) < CACHE_TTL_MS) {
return sheetCache.data;
}
const beds = await parseBookingSheet();
if (beds) {
sheetCache.data = beds;
sheetCache.timestamp = now;
}
return beds;
}
/**
* Check availability of all accommodation types for the given weeks.
* @param {string[]} selectedWeeks - e.g. ['week1', 'week2'] or ['week1','week2','week3','week4'] for full month
* @returns {object|null} Map of accommodation type boolean (available)
*/
async function checkAvailability(selectedWeeks) {
const beds = await getCachedBeds();
if (!beds) return null;
const availability = {};
for (const type of Object.keys(ACCOMMODATION_CRITERIA)) {
availability[type] = !!findAvailableBed(beds, type, selectedWeeks);
}
return availability;
}
/**
* Find an available bed matching the accommodation criteria for the given weeks.
* A bed is "available" only if ALL requested week columns are empty.
@ -257,6 +293,10 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
});
}
// Invalidate cache after successful assignment
sheetCache.data = null;
sheetCache.timestamp = 0;
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for weeks: ${selectedWeeks.join(', ')}`);
return {
@ -271,4 +311,4 @@ async function assignBooking(guestName, accommodationType, selectedWeeks) {
}
}
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, ACCOMMODATION_CRITERIA };
module.exports = { assignBooking, parseBookingSheet, findAvailableBed, checkAvailability, ACCOMMODATION_CRITERIA };

View File

@ -371,9 +371,10 @@ async function handleWebhook(req, res) {
};
try {
const bookingAlertEmail = process.env.BOOKING_ALERT_EMAIL || 'jeff@jeffemmett.com';
await smtp.sendMail({
from: process.env.EMAIL_FROM || 'Valley of the Commons <contact@valleyofthecommons.com>',
to: 'team@valleyofthecommons.com',
to: bookingAlertEmail,
subject: bookingNotification.subject,
html: bookingNotification.html,
});

View File

@ -177,6 +177,28 @@
.week-card .dates { font-size: 0.8rem; color: #666; margin-bottom: 0.5rem; }
.week-card .desc { font-size: 0.85rem; color: #555; }
.week-card.sold-out {
opacity: 0.5;
pointer-events: none;
position: relative;
}
.week-card.sold-out::after {
content: 'Sold Out';
position: absolute;
top: 1rem;
right: 1rem;
background: rgba(200, 50, 50, 0.12);
border: 1px solid rgba(200, 50, 50, 0.4);
color: #c83232;
font-size: 0.75rem;
font-weight: 600;
padding: 0.2rem 0.6rem;
border-radius: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Select-all card */
.select-all-card {
border-style: dashed;
@ -1329,6 +1351,34 @@
}
// ===== Week selection =====
// Fetch and apply accommodation availability based on selected weeks
async function fetchAndApplyAvailability() {
const weeks = getSelectedWeeks();
if (weeks.length === 0) return;
try {
const res = await fetch(`/api/accommodation-availability?weeks=${encodeURIComponent(weeks.join(','))}`);
if (!res.ok) return;
const availability = await res.json();
document.querySelectorAll('input[name="room_type"]').forEach(radio => {
const type = radio.value;
const card = radio.closest('.week-card');
if (type in availability && !availability[type]) {
card.classList.add('sold-out');
card.classList.remove('selected');
radio.checked = false;
if (document.getElementById('accommodation_type').value === type) {
document.getElementById('accommodation_type').value = '';
updatePriceSummary();
}
} else {
card.classList.remove('sold-out');
}
});
} catch (e) {
// Network error — leave all options enabled
}
}
function toggleWeek(card) {
const cb = card.querySelector('input');
cb.checked = !cb.checked;
@ -1336,6 +1386,8 @@
syncSelectAll();
updatePriceSummary();
saveFormData();
// Re-check availability when weeks change
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function toggleAllWeeks(card) {
@ -1350,6 +1402,7 @@
});
updatePriceSummary();
saveFormData();
if (document.getElementById('need_accommodation').checked) fetchAndApplyAvailability();
}
function syncSelectAll() {
@ -1376,6 +1429,8 @@
document.querySelectorAll('input[name="room_type"]').forEach(r => { r.checked = false; r.closest('.week-card').classList.remove('selected'); });
document.getElementById('ch-rooms').style.display = 'none';
document.getElementById('hh-rooms').style.display = 'none';
} else {
fetchAndApplyAvailability();
}
}
@ -1402,6 +1457,10 @@
}
function selectRoom(roomType) {
// Prevent selecting sold-out rooms
const radio = document.querySelector(`input[name="room_type"][value="${roomType}"]`);
if (radio && radio.closest('.week-card').classList.contains('sold-out')) return;
document.querySelectorAll('input[name="room_type"]').forEach(r => {
r.checked = (r.value === roomType);
r.closest('.week-card').classList.toggle('selected', r.checked);

View File

@ -50,6 +50,30 @@ app.post('/api/mollie/webhook', vercelToExpress(handleWebhook));
app.all('/api/mollie/status', vercelToExpress(getPaymentStatus));
app.get('/api/mollie/resume', vercelToExpress(resumePayment));
// Accommodation availability check
const { checkAvailability } = require('./api/booking-sheet');
const VALID_WEEKS = new Set(['week1', 'week2', 'week3', 'week4']);
app.get('/api/accommodation-availability', async (req, res) => {
try {
const weeksParam = (req.query.weeks || '').trim();
if (!weeksParam) {
return res.status(400).json({ error: 'weeks parameter required (e.g. week1,week2)' });
}
const selectedWeeks = weeksParam.split(',').filter(w => VALID_WEEKS.has(w));
if (selectedWeeks.length === 0) {
return res.status(400).json({ error: 'No valid weeks provided' });
}
const availability = await checkAvailability(selectedWeeks);
if (!availability) {
return res.status(503).json({ error: 'Booking sheet not configured' });
}
res.json(availability);
} catch (error) {
console.error('Availability check error:', error);
res.status(500).json({ error: 'Failed to check availability' });
}
});
// Static files
app.use(express.static(path.join(__dirname), {
extensions: ['html'],