fix: rewrite booking-sheet parser to match actual sheet structure
CI/CD / deploy (push) Failing after 1m23s Details

Sheet tab is 'WorldPlay' not 'Booking Sheet'. Dates use DD/MM/YYYY
format. Room/bed columns are at indices 2/4 not 0/1. Room numbers
carry down for multi-bed rooms. Added 'Couch' to hh-living bed types.
Changed valueInputOption to RAW to prevent formula injection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-14 15:18:38 -04:00
parent 6b8bf32c3b
commit c2b75cc5e9
1 changed files with 69 additions and 47 deletions

View File

@ -1,6 +1,12 @@
// Booking sheet integration for WORLDPLAY // Booking sheet integration for WORLDPLAY
// Manages bed assignments on a Google Sheets booking sheet // Manages bed assignments on a Google Sheets booking sheet
// Adapted from VotC booking-sheet.js for single-week event // Sheet structure (tab "WorldPlay"):
// Row: "Occupancy Commons Hub" (venue header)
// Row: '' | '' | room # | bed # | bed type | 07/06/2026 | 08/06/2026 | ...
// Row: '' | '' | 1 | 1 | single | | | ...
// (empty rows = section separator)
// Row: "Herrnhof Villa" (venue header)
// Row: Appt name | Appt # | room # | bed # | bed type | 07/06/2026 | ...
const fs = require('fs'); const fs = require('fs');
@ -18,7 +24,7 @@ const ACCOMMODATION_CRITERIA = {
}, },
'hh-living': { 'hh-living': {
venue: 'Herrnhof Villa', venue: 'Herrnhof Villa',
bedTypes: ['Living room', 'Sofa bed', 'Daybed'], bedTypes: ['Living room', 'Sofa bed', 'Daybed', 'Couch'],
}, },
'hh-triple': { 'hh-triple': {
venue: 'Herrnhof Villa', venue: 'Herrnhof Villa',
@ -38,12 +44,16 @@ const ACCOMMODATION_CRITERIA = {
}, },
}; };
// Per-day columns for WORLDPLAY (June 7-13) // Date columns on the sheet use DD/MM/YYYY format
const DAY_COLUMNS = ['Jun 7', 'Jun 8', 'Jun 9', 'Jun 10', 'Jun 11', 'Jun 12', 'Jun 13']; // Map our ISO date IDs to the sheet header strings
const DAY_ID_TO_LABEL = { const DAY_SHEET_HEADERS = [
'2026-06-07': 'Jun 7', '2026-06-08': 'Jun 8', '2026-06-09': 'Jun 9', '07/06/2026', '08/06/2026', '09/06/2026', '10/06/2026',
'2026-06-10': 'Jun 10', '2026-06-11': 'Jun 11', '2026-06-12': 'Jun 12', '11/06/2026', '12/06/2026', '13/06/2026',
'2026-06-13': 'Jun 13', ];
const DAY_ID_TO_SHEET_HEADER = {
'2026-06-07': '07/06/2026', '2026-06-08': '08/06/2026', '2026-06-09': '09/06/2026',
'2026-06-10': '10/06/2026', '2026-06-11': '11/06/2026', '2026-06-12': '12/06/2026',
'2026-06-13': '13/06/2026',
}; };
/** Convert 0-based column index to spreadsheet letter (supports A-Z, AA-AZ) */ /** Convert 0-based column index to spreadsheet letter (supports A-Z, AA-AZ) */
@ -90,15 +100,9 @@ async function getSheetsClient() {
/** /**
* Read and parse the booking sheet. * Read and parse the booking sheet.
* Expected structure per venue section:
* Row: "Commons Hub" or "Herrnhof Villa" (venue header)
* Row: Room | Bed Type | Week 1 (column headers)
* Row: 5 | Bunk up | (bed rows)
* ...
* Empty row (section separator)
*
* Returns array of bed objects: * Returns array of bed objects:
* { venue, room, bedType, rowIndex, dayColumns: { 'Jun 7': colIndex, ... }, occupancy: { 'Jun 7': 'Guest Name' | null, ... } } * { venue, room, bedType, rowIndex, dayColumns: { '07/06/2026': colIndex, ... },
* occupancy: { '07/06/2026': 'Guest Name' | null, ... } }
*/ */
async function parseBookingSheet() { async function parseBookingSheet() {
const sheets = await getSheetsClient(); const sheets = await getSheetsClient();
@ -112,52 +116,71 @@ async function parseBookingSheet() {
console.log('[Booking Sheet] No BOOKING_SHEET_ID configured — skipping'); console.log('[Booking Sheet] No BOOKING_SHEET_ID configured — skipping');
return null; return null;
} }
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet'; const sheetName = process.env.BOOKING_SHEET_TAB || 'WorldPlay';
const quotedName = `'${sheetName}'`;
const response = await sheets.spreadsheets.values.get({ const response = await sheets.spreadsheets.values.get({
spreadsheetId: sheetId, spreadsheetId: sheetId,
range: `${quotedName}!A:J`, range: `${sheetName}!A:L`,
}); });
const rows = response.data.values || []; const rows = response.data.values || [];
const beds = []; const beds = [];
let currentVenue = null; let currentVenue = null;
let dayColIndexes = {}; let dayColIndexes = {};
let currentRoom = null; // Room numbers carry down for beds in same room
let bedTypeCol = -1; // Column index for bed type (varies by section)
let roomCol = -1; // Column index for room #
for (let i = 0; i < rows.length; i++) { for (let i = 0; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) { if (!row || row.length === 0 || row.every(cell => !cell || !cell.toString().trim())) {
currentVenue = null; currentVenue = null;
dayColIndexes = {}; dayColIndexes = {};
currentRoom = null;
bedTypeCol = -1;
roomCol = -1;
continue; continue;
} }
const firstCell = (row[0] || '').toString().trim(); const firstCell = (row[0] || '').toString().trim();
// Check if this is a venue header // Check if this is a venue header
if (firstCell === 'Commons Hub' || firstCell === 'Herrnhof Villa') { if (firstCell.startsWith('Occupancy Commons Hub') || firstCell === 'Commons Hub') {
currentVenue = firstCell; currentVenue = 'Commons Hub';
dayColIndexes = {}; dayColIndexes = {};
currentRoom = null;
continue;
}
if (firstCell === 'Herrnhof Villa') {
currentVenue = 'Herrnhof Villa';
dayColIndexes = {};
currentRoom = null;
continue; continue;
} }
// Check if this is the column header row (contains "Room" and day columns) // Check if this is the column header row (look for date-formatted headers)
if (firstCell.toLowerCase() === 'room' && currentVenue) { if (currentVenue && !Object.keys(dayColIndexes).length) {
// Find date columns and structural columns
let foundDates = false;
for (let c = 0; c < row.length; c++) { for (let c = 0; c < row.length; c++) {
const header = (row[c] || '').toString().trim(); const header = (row[c] || '').toString().trim();
if (DAY_COLUMNS.includes(header)) { if (DAY_SHEET_HEADERS.includes(header)) {
dayColIndexes[header] = c; dayColIndexes[header] = c;
foundDates = true;
} }
if (header.toLowerCase() === 'room #') roomCol = c;
if (header.toLowerCase() === 'bed type') bedTypeCol = c;
} }
continue; if (foundDates) continue;
} }
// If we have a venue and day columns, this is a bed row // If we have a venue and day columns, this is a bed row
if (currentVenue && Object.keys(dayColIndexes).length > 0 && firstCell) { if (currentVenue && Object.keys(dayColIndexes).length > 0 && roomCol >= 0 && bedTypeCol >= 0) {
const room = firstCell; const roomCell = (row[roomCol] || '').toString().trim();
const bedType = (row[1] || '').toString().trim(); if (roomCell) currentRoom = roomCell;
if (!bedType) continue;
const bedType = (row[bedTypeCol] || '').toString().trim();
if (!bedType || !currentRoom) continue;
const occupancy = {}; const occupancy = {};
for (const [day, colIdx] of Object.entries(dayColIndexes)) { for (const [day, colIdx] of Object.entries(dayColIndexes)) {
@ -167,7 +190,7 @@ async function parseBookingSheet() {
beds.push({ beds.push({
venue: currentVenue, venue: currentVenue,
room, room: currentRoom,
bedType, bedType,
rowIndex: i, rowIndex: i,
dayColumns: { ...dayColIndexes }, dayColumns: { ...dayColIndexes },
@ -184,9 +207,9 @@ async function parseBookingSheet() {
* A bed is "available" if ALL selected day columns are empty. * A bed is "available" if ALL selected day columns are empty.
* @param {object[]} beds - parsed bed objects * @param {object[]} beds - parsed bed objects
* @param {string} accommodationType - e.g. 'ch-shared' * @param {string} accommodationType - e.g. 'ch-shared'
* @param {string[]} dayLabels - e.g. ['Jun 7', 'Jun 9', 'Jun 10'] * @param {string[]} dayHeaders - e.g. ['07/06/2026', '09/06/2026']
*/ */
function findAvailableBed(beds, accommodationType, dayLabels) { function findAvailableBed(beds, accommodationType, dayHeaders) {
const criteria = ACCOMMODATION_CRITERIA[accommodationType]; const criteria = ACCOMMODATION_CRITERIA[accommodationType];
if (!criteria) { if (!criteria) {
console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`); console.error(`[Booking Sheet] Unknown accommodation type: ${accommodationType}`);
@ -201,7 +224,7 @@ function findAvailableBed(beds, accommodationType, dayLabels) {
if (!matchesBedType) continue; if (!matchesBedType) continue;
// Check ALL selected days are empty for this bed // Check ALL selected days are empty for this bed
const allEmpty = dayLabels.every(day => !bed.occupancy[day]); const allEmpty = dayHeaders.every(day => !bed.occupancy[day]);
if (allEmpty) { if (allEmpty) {
return bed; return bed;
} }
@ -224,13 +247,13 @@ async function assignBooking(guestName, accommodationType, selectedDays) {
return { success: false, reason: 'Missing accommodation type' }; return { success: false, reason: 'Missing accommodation type' };
} }
// Convert selectedDays to day labels // Convert selectedDays to sheet header date strings
let dayLabels; let dayHeaders;
if (selectedDays === 'full-week' || !selectedDays) { if (selectedDays === 'full-week' || !selectedDays) {
dayLabels = [...DAY_COLUMNS]; // all 7 days dayHeaders = [...DAY_SHEET_HEADERS]; // all 7 days
} else { } else {
dayLabels = selectedDays.map(id => DAY_ID_TO_LABEL[id]).filter(Boolean); dayHeaders = selectedDays.map(id => DAY_ID_TO_SHEET_HEADER[id]).filter(Boolean);
if (dayLabels.length === 0) { if (dayHeaders.length === 0) {
return { success: false, reason: 'No valid days selected' }; return { success: false, reason: 'No valid days selected' };
} }
} }
@ -241,26 +264,25 @@ async function assignBooking(guestName, accommodationType, selectedDays) {
return { success: false, reason: 'Booking sheet not configured' }; return { success: false, reason: 'Booking sheet not configured' };
} }
const bed = findAvailableBed(beds, accommodationType, dayLabels); const bed = findAvailableBed(beds, accommodationType, dayHeaders);
if (!bed) { if (!bed) {
console.warn(`[Booking Sheet] No available bed for ${accommodationType} on days: ${dayLabels.join(', ')}`); console.warn(`[Booking Sheet] No available bed for ${accommodationType} on days: ${dayHeaders.join(', ')}`);
return { success: false, reason: 'No available bed matching criteria' }; return { success: false, reason: 'No available bed matching criteria' };
} }
const sheets = await getSheetsClient(); const sheets = await getSheetsClient();
const sheetId = process.env.BOOKING_SHEET_ID; const sheetId = process.env.BOOKING_SHEET_ID;
const sheetName = process.env.BOOKING_SHEET_TAB || 'Booking Sheet'; const sheetName = process.env.BOOKING_SHEET_TAB || 'WorldPlay';
const quotedName = `'${sheetName}'`;
const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed const rowNum = bed.rowIndex + 1; // Sheets is 1-indexed
// Build batch update data for each selected day column // Build batch update data for each selected day column
const data = []; const data = [];
for (const dayLabel of dayLabels) { for (const dayHeader of dayHeaders) {
const colIdx = bed.dayColumns[dayLabel]; const colIdx = bed.dayColumns[dayHeader];
if (colIdx === undefined) continue; if (colIdx === undefined) continue;
const colLetter = colIdxToLetter(colIdx); const colLetter = colIdxToLetter(colIdx);
data.push({ data.push({
range: `${quotedName}!${colLetter}${rowNum}`, range: `${sheetName}!${colLetter}${rowNum}`,
values: [[guestName]], values: [[guestName]],
}); });
} }
@ -272,12 +294,12 @@ async function assignBooking(guestName, accommodationType, selectedDays) {
await sheets.spreadsheets.values.batchUpdate({ await sheets.spreadsheets.values.batchUpdate({
spreadsheetId: sheetId, spreadsheetId: sheetId,
resource: { resource: {
valueInputOption: 'USER_ENTERED', valueInputOption: 'RAW',
data, data,
}, },
}); });
console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayLabels.length} days`); console.log(`[Booking Sheet] Assigned ${guestName} to ${bed.venue} Room ${bed.room} (${bed.bedType}) for ${dayHeaders.length} days`);
return { return {
success: true, success: true,