/**
* =========================================================
* AUTO SOCIAL MEDIA POSTS โ Automation #16
* Flavors Driving School
* =========================================================
* Auto-generates congrats social posts when students complete
* all lessons. Queue for Instagram, Facebook, Google Business.
*
* Features:
* - Demo mode with fake graduates (no real data during demos)
* - Email-first + fuzzy name matching
* - Attendance counting skips cancelled/no-show/pending/scheduled/upcoming/rescheduled
* - Mission Control themed admin emails with unsubscribe
* - Graduate ordinal tracking ("our 1st graduate!")
* - Configurable post days (Tue/Wed/Thu default)
* - Weekly digest email
* - New graduate notification email
* - Manual: generatePostForStudent(), previewPosts()
*
* SHEETS (in Schedule Board spreadsheet):
* - Social Post Queue
* - Posted Students
* - Social Settings
*
* TRIGGERS: dailySocialCheck @ 11 AM daily, weeklyPostDigest Monday 9 AM
* =========================================================
*/
// โโ CONFIG โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const CONFIG = {
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_EMAIL: '[email protected]',
SCHOOL_HANDLE: '@flavorsdrivingschool',
ADMIN_EMAIL: '[email protected]',
BOOKING_URL: '',
// Sheet tab names
BOOKINGS_SHEET_TAB: 'Bookings',
REGISTRATION_SHEET_TAB: '',
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
};
const PACKAGES = {
'3 Lessons': 3,
'5 Lessons': 5,
'10 Lessons': 10,
'15 Lessons': 15,
'25 Lessons': 25,
'5-Hour Class': 1,
};
const HASHTAGS = {
primary: '#FlavorsDrivingSchool',
extra: ['#DrivingSchool', '#LearnToDrive', '#NewDriver', '#SafeDriving', '#QueensNY'],
};
// โโ DEMO DATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const DEMO = {
graduates: [
{ name: 'Sarah Johnson', ordinal: '47th' },
{ name: 'Marcus Williams', ordinal: '48th' },
],
existingCount: 46,
};
// โโ FORCE AUTH โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized โ 3 sheets + email.');
}
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function fullSetup() {
setupSocialSheets_();
setupTriggers_();
Logger.log('โ
Auto Social Media Posts fully set up.');
}
function setupSocialSheets_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
let queue = ss.getSheetByName('Social Post Queue');
if (!queue) {
queue = ss.insertSheet('Social Post Queue');
queue.getRange('A1:E1').setValues([['Platform', 'Suggested Date', 'Content', 'Student Name', 'Status']]);
formatHeader_(queue, 5);
}
let posted = ss.getSheetByName('Posted Students');
if (!posted) {
posted = ss.insertSheet('Posted Students');
posted.getRange('A1:C1').setValues([['Student Name', 'Graduate Date', 'Posted']]);
formatHeader_(posted, 3);
}
let settings = ss.getSheetByName('Social Settings');
if (!settings) {
settings = ss.insertSheet('Social Settings');
settings.getRange('A1:B1').setValues([['Setting', 'Value']]);
formatHeader_(settings, 2);
const defaults = [
['Demo Mode', CONFIG.DEMO_MODE ? 'Yes' : 'No'],
['School Name', CONFIG.SCHOOL_NAME],
['School Email', CONFIG.SCHOOL_EMAIL],
['School Handle', CONFIG.SCHOOL_HANDLE],
['Admin Email', CONFIG.ADMIN_EMAIL],
['Send New Posts Email', 'Yes'],
['Post Day 1', 'Tuesday'],
['Post Day 2', 'Wednesday'],
['Post Day 3', 'Thursday'],
['Default Post Time', '11:00 AM'],
['Total Graduates', '0'],
];
settings.getRange(2, 1, defaults.length, 2).setValues(defaults);
settings.getRange(2, 2).setFontColor('#f59e0b').setFontWeight('bold').setFontSize(12);
settings.getRange(2, 1, defaults.length, 2).setBackground('#0a0a0a');
settings.getRange(2, 1, defaults.length, 1).setFontColor('#888');
settings.getRange(2, 2, defaults.length, 1).setFontColor('#fff').setFontWeight('bold');
settings.setColumnWidth(1, 260);
settings.setColumnWidth(2, 200);
}
Logger.log('โ
Social sheets created/verified.');
}
function setupTriggers_() {
const clean = ['dailySocialCheck', 'weeklyPostDigest'];
ScriptApp.getProjectTriggers().forEach(t => {
if (clean.includes(t.getHandlerFunction())) ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('dailySocialCheck').timeBased().everyDays(1).atHour(11).nearMinute(0).create();
ScriptApp.newTrigger('weeklyPostDigest').timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).nearMinute(0).create();
Logger.log('โ
Triggers: dailySocialCheck 11 AM daily, weeklyPostDigest Monday 9 AM');
}
function formatHeader_(sheet, cols) {
sheet.getRange(1, 1, 1, cols).setFontWeight('bold').setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontSize(10);
sheet.setFrozenRows(1);
}
// ================================================================
// DAILY CHECK (trigger)
// ================================================================
function dailySocialCheck() {
try {
const settings = getSettings_();
const isDemo = settings.demoMode;
if (isDemo) {
Logger.log('โ ๏ธ DEMO MODE โ using fake graduate data.');
}
const graduates = isDemo ? DEMO.graduates : findNewGraduates_();
if (graduates.length === 0) {
Logger.log('No new graduates today.');
return;
}
const board = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const queueSheet = board.getSheetByName('Social Post Queue');
const postedSheet = board.getSheetByName('Posted Students');
const platforms = ['Instagram', 'Facebook', 'Google Business'];
const postDate = suggestPostDate_(settings);
for (const grad of graduates) {
const posts = generatePosts_(grad.name, grad.ordinal, settings);
for (const platform of platforms) {
const content = getTemplateForPlatform_(posts, platform);
if (content) {
if (!isDemo) {
queueSheet.appendRow([platform, postDate, content, grad.name, 'Queued']);
}
}
}
if (!isDemo) {
postedSheet.appendRow([grad.name, new Date(), 'No']);
}
Logger.log((isDemo ? 'DEMO: Would queue' : 'Queued') + ' posts for ' + grad.name + ' (' + grad.ordinal + ' graduate)');
}
if (!isDemo) {
updateTotalGraduates_(board, graduates.length);
}
if (settings.sendNewPostsEmail && settings.adminEmail) {
sendNewPostsEmail_(graduates, settings, isDemo);
}
Logger.log((isDemo ? 'DEMO: Would queue' : 'Queued') + ' posts for ' + graduates.length + ' new graduate(s).');
} catch (e) {
Logger.log('โ dailySocialCheck error: ' + e.message + '\n' + e.stack);
try {
if (CONFIG.ADMIN_EMAIL) {
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: 'โ Auto Social Posts โ Daily check failed',
body: 'Error: ' + (e.message || e.toString()),
});
}
} catch (mailErr) { Logger.log('Could not send error email: ' + mailErr.message); }
}
}
// ================================================================
// WEEKLY DIGEST (trigger)
// ================================================================
function weeklyPostDigest() {
try {
const settings = getSettings_();
if (settings.demoMode) {
Logger.log('โ ๏ธ DEMO MODE โ skipping weekly digest email.');
return;
}
const board = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const queueSheet = board.getSheetByName('Social Post Queue');
if (!queueSheet) return;
const data = queueSheet.getDataRange().getValues();
if (data.length < 2) { Logger.log('No posts in queue.'); return; }
if (!settings.adminEmail) return;
let rows = '';
for (let i = 1; i < data.length; i++) {
const platform = escHtml_(String(data[i][0] || '').trim());
const rawDate = data[i][1];
const dateStr = rawDate instanceof Date
? Utilities.formatDate(rawDate, Session.getScriptTimeZone(), 'MMM d, yyyy')
: escHtml_(String(rawDate || ''));
const content = escHtml_(String(data[i][2] || '').substring(0, 200));
const student = escHtml_(String(data[i][3] || '').trim());
const status = escHtml_(String(data[i][4] || '').trim());
rows +=
'<tr>' +
'<td style="color:#ccc;padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;">' + platform + '</td>' +
'<td style="color:#ccc;padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;">' + dateStr + '</td>' +
'<td style="color:#fff;padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;font-weight:500;">' + student + '</td>' +
'<td style="color:#888;padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:12px;">' + content + '</td>' +
'<td style="color:' + (status.toLowerCase() === 'posted' ? '#22c55e' : '#f59e0b') + ';padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:12px;font-weight:600;">' + status + '</td>' +
'</tr>';
}
const tableContent =
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0;">' +
'<tr>' +
'<td style="color:#ff2d2d;padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,0.06);">Platform</td>' +
'<td style="color:#ff2d2d;padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,0.06);">Date</td>' +
'<td style="color:#ff2d2d;padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,0.06);">Student</td>' +
'<td style="color:#ff2d2d;padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,0.06);">Content</td>' +
'<td style="color:#ff2d2d;padding:8px 12px;font-size:10px;text-transform:uppercase;letter-spacing:1px;border-bottom:1px solid rgba(255,255,255,0.06);">Status</td>' +
'</tr>' + rows + '</table>' +
'<p style="color:#555;font-size:13px;margin-top:16px;">' + (data.length - 1) + ' total posts in queue</p>';
const html = missionControlWrapper_('Weekly Post Digest', tableContent);
MailApp.sendEmail({
to: settings.adminEmail,
subject: '๐ฑ Social Post Queue โ Weekly Digest โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html,
});
Logger.log('โ
Weekly digest sent.');
} catch (e) {
Logger.log('โ weeklyPostDigest error: ' + e.message + '\n' + e.stack);
}
}
// ================================================================
// FIND NEW GRADUATES
// ================================================================
function findNewGraduates_() {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const regSheet = CONFIG.REGISTRATION_SHEET_TAB
? regSS.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB)
: regSS.getSheets()[0];
if (!regSheet) return [];
const regData = regSheet.getDataRange().getValues();
if (regData.length < 2) return [];
const headers = regData[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['full name', 'name', 'student name']);
const emailCol = findCol_(headers, ['email', 'email address', 'student email']);
const packageCol = findCol_(headers, ['lesson package', 'package', 'selected package', 'class type']);
const students = [];
for (let i = 1; i < regData.length; i++) {
const name = String(regData[i][nameCol] || '').trim();
const email = emailCol !== -1 ? String(regData[i][emailCol] || '').trim().toLowerCase() : '';
const pkgRaw = packageCol !== -1 ? String(regData[i][packageCol] || '').trim() : '';
if (!name) continue;
const lessonCount = extractLessonCount_(pkgRaw);
students.push({ name, email, lessonCount, nameKey: name.toLowerCase() });
}
// Count completed lessons per student
const board = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookingsSheet = CONFIG.BOOKINGS_SHEET_TAB
? board.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB)
: board.getSheets()[0];
if (!bookingsSheet) return [];
const bookData = bookingsSheet.getDataRange().getValues();
if (bookData.length < 2) return [];
const bookHeaders = bookData[0].map(h => String(h).toLowerCase().trim());
const bookNameCol = findCol_(bookHeaders, ['student', 'student name', 'name']);
const bookEmailCol = findCol_(bookHeaders, ['email', 'student email']);
const bookStatusCol = findCol_(bookHeaders, ['status', 'booking status']);
const bookDateCol = findCol_(bookHeaders, ['date', 'lesson date', 'booking date']);
// Only count confirmed past lessons
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'pending', 'scheduled', 'upcoming', 'rescheduled'];
const today = new Date();
// Build completed count by name and email
const completedByName = {};
const completedByEmail = {};
for (let i = 1; i < bookData.length; i++) {
const status = String(bookData[i][bookStatusCol] || '').toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
const rawDate = bookData[i][bookDateCol];
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (isNaN(date.getTime()) || date > today) continue;
const sName = String(bookData[i][bookNameCol] || '').trim().toLowerCase();
const sEmail = bookEmailCol !== -1 ? String(bookData[i][bookEmailCol] || '').trim().toLowerCase() : '';
if (sName) completedByName[sName] = (completedByName[sName] || 0) + 1;
if (sEmail && sEmail.includes('@')) completedByEmail[sEmail] = (completedByEmail[sEmail] || 0) + 1;
}
// Get already-posted students
const postedSheet = board.getSheetByName('Posted Students');
const postedSet = new Set();
if (postedSheet && postedSheet.getLastRow() > 1) {
const postedData = postedSheet.getDataRange().getValues();
for (let i = 1; i < postedData.length; i++) {
const postedName = String(postedData[i][0] || '').trim().toLowerCase();
if (postedName) postedSet.add(postedName);
}
}
const existingCount = postedSheet ? Math.max(0, postedSheet.getLastRow() - 1) : 0;
const graduates = [];
for (const st of students) {
// Skip if already posted (exact or fuzzy)
if (postedSet.has(st.nameKey)) continue;
let alreadyPosted = false;
for (const posted of postedSet) {
if (fuzzyNameMatch_(st.nameKey, posted)) { alreadyPosted = true; break; }
}
if (alreadyPosted) continue;
// Count completed: email-first, then name, then fuzzy
let completed = 0;
if (st.email && completedByEmail[st.email]) {
completed = completedByEmail[st.email];
} else if (completedByName[st.nameKey]) {
completed = completedByName[st.nameKey];
} else {
// Fuzzy name match
for (const [key, count] of Object.entries(completedByName)) {
if (fuzzyNameMatch_(st.nameKey, key)) {
completed = count;
break;
}
}
}
if (st.lessonCount > 0 && completed >= st.lessonCount) {
graduates.push({
name: st.name,
ordinal: getOrdinal_(existingCount + graduates.length + 1),
});
}
}
return graduates;
}
// ================================================================
// POST GENERATION
// ================================================================
function generatePosts_(studentName, ordinal, settings) {
const school = settings.schoolName || CONFIG.SCHOOL_NAME;
const handle = settings.schoolHandle || CONFIG.SCHOOL_HANDLE;
const tags = HASHTAGS.primary + ' ' + HASHTAGS.extra.join(' ');
const safeName = escHtml_(studentName);
return {
instagram: '๐๐ Congrats to our ' + ordinal + ' graduate, ' + studentName + '! Another one ready for the road! ' + handle + '\n\n' + tags,
facebook: '๐ Congratulations to ' + studentName + ' โ our ' + ordinal + ' graduate! We\'re so proud of your hard work and dedication. Ready for the open road! ๐๐จ\n\n' + school + '\n' + tags,
google: 'Congratulations ' + studentName + '! You\'re our ' + ordinal + ' graduate. Thank you for choosing ' + school + '! ๐ ' + tags,
};
}
function getTemplateForPlatform_(posts, platform) {
const p = String(platform || '').trim().toLowerCase();
if (p === 'instagram') return posts.instagram;
if (p === 'facebook') return posts.facebook;
if (p.includes('google')) return posts.google;
return posts.instagram;
}
// ================================================================
// EMAIL โ Mission Control Theme
// ================================================================
function missionControlWrapper_(title, content) {
const unsubLink = '<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="mailto:' + escHtml_(CONFIG.SCHOOL_EMAIL) + '?subject=Unsubscribe%20Social%20Post%20Notifications" style="color:#555;text-decoration:underline;">Unsubscribe</a></p>';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,\'SF Pro Display\',sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:20px;">' +
'<tr><td align="center">' +
'<table width="600" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,0.5);">' +
'<tr><td style="padding:30px 40px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<table width="100%"><tr>' +
'<td style="color:#ff2d2d;font-size:22px;font-weight:700;">๐ฑ ' + escHtml_(title) + '</td>' +
'<td align="right" style="color:#555;font-size:11px;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</td>' +
'</tr></table></td></tr>' +
'<tr><td style="padding:30px 40px;">' + content + '</td></tr>' +
'<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">' +
'<p style="color:#333;font-size:11px;margin:0;">Powered by Auto Social Posts โข <span style="color:#ff2d2d;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</span></p>' +
unsubLink +
'</td></tr></table></td></tr></table></body></html>';
}
function sendNewPostsEmail_(graduates, settings, isDemo) {
let gradList = '';
for (const grad of graduates) {
gradList +=
'<div style="display:flex;align-items:center;padding:12px 14px;background:rgba(255,255,255,0.03);border-radius:12px;margin-bottom:8px;border:1px solid rgba(255,255,255,0.06);">' +
'<div style="width:40px;height:40px;background:linear-gradient(135deg,#ff2d2d,#cc0000);border-radius:12px;display:flex;align-items:center;justify-content:center;margin-right:14px;">' +
'<span style="color:#fff;font-size:18px;font-weight:700;">' + escHtml_(grad.name.charAt(0)) + '</span></div>' +
'<div><p style="color:#fff;font-size:15px;font-weight:600;margin:0;">' + escHtml_(grad.name) + '</p>' +
'<p style="color:#888;font-size:12px;margin:2px 0 0;">' + escHtml_(grad.ordinal) + ' graduate</p></div></div>';
}
const demoTag = isDemo ? '<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:10px 16px;margin-bottom:20px;text-align:center;"><span style="color:#ff6b6b;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;">โฆ Demo Mode</span></div>' : '';
const content = demoTag +
'<p style="color:#ccc;font-size:16px;line-height:1.6;">New graduate posts have been ' + (isDemo ? 'simulated' : 'queued') + '!</p>' +
gradList +
'<p style="color:#888;font-size:13px;margin-top:20px;">Posts are queued for Instagram, Facebook, and Google Business. Review and post from the <strong style="color:#fff;">Social Post Queue</strong> sheet.</p>';
const html = missionControlWrapper_('New Graduate Posts ' + (isDemo ? '[DEMO]' : 'Queued'), content);
try {
MailApp.sendEmail({
to: settings.adminEmail,
subject: (isDemo ? '[DEMO] ' : '') + '๐ New graduate posts queued โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html,
});
} catch (e) { Logger.log('Error sending new posts email: ' + e.message); }
}
// ================================================================
// UTILITIES
// ================================================================
function getSettings_() {
const d = {
demoMode: CONFIG.DEMO_MODE,
schoolName: CONFIG.SCHOOL_NAME,
schoolEmail: CONFIG.SCHOOL_EMAIL,
schoolHandle: CONFIG.SCHOOL_HANDLE,
adminEmail: CONFIG.ADMIN_EMAIL,
sendNewPostsEmail: true,
postDay1: 'Tuesday',
postDay2: 'Wednesday',
postDay3: 'Thursday',
defaultPostTime: '11:00 AM',
};
try {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = ss.getSheetByName('Social Settings');
if (!sheet) return d;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
const key = String(data[i][0] || '').toLowerCase().trim();
const val = String(data[i][1] || '').trim();
if (key.includes('demo mode')) d.demoMode = val.toLowerCase() === 'yes';
else if (key.includes('school name')) d.schoolName = val;
else if (key.includes('school email')) d.schoolEmail = val;
else if (key.includes('school handle')) d.schoolHandle = val;
else if (key.includes('admin email')) d.adminEmail = val;
else if (key.includes('send new posts')) d.sendNewPostsEmail = val.toLowerCase() === 'yes';
else if (key.includes('post day 1')) d.postDay1 = val || 'Tuesday';
else if (key.includes('post day 2')) d.postDay2 = val || 'Wednesday';
else if (key.includes('post day 3')) d.postDay3 = val || 'Thursday';
else if (key.includes('default post time')) d.defaultPostTime = val || '11:00 AM';
}
} catch (e) { Logger.log('Settings error: ' + e.message); }
return d;
}
function suggestPostDate_(settings) {
const now = new Date();
const dayNames = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
const dayNum = str => dayNames[String(str || '').toLowerCase().trim()] ?? -1;
let targetDays = [dayNum(settings.postDay1), dayNum(settings.postDay2), dayNum(settings.postDay3)].filter(d => d >= 0);
if (targetDays.length === 0) targetDays = [2, 3, 4];
const timeStr = (settings.defaultPostTime || '11:00 AM').trim();
let hour = 11, minute = 0;
const match = timeStr.match(/(\d+)\s*:\s*(\d+)\s*(AM|PM)?/i);
if (match) {
hour = parseInt(match[1]);
minute = parseInt(match[2]) || 0;
if (match[3] && match[3].toUpperCase() === 'PM' && hour < 12) hour += 12;
if (match[3] && match[3].toUpperCase() === 'AM' && hour === 12) hour = 0;
}
const d = new Date(now.getFullYear(), now.getMonth(), now.getDate(), hour, minute, 0, 0);
while (d <= now || !targetDays.includes(d.getDay())) {
d.setDate(d.getDate() + 1);
d.setHours(hour, minute, 0, 0);
}
return d;
}
function updateTotalGraduates_(board, addCount) {
const settingsSheet = board.getSheetByName('Social Settings');
if (!settingsSheet) return;
const data = settingsSheet.getDataRange().getValues();
let rowIndex = -1;
for (let i = 0; i < data.length; i++) {
if (String(data[i][0] || '').toLowerCase().includes('total graduate')) {
rowIndex = i + 1;
break;
}
}
let current = 0;
if (rowIndex > 0) {
current = parseInt(String(settingsSheet.getRange(rowIndex, 2).getValue()).trim()) || 0;
} else {
settingsSheet.appendRow(['Total Graduates', '0']);
rowIndex = settingsSheet.getLastRow();
}
settingsSheet.getRange(rowIndex, 2).setValue(current + addCount);
}
function getOrdinal_(n) {
const num = parseInt(n) || 0;
if (num >= 11 && num <= 13) return num + 'th';
const last = num % 10;
if (last === 1) return num + 'st';
if (last === 2) return num + 'nd';
if (last === 3) return num + 'rd';
return num + 'th';
}
function findCol_(headers, candidates) {
for (const c of candidates) {
const idx = headers.findIndex(h => h.includes(c.toLowerCase()));
if (idx !== -1) return idx;
}
return -1;
}
function escHtml_(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function extractLessonCount_(pkg) {
if (!pkg) return 0;
const raw = pkg.toString().toLowerCase().trim();
if (raw.includes('5-hour') || raw.includes('5 hour')) return 1;
const match = raw.match(/(\d+)\s*lesson/i);
if (match) return parseInt(match[1]);
const match2 = raw.match(/^(\d+)$/);
if (match2) return parseInt(match2[1]);
if (raw.includes('beginner')) return 10;
if (raw.includes('standard')) return 10;
if (raw.includes('premium')) return 15;
if (raw.includes('intensive')) return 20;
return 0;
}
function formatDate_(date) {
if (!date) return '';
if (!(date instanceof Date)) date = new Date(date);
if (isNaN(date.getTime())) return '';
return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear();
}
// ================================================================
// FUZZY NAME MATCHING
// ================================================================
function fuzzyNameMatch_(name1, name2) {
if (!name1 || !name2) return false;
const n1 = name1.toLowerCase().replace(/\s+/g, ' ').trim();
const n2 = name2.toLowerCase().replace(/\s+/g, ' ').trim();
if (n1 === n2) return true;
if (n1.includes(n2) || n2.includes(n1)) return true;
const p1 = n1.split(' ').filter(Boolean);
const p2 = n2.split(' ').filter(Boolean);
if (p1.length >= 2 && p2.length >= 2) {
if (p1[p1.length-1] === p2[p2.length-1] && p1[0].substring(0,3) === p2[0].substring(0,3)) return true;
if (p1[0] === p2[p2.length-1] && p1[p1.length-1] === p2[0]) return true;
}
if (levenshtein_(n1, n2) <= 2) return true;
return false;
}
function levenshtein_(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
m[i][j] = b.charAt(i-1) === a.charAt(j-1) ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
}
}
return m[b.length][a.length];
}
// ================================================================
// MANUAL FUNCTIONS
// ================================================================
function generatePostForStudent(studentName) {
const board = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const settings = getSettings_();
const postedSheet = board.getSheetByName('Posted Students');
const total = postedSheet ? Math.max(0, postedSheet.getLastRow() - 1) + 1 : 1;
const posts = generatePosts_(studentName, getOrdinal_(total), settings);
const queueSheet = board.getSheetByName('Social Post Queue');
const postDate = suggestPostDate_(settings);
queueSheet.appendRow(['Instagram', postDate, posts.instagram, studentName, 'Queued']);
queueSheet.appendRow(['Facebook', postDate, posts.facebook, studentName, 'Queued']);
queueSheet.appendRow(['Google Business', postDate, posts.google, studentName, 'Queued']);
postedSheet.appendRow([studentName, new Date(), 'No']);
updateTotalGraduates_(board, 1);
Logger.log('โ
Queued posts for ' + studentName + ' (' + getOrdinal_(total) + ' graduate)');
}
function previewPosts() {
const settings = getSettings_();
const posts = generatePosts_('Preview Student', getOrdinal_(1), settings);
Logger.log('๐ธ Instagram:\n' + posts.instagram);
Logger.log('๐ Facebook:\n' + posts.facebook);
Logger.log('๐ข Google Business:\n' + posts.google);
}
function runOptimizationNow() {
dailySocialCheck();
Logger.log('โ
Manual social check complete.');
}
/**
* =========================================================
* BUSINESS INTELLIGENCE DASHBOARD โ Cherry on Top #2
* Flavors Driving School
* =========================================================
* Live web app โ opens on Mom's phone, shows everything.
* Mobile-first. Real-time data. Zero effort.
*
* Features:
* - Demo mode with realistic fake business data
* - PIN gate (4-digit access code)
* - Mission Control theme (#000/#0d0d0d/#ff2d2d)
* - Revenue snapshot (today / week / month / trend)
* - Active students & new signups
* - Today's schedule with instructor breakdown
* - Instructor utilization (visual bars)
* - Payment health (collected vs outstanding)
* - Tomorrow's preview
* - Alerts & action items
* - 90-second data cache for performance
* - Mobile-first with floating refresh button
* =========================================================
*/
const CONFIG = {
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SIGNUP_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
EXPENSE_TRACKER_ID: '1QyC39fjuslDXk-a_u09XACyHSq782H7NUJajV3vBp8M',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_EMAIL: '[email protected]',
INSTRUCTORS: ['Anisha', 'Carlos', 'Nick'],
WORK_START: 9,
WORK_END: 18,
BOOKINGS_SHEET_TAB: 'Bookings',
REGISTRATION_SHEET_TAB: '',
SIGNUP_SHEET_TAB: '',
CACHE_TTL_SECONDS: 90,
// Access PIN โ change this to your own 4-digit code
ACCESS_PIN: '7777',
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
};
// โโ DEMO DATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const DEMO = {
revenue: { today: 650, week: 2150, month: 8400, lastMonth: 7200, trend: '17', netMonth: 6850 },
expenses: { month: 1550 },
students: {
active: 38, total: 38, newWeek: 3, newMonth: 9, fiveHourMonth: 5,
recent: [
{ name: 'Tyler Brooks', package: '10 Lessons', date: '6/25' },
{ name: 'Jessica Kim', package: '5-Hour Class', date: '6/24' },
{ name: 'Marcus Williams', package: '15 Lessons', date: '6/23' },
{ name: 'Priya Patel', package: '10 Lessons', date: '6/22' },
],
},
lessons: { completedToday: 5, noShowsToday: 1, remainingToday: 3, weekTotal: 32 },
todaySchedule: [
{ time: '9:00 AM', student: 'Sarah Johnson', instructor: 'Carlos', status: 'completed' },
{ time: '10:00 AM', student: 'Marcus Williams', instructor: 'Nick', status: 'completed' },
{ time: '11:00 AM', student: 'Priya Patel', instructor: 'Anisha', status: 'completed' },
{ time: '1:00 PM', student: 'Alex Rivera', instructor: 'Carlos', status: 'no show' },
{ time: '2:00 PM', student: 'Sofia Rivera', instructor: 'Anisha', status: 'completed' },
{ time: '3:00 PM', student: 'Emma Garcia', instructor: 'Carlos', status: 'scheduled' },
{ time: '4:00 PM', student: 'Tyler Brooks', instructor: 'Nick', status: 'scheduled' },
{ time: '5:00 PM', student: 'David Chen', instructor: 'Anisha', status: 'scheduled' },
],
utilization: {
'Anisha': { booked: 24, total: 63, percent: 38, todayCount: 3 },
'Carlos': { booked: 35, total: 63, percent: 56, todayCount: 3 },
'Nick': { booked: 18, total: 63, percent: 29, todayCount: 2 },
},
paymentHealth: { totalPaid: 28500, totalOwed: 3200, studentsOwing: 6, studentsPaid: 32 },
tomorrow: {
byInstructor: {
'Anisha': [
{ time: '9:00 AM', student: 'Priya Patel' },
{ time: '11:00 AM', student: 'Sofia Rivera' },
{ time: '2:00 PM', student: 'Emma Garcia' },
],
'Carlos': [
{ time: '10:00 AM', student: 'Sarah Johnson' },
{ time: '1:00 PM', student: 'Tyler Brooks' },
],
'Nick': [
{ time: '9:00 AM', student: 'Marcus Williams' },
{ time: '3:00 PM', student: 'David Chen' },
],
},
total: 7, open: 20,
},
overdue: [
{ name: 'Alex Rivera', balance: 250, days: 21 },
{ name: 'Jade Thompson', balance: 175, days: 16 },
],
dayName: 'Wednesday',
tomorrowDay: 'Thursday',
};
// โโ WEB APP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function doGet(e) {
return HtmlService.createHtmlOutput(getDashboardHTML_())
.setTitle('Business Intelligence โ ' + CONFIG.SCHOOL_NAME)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
}
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
SpreadsheetApp.openById(CONFIG.EXPENSE_TRACKER_ID);
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized โ 5 sheets + email.');
}
function fullSetup() {
Logger.log('โ
Business Intelligence Dashboard ready. Deploy as web app to use.');
}
// โโ PIN VERIFICATION โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function verifyPin(pin) {
pin = String(pin || '').trim();
if (CONFIG.DEMO_MODE) {
return { verified: pin === CONFIG.ACCESS_PIN || pin === '0000' };
}
return { verified: pin === CONFIG.ACCESS_PIN };
}
// โโ DATA API (called from client) โโโโโโโโโโโโโโโโโโโโโโโ
function getDashboardData() {
try {
if (CONFIG.DEMO_MODE) {
return DEMO;
}
if (CONFIG.CACHE_TTL_SECONDS > 0) {
const cache = CacheService.getScriptCache();
const cached = cache.get('bi_dashboard');
if (cached) return JSON.parse(cached);
}
const payload = buildDashboardData_();
if (CONFIG.CACHE_TTL_SECONDS > 0) {
try {
CacheService.getScriptCache().put('bi_dashboard', JSON.stringify(payload), CONFIG.CACHE_TTL_SECONDS);
} catch (cacheErr) { Logger.log('Cache put failed: ' + cacheErr.message); }
}
return payload;
} catch (e) {
Logger.log('getDashboardData error: ' + e.message);
return { error: true, message: String(e.message || '').substring(0, 200) };
}
}
function buildDashboardData_() {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const endOfToday = new Date(today); endOfToday.setHours(23, 59, 59, 999);
const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1);
const endOfTomorrow = new Date(tomorrow); endOfTomorrow.setHours(23, 59, 59, 999);
const monday = new Date(today);
const dow = monday.getDay();
monday.setDate(monday.getDate() - (dow === 0 ? 6 : dow - 1));
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const lastMonthStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastMonthEnd = new Date(today.getFullYear(), today.getMonth(), 0, 23, 59, 59, 999);
const allPayments = getAllPayments_();
const revenueToday = sumInRange_(allPayments, today, endOfToday);
const revenueWeek = sumInRange_(allPayments, monday, endOfToday);
const revenueMonth = sumInRange_(allPayments, monthStart, endOfToday);
const revenueLastMonth = sumInRange_(allPayments, lastMonthStart, lastMonthEnd);
const monthTrend = revenueLastMonth > 0 ? ((revenueMonth - revenueLastMonth) / revenueLastMonth * 100).toFixed(0) : '0';
const allExpenses = getAllExpenses_();
const expensesMonth = sumInRange_(allExpenses, monthStart, endOfToday);
const students = getStudentData_();
const fiveHourMonth = get5HourSignups_(monthStart, endOfToday);
const allBookings = getAllBookings_();
const todaysLessons = filterBookings_(allBookings, today, endOfToday);
const tomorrowLessons = filterBookings_(allBookings, tomorrow, endOfTomorrow);
const weekLessons = filterBookings_(allBookings, monday, endOfToday);
// Only count confirmed past lessons as completed
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'pending', 'scheduled', 'upcoming', 'rescheduled'];
const completedToday = todaysLessons.filter(l => !skipStatuses.includes(l.status) && isPast_(l.date, l.time)).length;
const noShowsToday = todaysLessons.filter(l => l.status === 'no show' || l.status === 'no-show' || l.status === 'noshow').length;
const remainingToday = todaysLessons.filter(l => !isCancelled_(l.status) && l.status !== 'no show' && l.status !== 'no-show' && !isPast_(l.date, l.time)).length;
const weekSlots = daysBetween_(monday, today) * (CONFIG.WORK_END - CONFIG.WORK_START);
const utilization = {};
CONFIG.INSTRUCTORS.forEach(inst => {
const booked = weekLessons.filter(l => l.instructor === inst && !isCancelled_(l.status)).length;
utilization[inst] = {
booked, total: weekSlots || 1,
percent: weekSlots > 0 ? Math.round((booked / weekSlots) * 100) : 0,
todayCount: todaysLessons.filter(l => l.instructor === inst && !isCancelled_(l.status)).length,
};
});
const tomorrowByInstructor = {};
CONFIG.INSTRUCTORS.forEach(i => { tomorrowByInstructor[i] = []; });
tomorrowLessons.filter(l => !isCancelled_(l.status)).forEach(l => {
const inst = l.instructor || 'Other';
if (!tomorrowByInstructor[inst]) tomorrowByInstructor[inst] = [];
tomorrowByInstructor[inst].push({ time: l.time, student: l.student });
});
const tomorrowTotal = tomorrowLessons.filter(l => !isCancelled_(l.status)).length;
return {
timestamp: now.toISOString(),
revenue: { today: revenueToday, week: revenueWeek, month: revenueMonth, lastMonth: revenueLastMonth, trend: monthTrend, netMonth: revenueMonth - expensesMonth },
expenses: { month: expensesMonth },
students: { active: students.total, total: students.total, newWeek: students.newThisWeek, newMonth: students.newThisMonth, fiveHourMonth: fiveHourMonth, recent: students.recent },
lessons: { completedToday, noShowsToday, remainingToday, weekTotal: weekLessons.filter(l => !isCancelled_(l.status)).length },
todaySchedule: todaysLessons.filter(l => !isCancelled_(l.status)),
utilization,
paymentHealth: getPaymentHealth_(),
tomorrow: { byInstructor: tomorrowByInstructor, total: tomorrowTotal, open: (CONFIG.INSTRUCTORS.length * (CONFIG.WORK_END - CONFIG.WORK_START)) - tomorrowTotal },
overdue: getOverdueStudents_(),
dayName: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][now.getDay()],
tomorrowDay: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][(now.getDay() + 1) % 7],
};
}
// ================================================================
// DATA HELPERS
// ================================================================
function getBookingsSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
if (CONFIG.BOOKINGS_SHEET_TAB) { const s = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB); if (s) return s; }
return ss.getSheets()[0];
}
function getRegistrationSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
if (CONFIG.REGISTRATION_SHEET_TAB) { const s = ss.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB); if (s) return s; }
return ss.getSheets()[0];
}
function getSignupSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
if (CONFIG.SIGNUP_SHEET_TAB) { const s = ss.getSheetByName(CONFIG.SIGNUP_SHEET_TAB); if (s) return s; }
return ss.getSheets()[0];
}
function getAllPayments_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const sheet = ss.getSheetByName('Payments') || ss.getSheets()[0];
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'payment date', 'timestamp']);
const amountCol = findCol_(headers, ['amount', 'payment amount']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const methodCol = findCol_(headers, ['method', 'payment method']);
if (dateCol === -1 || amountCol === -1) return [];
const out = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime())) continue;
out.push({ date, amount: parseFloat(data[i][amountCol]) || 0, student: String(data[i][studentCol] || '').trim(), method: methodCol !== -1 ? String(data[i][methodCol] || '').trim() : '' });
}
return out;
}
function getAllExpenses_() {
try {
const ss = SpreadsheetApp.openById(CONFIG.EXPENSE_TRACKER_ID);
const sheet = ss.getSheets()[0];
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'timestamp', 'expense date']);
const amountCol = findCol_(headers, ['amount', 'expense amount']);
if (dateCol === -1 || amountCol === -1) return [];
const out = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime())) continue;
out.push({ date, amount: parseFloat(data[i][amountCol]) || 0 });
}
return out;
} catch (e) { Logger.log('Expense error: ' + e.message); return []; }
}
function sumInRange_(items, start, end) {
return items.filter(p => p.date >= start && p.date <= end).reduce((s, p) => s + p.amount, 0);
}
function getAllBookings_() {
const sheet = getBookingsSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
if (dateCol === -1) return [];
const out = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime())) continue;
out.push({
date, time: String(data[i][timeCol] || '').trim(),
student: String(data[i][studentCol] || '').trim(),
instructor: String(data[i][instructorCol] || '').trim(),
status: String(data[i][statusCol] || '').trim().toLowerCase(),
});
}
return out;
}
function filterBookings_(bookings, start, end) { return bookings.filter(b => b.date >= start && b.date <= end); }
function getStudentData_() {
const sheet = getRegistrationSheet_();
if (!sheet || sheet.getLastRow() <= 1) return { total: 0, newThisWeek: 0, newThisMonth: 0, recent: [] };
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['full name', 'name', 'student name']);
const tsCol = findCol_(headers, ['timestamp', 'date', 'submitted']);
const packageCol = findCol_(headers, ['lesson package', 'package', 'class type', 'selected package']);
if (nameCol === -1) return { total: 0, newThisWeek: 0, newThisMonth: 0, recent: [] };
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const monday = new Date(today); const dow = monday.getDay(); monday.setDate(monday.getDate() - (dow === 0 ? 6 : dow - 1));
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
const sevenDaysAgo = new Date(today); sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
let total = 0, newWeek = 0, newMonth = 0;
const recent = [];
for (let i = 1; i < data.length; i++) {
const name = String(data[i][nameCol] || '').trim();
if (!name) continue;
total++;
if (tsCol !== -1) {
const date = data[i][tsCol] instanceof Date ? data[i][tsCol] : new Date(data[i][tsCol]);
if (!isNaN(date.getTime())) {
if (date >= monday) newWeek++;
if (date >= monthStart) newMonth++;
if (date >= sevenDaysAgo) {
recent.push({ name, package: packageCol !== -1 ? String(data[i][packageCol] || '').trim() : '', date: formatDateShort_(date) });
}
}
}
}
return { total, newThisWeek: newWeek, newThisMonth: newMonth, recent: recent.slice(-5).reverse() };
}
function get5HourSignups_(start, end) {
const sheet = getSignupSheet_();
if (!sheet || sheet.getLastRow() <= 1) return 0;
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const tsCol = findCol_(headers, ['timestamp', 'date']);
if (tsCol === -1) return 0;
let count = 0;
for (let i = 1; i < data.length; i++) {
const date = data[i][tsCol] instanceof Date ? data[i][tsCol] : new Date(data[i][tsCol]);
if (!isNaN(date.getTime()) && date >= start && date <= end) count++;
}
return count;
}
function getPaymentHealth_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const sheet = ss.getSheetByName('Student Balances');
if (!sheet || sheet.getLastRow() <= 1) return { totalOwed: 0, totalPaid: 0, studentsOwing: 0, studentsPaid: 0 };
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const balCol = findCol_(headers, ['balance', 'remaining balance', 'amount due']);
const paidCol = findCol_(headers, ['total paid', 'paid']);
if (balCol === -1) return { totalOwed: 0, totalPaid: 0, studentsOwing: 0, studentsPaid: 0 };
let totalOwed = 0, totalPaid = 0, studentsOwing = 0, studentsPaid = 0;
for (let i = 1; i < data.length; i++) {
const bal = parseFloat(data[i][balCol]) || 0;
const paid = paidCol !== -1 ? (parseFloat(data[i][paidCol]) || 0) : 0;
totalPaid += paid;
if (bal > 0) { totalOwed += bal; studentsOwing++; } else { studentsPaid++; }
}
return { totalOwed, totalPaid, studentsOwing, studentsPaid };
}
function getOverdueStudents_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const sheet = ss.getSheetByName('Student Balances');
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const balCol = findCol_(headers, ['balance', 'remaining balance', 'amount due']);
const lastPayCol = findCol_(headers, ['last payment', 'last payment date']);
if (nameCol === -1 || balCol === -1) return [];
const overdue = [];
const now = new Date();
for (let i = 1; i < data.length; i++) {
const bal = parseFloat(data[i][balCol]) || 0;
if (bal <= 0) continue;
if (lastPayCol === -1) continue;
const lastPay = data[i][lastPayCol] instanceof Date ? data[i][lastPayCol] : new Date(data[i][lastPayCol]);
if (!isNaN(lastPay.getTime()) && (now - lastPay) > 14 * 86400000) {
overdue.push({ name: String(data[i][nameCol] || '').trim(), balance: bal, days: Math.floor((now - lastPay) / 86400000) });
}
}
return overdue.sort((a, b) => b.days - a.days);
}
// ================================================================
// UTILITIES
// ================================================================
function findCol_(headers, candidates) {
for (const c of candidates) {
const idx = headers.findIndex(h => h.includes(c.toLowerCase()));
if (idx !== -1) return idx;
}
return -1;
}
function isCancelled_(status) {
const s = String(status || '').toLowerCase().trim();
return s === 'cancelled' || s === 'canceled';
}
function isPast_(date, timeStr) {
const now = new Date();
const t = parseTime_(timeStr);
const lessonEnd = new Date(date);
lessonEnd.setHours(Math.floor(t / 60) + 1, t % 60, 0);
return now > lessonEnd;
}
function parseTime_(s) {
if (!s) return 0;
const m = s.toString().match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/i);
if (!m) return 0;
let h = parseInt(m[1]);
const min = parseInt(m[2] || '0');
const p = (m[3] || '').toUpperCase();
if (p === 'PM' && h < 12) h += 12;
if (p === 'AM' && h === 12) h = 0;
return h * 60 + min;
}
function daysBetween_(a, b) { return Math.max(1, Math.ceil((b - a) / 86400000) + 1); }
function formatDateShort_(d) {
if (!(d instanceof Date)) d = new Date(d);
if (isNaN(d.getTime())) return '';
return (d.getMonth() + 1) + '/' + d.getDate();
}
function esc_(s) {
if (!s) return '';
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
}
// ================================================================
// DASHBOARD HTML
// ================================================================
function getDashboardHTML_() {
const sn = esc_(CONFIG.SCHOOL_NAME);
const demoHint = CONFIG.DEMO_MODE ? ' (Demo PIN: 7777 or 0000)' : '';
return '<!DOCTYPE html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n' +
'<meta name="viewport" content="width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no">\n' +
'<title>Business Intelligence โ ' + sn + '</title>\n' +
'<style>\n' +
'*{margin:0;padding:0;box-sizing:border-box}\n' +
'body{background:#000;color:#ccc;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI",Roboto,sans-serif;min-height:100vh;-webkit-font-smoothing:antialiased;overflow-x:hidden}\n' +
'.app{max-width:500px;margin:0 auto;padding:16px 16px 80px}\n' +
// PIN screen
'.pin-screen{text-align:center;padding:60px 20px}\n' +
'.pin-screen .icon{font-size:48px;margin-bottom:20px}\n' +
'.pin-screen h2{color:#fff;font-size:22px;font-weight:700;margin-bottom:8px}\n' +
'.pin-screen p{color:#555;font-size:14px;margin-bottom:24px}\n' +
'.pin-input{background:#0a0a0a;border:1px solid rgba(255,255,255,0.08);border-radius:12px;padding:16px;color:#fff;font-size:24px;text-align:center;letter-spacing:8px;width:180px;outline:none}\n' +
'.pin-input:focus{border-color:#ff2d2d}\n' +
'.pin-btn{background:linear-gradient(135deg,#ff2d2d,#cc0000);color:#fff;border:none;border-radius:12px;padding:14px 32px;font-size:15px;font-weight:600;cursor:pointer;margin-top:16px;box-shadow:0 4px 16px rgba(255,45,45,0.3)}\n' +
'.pin-error{color:#ff2d2d;font-size:13px;margin-top:12px;display:none}\n' +
// Header
'.hdr{padding:20px 0 16px;text-align:center;border-bottom:1px solid rgba(255,255,255,0.06);margin-bottom:20px}\n' +
'.hdr .tag{display:inline-block;background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.12);color:#ff2d2d;font-size:9px;letter-spacing:2px;text-transform:uppercase;font-weight:700;padding:4px 12px;border-radius:20px;margin-bottom:10px}\n' +
'.hdr h1{font-size:22px;font-weight:800;color:#fff;letter-spacing:-0.5px}\n' +
'.hdr .sub{font-size:12px;color:#555;margin-top:4px}\n' +
'.hdr .live{display:inline-flex;align-items:center;gap:5px;font-size:10px;color:#22c55e;margin-top:8px}\n' +
'.hdr .live .dot{width:6px;height:6px;background:#22c55e;border-radius:50%;animation:blink 2s infinite}\n' +
'@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}\n' +
// Loading
'.loading{text-align:center;padding:60px 0}\n' +
'.spin{width:32px;height:32px;border:3px solid rgba(255,255,255,0.06);border-top-color:#ff2d2d;border-radius:50%;animation:sp .7s linear infinite;margin:0 auto 12px}\n' +
'@keyframes sp{to{transform:rotate(360deg)}}\n' +
// Stats grid
'.stats{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}\n' +
'.stat{background:#0d0d0d;border:1px solid rgba(255,255,255,0.06);border-radius:16px;padding:16px;text-align:center}\n' +
'.stat .ico{font-size:22px;margin-bottom:6px}\n' +
'.stat .val{font-size:24px;font-weight:800;color:#fff}\n' +
'.stat .lbl{font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-top:3px}\n' +
'.stat .sub{font-size:11px;margin-top:6px}\n' +
// Cards
'.card{background:#0d0d0d;border:1px solid rgba(255,255,255,0.06);border-radius:16px;margin-bottom:14px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.3)}\n' +
'.card-h{padding:14px 18px;border-bottom:1px solid rgba(255,255,255,0.06);display:flex;align-items:center;justify-content:space-between}\n' +
'.card-h h3{font-size:13px;font-weight:600;color:#fff}\n' +
'.card-h .badge{font-size:10px;background:rgba(255,45,45,0.06);color:#ff2d2d;padding:3px 9px;border-radius:8px;font-weight:700}\n' +
'.card-b{padding:14px 18px}\n' +
// Bars
'.bar-row{display:flex;align-items:center;gap:10px;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.02)}\n' +
'.bar-row:last-child{border:none}\n' +
'.bar-name{font-size:13px;color:#ccc;width:65px;flex-shrink:0}\n' +
'.bar-track{flex:1;height:8px;background:rgba(255,255,255,0.04);border-radius:4px;overflow:hidden}\n' +
'.bar-fill{height:100%;border-radius:4px}\n' +
'.bar-val{font-size:13px;font-weight:700;color:#fff;width:40px;text-align:right}\n' +
// Rows
'.row{display:flex;align-items:center;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.02);gap:10px}\n' +
'.row:last-child{border:none}\n' +
'.row .time{font-size:12px;color:#555;width:55px;flex-shrink:0}\n' +
'.row .name{font-size:13px;color:#fff;flex:1}\n' +
'.row .inst{font-size:11px;color:#555}\n' +
// Alerts
'.alert{background:rgba(255,45,45,0.04);border:1px solid rgba(255,45,45,0.12);border-radius:12px;padding:12px 14px;margin-bottom:8px;display:flex;align-items:center;gap:10px}\n' +
'.alert .a-text{font-size:12px;color:#ccc;flex:1}\n' +
'.alert .a-val{font-size:13px;font-weight:700;color:#ff2d2d}\n' +
// Instructor blocks
'.inst-block{margin-bottom:12px}\n' +
'.inst-head{font-size:11px;color:#ff2d2d;text-transform:uppercase;letter-spacing:1px;font-weight:700;padding:8px 0 4px;border-bottom:1px solid rgba(255,255,255,0.06)}\n' +
'.inst-slot{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid rgba(255,255,255,0.02);font-size:12px}\n' +
'.inst-slot .t{color:#555}\n' +
'.inst-slot .n{color:#fff}\n' +
// Revenue grid
'.rev-grid{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-bottom:12px}\n' +
'.rev-item{text-align:center;padding:10px 0}\n' +
'.rev-item .rv{font-size:20px;font-weight:800;color:#22c55e}\n' +
'.rev-item .rl{font-size:9px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-top:3px}\n' +
// Misc
'.trend-up{color:#22c55e}.trend-down{color:#ff2d2d}.trend-flat{color:#f59e0b}\n' +
'.refresh{position:fixed;bottom:20px;right:20px;width:48px;height:48px;background:linear-gradient(135deg,#ff2d2d,#cc0000);border:none;border-radius:50%;color:#fff;font-size:20px;cursor:pointer;box-shadow:0 4px 20px rgba(255,45,45,0.3);z-index:100;display:none}\n' +
'.empty{text-align:center;padding:16px;color:#333;font-size:12px}\n' +
'.signup-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.02)}\n' +
'.signup-row:last-child{border:none}\n' +
'.signup-name{font-size:13px;color:#fff}\n' +
'.signup-pkg{font-size:11px;color:#3b82f6;font-weight:600}\n' +
'.signup-date{font-size:10px;color:#333}\n' +
'@media(max-width:400px){.rev-item .rv{font-size:16px}.stat .val{font-size:20px}}\n' +
'</style>\n</head>\n<body>\n' +
'<div class="app">\n' +
// PIN Screen
'<div id="pinScreen" class="pin-screen">\n' +
'<span class="icon">๐</span>\n' +
'<h2>' + sn + '</h2>\n' +
'<p>Enter your access PIN to view the dashboard.' + esc_(demoHint) + '</p>\n' +
'<input type="password" id="pinInput" class="pin-input" maxlength="4" inputmode="numeric" pattern="[0-9]*" placeholder="โขโขโขโข" onkeypress="if(event.key===\'Enter\')checkPin()">\n' +
'<br><button class="pin-btn" onclick="checkPin()">Access Dashboard</button>\n' +
'<div class="pin-error" id="pinError"></div>\n' +
'</div>\n' +
// Dashboard (hidden until PIN verified)
'<div id="dashboard" style="display:none">\n' +
'<div class="hdr">\n' +
'<div class="tag">Business Intelligence</div>\n' +
'<h1>' + sn + '</h1>\n' +
'<div class="sub" id="dateDisplay"></div>\n' +
'<div class="live"><span class="dot"></span> Live Data</div>\n' +
'<div id="lastUpdated" style="font-size:12px;color:rgba(255,255,255,0.45);margin-top:6px"></div>\n' +
'</div>\n' +
'<div id="content"><div class="loading"><div class="spin"></div><div style="color:#555;font-size:13px">Loading dashboard...</div></div></div>\n' +
'</div>\n' +
'</div>\n' +
'<button class="refresh" id="refreshBtn" onclick="loadData()" title="Refresh">↻</button>\n' +
'<script>\n' +
'function $(id){return document.getElementById(id)}\n' +
'function esc(s){if(s==null)return"";var d=document.createElement("div");d.appendChild(document.createTextNode(String(s)));return d.innerHTML}\n' +
// PIN check
'function checkPin(){\n' +
'var pin=$("pinInput").value.trim();\n' +
'if(!pin||pin.length!==4){showPinError("Enter a 4-digit PIN.");return}\n' +
'$("pinInput").disabled=true;\n' +
'google.script.run.withSuccessHandler(function(r){\n' +
'$("pinInput").disabled=false;\n' +
'if(r&&r.verified){$("pinScreen").style.display="none";$("dashboard").style.display="block";$("refreshBtn").style.display="block";initDashboard()}\n' +
'else{showPinError("Incorrect PIN. Try again.")}\n' +
'}).withFailureHandler(function(){$("pinInput").disabled=false;showPinError("Error verifying. Try again.")}).verifyPin(pin)}\n' +
'function showPinError(msg){var e=$("pinError");e.textContent=msg;e.style.display="block"}\n' +
// Dashboard init
'function initDashboard(){\n' +
'var now=new Date();var opts={weekday:"long",month:"long",day:"numeric",year:"numeric"};\n' +
'$("dateDisplay").textContent=now.toLocaleDateString("en-US",opts)+" \\u2022 "+now.toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit"});\n' +
'loadData()}\n' +
'function loadData(){\n' +
'$("content").innerHTML=\'<div class="loading"><div class="spin"></div><div style="color:#555;font-size:13px">Refreshing...</div></div>\';\n' +
'google.script.run.withSuccessHandler(render).withFailureHandler(err).getDashboardData()}\n' +
'function err(e){$("content").innerHTML=\'<div class="empty" style="color:#ff2d2d">Error loading data. Tap refresh to try again.</div>\'}\n' +
// Render
'function render(d){\n' +
'if(d&&d.error){$("content").innerHTML=\'<div class="empty" style="color:#ff2d2d">\'+esc(d.message||"Error")+\'. Tap refresh.</div>\';return}\n' +
'var trend=parseFloat(d.revenue.trend)||0;\n' +
'var tClass=trend>0?"trend-up":trend<0?"trend-down":"trend-flat";\n' +
'var tIcon=trend>0?"\\u2191":trend<0?"\\u2193":"\\u2192";\n' +
'var h="";\n' +
// Revenue card
'h+=\'<div class="card"><div class="card-h"><h3>\\uD83D\\uDCB0 Revenue</h3><span class="badge \'+tClass+\'">\'+tIcon+" "+esc(Math.abs(trend))+"% vs last month</span></div><div class=\\"card-b\\">";\n' +
'h+=\'<div class="rev-grid">\';\n' +
'h+=revItem(d.revenue.today,"Today")+revItem(d.revenue.week,"This Week")+revItem(d.revenue.month,"This Month");\n' +
'h+="</div>";\n' +
'h+=\'<div style="display:flex;justify-content:space-between;padding:8px 0;border-top:1px solid rgba(255,255,255,.04)"><span style="font-size:12px;color:#555">Expenses this month</span><span style="font-size:12px;color:#ff2d2d;font-weight:600">-$\'+esc((d.expenses.month||0).toLocaleString())+"</span></div>";\n' +
'var nc=(d.revenue.netMonth||0)>=0?"#22c55e":"#ff2d2d";\n' +
'h+=\'<div style="display:flex;justify-content:space-between;padding:8px 0"><span style="font-size:12px;color:#555">Net profit</span><span style="font-size:13px;color:\'+nc+\';font-weight:700">\'+((d.revenue.netMonth||0)>=0?"+":"")+\'$\'+esc((d.revenue.netMonth||0).toLocaleString())+"</span></div></div></div>";\n' +
// Stats
'h+=\'<div class="stats">\';\n' +
'h+=statCard("\\uD83D\\uDCDA",(d.lessons.completedToday||0)+(d.lessons.remainingToday||0),"Lessons Today",esc(d.lessons.remainingToday||0)+" remaining");\n' +
'h+=statCard("\\uD83C\\uDD95",d.students.newWeek||0,"New This Week",esc(d.students.newMonth||0)+" this month");\n' +
'h+=statCard("\\uD83D\\uDC65",d.students.active||0,"Total Students",esc(d.students.fiveHourMonth||0)+" 5-hr this month");\n' +
'var nsc=(d.lessons.noShowsToday||0)>0?"color:#ff2d2d":"color:#22c55e";\n' +
'h+=statCard("\\uD83D\\uDCCA",d.lessons.weekTotal||0,"Week Lessons",(d.lessons.noShowsToday||0)>0?esc(d.lessons.noShowsToday)+" no-show today":"No no-shows today",nsc);\n' +
'h+="</div>";\n' +
// Utilization
'h+=\'<div class="card"><div class="card-h"><h3>Instructor Utilization</h3><span class="badge">THIS WEEK</span></div><div class="card-b">\';\n' +
'Object.keys(d.utilization||{}).forEach(function(n){var u=d.utilization[n];var c=u.percent>60?"#22c55e":u.percent>30?"#f59e0b":"#ff2d2d";\n' +
'h+=\'<div class="bar-row"><span class="bar-name">\'+esc(n)+\'</span><div class="bar-track"><div class="bar-fill" style="width:\'+Math.max(3,u.percent||0)+\'%;background:\'+c+\'"></div></div><span class="bar-val">\'+esc(u.percent||0)+\'%</span></div>\'});\n' +
'h+="</div></div>";\n' +
// Today
'h+=\'<div class="card"><div class="card-h"><h3>Today \\u2014 \'+esc(d.dayName||"")+"</h3></div><div class=\\"card-b\\">";\n' +
'if(d.todaySchedule&&d.todaySchedule.length>0){d.todaySchedule.forEach(function(l){var p=isPastC(l.time);\n' +
'h+=\'<div class="row"><span class="time">\'+esc(l.time)+\'</span><span class="name" style="\' +(p?"opacity:.5":"")+\'">\'+esc(l.student)+\'</span><span class="inst">\'+esc(l.instructor)+"</span></div>"})}\n' +
'else{h+=\'<div class="empty">No lessons today</div>\'}\n' +
'h+="</div></div>";\n' +
// Payment Health
'h+=\'<div class="card"><div class="card-h"><h3>Payment Health</h3></div><div class="card-b">\';\n' +
'var ph=d.paymentHealth||{};\n' +
'h+=\'<div style="display:flex;justify-content:space-around;text-align:center;margin-bottom:12px">\';\n' +
'h+=\'<div><div style="font-size:22px;font-weight:800;color:#22c55e">$\'+esc((ph.totalPaid||0).toLocaleString())+\'</div><div style="font-size:10px;color:#555;margin-top:3px">COLLECTED</div></div>\';\n' +
'h+=\'<div><div style="font-size:22px;font-weight:800;color:\'+((ph.totalOwed||0)>0?"#f59e0b":"#22c55e")+\'">$\'+esc((ph.totalOwed||0).toLocaleString())+\'</div><div style="font-size:10px;color:#555;margin-top:3px">OUTSTANDING</div></div>\';\n' +
'h+="</div>";\n' +
'h+=\'<div style="display:flex;justify-content:space-around;text-align:center;padding-top:10px;border-top:1px solid rgba(255,255,255,.04)">\';\n' +
'h+=\'<div style="font-size:12px"><span style="color:#22c55e;font-weight:700">\'+esc(ph.studentsPaid||0)+\'</span> <span style="color:#555">paid up</span></div>\';\n' +
'h+=\'<div style="font-size:12px"><span style="color:#f59e0b;font-weight:700">\'+esc(ph.studentsOwing||0)+\'</span> <span style="color:#555">with balance</span></div>\';\n' +
'h+="</div></div></div>";\n' +
// Tomorrow
'h+=\'<div class="card"><div class="card-h"><h3>Tomorrow \\u2014 \'+esc(d.tomorrowDay||"")+\'</h3><span class="badge">\'+esc(d.tomorrow.total||0)+\' booked \\u00B7 \'+esc(d.tomorrow.open||0)+\' open</span></div><div class="card-b">\';\n' +
'if((d.tomorrow.total||0)>0&&d.tomorrow.byInstructor){Object.keys(d.tomorrow.byInstructor).forEach(function(inst){\n' +
'var ls=d.tomorrow.byInstructor[inst];\n' +
'h+=\'<div class="inst-block"><div class="inst-head">\'+esc(inst)+" ("+ls.length+")</div>";\n' +
'if(ls.length>0){ls.forEach(function(l){h+=\'<div class="inst-slot"><span class="t">\'+esc(l.time)+\'</span><span class="n">\'+esc(l.student)+"</span></div>"})}\n' +
'else{h+=\'<div style="font-size:11px;color:#333;padding:6px 0">No lessons booked</div>\'}\n' +
'h+="</div>"})}else{h+=\'<div class="empty">No lessons scheduled tomorrow</div>\'}\n' +
'h+="</div></div>";\n' +
// Recent signups
'if(d.students.recent&&d.students.recent.length>0){\n' +
'h+=\'<div class="card"><div class="card-h"><h3>Recent Signups</h3><span class="badge">LAST 7 DAYS</span></div><div class="card-b">\';\n' +
'd.students.recent.forEach(function(s){h+=\'<div class="signup-row"><div><div class="signup-name">\'+esc(s.name)+\'</div><div class="signup-date">\'+esc(s.date)+\'</div></div><div class="signup-pkg">\'+esc(s.package)+"</div></div>"});\n' +
'h+="</div></div>"}\n' +
// Overdue
'if(d.overdue&&d.overdue.length>0){\n' +
'h+=\'<div class="card"><div class="card-h"><h3>\\u26A0\\uFE0F Overdue Payments</h3></div><div class="card-b">\';\n' +
'd.overdue.forEach(function(o){h+=\'<div class="alert"><span style="font-size:18px">\\uD83D\\uDD34</span><span class="a-text">\'+esc(o.name)+\' <span style="color:#333">(\'+esc(o.days)+" days)</span></span><span class=\\"a-val\\">$"+esc((o.balance||0).toFixed(0))+"</span></div>"});\n' +
'h+="</div></div>"}\n' +
// Footer
'h+=\'<div style="text-align:center;padding:20px 0;font-size:11px;color:#333">Last refreshed: \'+esc(new Date().toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit",second:"2-digit"}))+"</div>";\n' +
'$("content").innerHTML=h;\n' +
'var u=$("lastUpdated");if(u)u.textContent="Data pulled "+new Date().toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit"})}\n' +
// Helpers
'function statCard(ico,val,lbl,sub,ss){return\'<div class="stat"><div class="ico">\'+ico+\'</div><div class="val">\'+esc(val)+\'</div><div class="lbl">\'+esc(lbl)+\'</div><div class="sub" style="\'+(ss||"color:#555")+\'">\'+sub+"</div></div>"}\n' +
'function revItem(val,lbl){return\'<div class="rev-item"><div class="rv">$\'+esc((val||0).toLocaleString())+\'</div><div class="rl">\'+esc(lbl)+"</div></div>"}\n' +
'function isPastC(ts){var now=new Date();var m=(ts||"").match(/(\\d{1,2}):?(\\d{2})?\\s*(AM|PM)?/i);if(!m)return false;var h=parseInt(m[1],10);var p=(m[3]||"").toUpperCase();if(p==="PM"&&h<12)h+=12;if(p==="AM"&&h===12)h=0;return now.getHours()>=h+1}\n' +
'</script>\n</body>\n</html>';
}
/**
* =========================================================
* SATURDAY 5-HOUR CLASS REMINDERS
* Flavors Driving School
* =========================================================
* Sends SMS reminders (email-to-SMS gateway) + email fallback
* to students whose Saturday 5-Hour Class is tomorrow.
*
* - Runs Friday at 6 PM via daily trigger
* - Multi-carrier SMS support (Verizon, AT&T, T-Mobile, Sprint, etc.)
* - Email fallback if no phone/carrier on file
* - Admin summary after each run
* - Reminder history log sheet
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
SIGNUP_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
// โโ Sheet tabs (empty = first sheet) โโ
SIGNUP_SHEET_TAB: '',
REGISTRATION_SHEET_TAB: '',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
// โโ Class details โโ
CLASS_TIME: '11:00 AM',
CLASS_LOCATION: 'Flavors Driving School, Queens, NY',
WHAT_TO_BRING: 'your learner permit and a valid photo ID',
// โโ Timezone โโ
TIMEZONE: 'America/New_York',
// โโ SMS carrier gateways โโ
CARRIER_GATEWAYS: {
'verizon': '@vtext.com',
'att': '@txt.att.net',
'at&t': '@txt.att.net',
'tmobile': '@tmomail.net',
't-mobile': '@tmomail.net',
'sprint': '@messaging.sprintpcs.com',
'metro': '@mymetropcs.com',
'metropcs': '@mymetropcs.com',
'boost': '@sms.myboostmobile.com',
'cricket': '@sms.cricketwireless.net',
'uscellular': '@email.uscc.net',
'us cellular': '@email.uscc.net',
'mint': '@tmomail.net',
'visible': '@vtext.com',
'xfinity': '@vtext.com',
'google fi': '@msg.fi.google.com',
'fi': '@msg.fi.google.com'
},
// โโ Default gateway if carrier unknown โโ
DEFAULT_SMS_GATEWAY: '@vtext.com',
// โโ Demo mode (true = no real messages, fake data for presentations) โโ
DEMO_MODE: true,
// โโ Week-ahead pre-reminder (Monday before Saturday class) โโ
SEND_WEEK_AHEAD_REMINDER: true
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO = {
students: [
{ name: 'Sarah Johnson', phone: '(917) 555-0123', carrier: 'Verizon', email: '[email protected]', classDate: 'next Saturday' },
{ name: 'Marcus Williams', phone: '(718) 555-0456', carrier: 'T-Mobile', email: '[email protected]', classDate: 'next Saturday' },
{ name: 'Emily Chen', phone: '', carrier: '', email: '[email protected]', classDate: 'next Saturday' },
{ name: 'David Rodriguez', phone: '(646) 555-0789', carrier: 'AT&T', email: '[email protected]', classDate: 'next Saturday' }
],
summary: {
smsReminders: 3,
emailFallbacks: 1,
skipped: 0,
failed: 0
}
};
/* ================================================================
SETUP & AUTH
================================================================ */
/** Run once to trigger OAuth for all connected services. */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
// โโ Create "Reminder Log" sheet โโ
let logSheet = ss.getSheetByName('Reminder Log');
if (!logSheet) {
logSheet = ss.insertSheet('Reminder Log');
logSheet.appendRow([
'Date Sent', 'Student Name', 'Phone', 'Carrier', 'Email', 'Class Date',
'Method', 'Status', 'Message Preview'
]);
logSheet.getRange('1:1').setFontWeight('bold');
logSheet.setFrozenRows(1);
Logger.log('โ
Created "Reminder Log" sheet.');
}
// โโ Friday evening trigger (delete old first) โโ
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'sendReminders' || t.getHandlerFunction() === 'sendWeekAheadReminders') {
ScriptApp.deleteTrigger(t);
}
});
// Daily at 6 PM โ sendReminders checks if tomorrow is the class date
ScriptApp.newTrigger('sendReminders')
.timeBased()
.everyDays(1)
.atHour(18)
.create();
Logger.log('โ
Daily trigger set: sendReminders at 6 PM.');
// Monday at 10 AM โ week-ahead heads up
if (CONFIG.SEND_WEEK_AHEAD_REMINDER) {
ScriptApp.newTrigger('sendWeekAheadReminders')
.timeBased()
.everyDays(1)
.atHour(10)
.create();
Logger.log('โ
Daily trigger set: sendWeekAheadReminders at 10 AM.');
}
Logger.log('โ
Class Reminders setup complete.');
}
/* ================================================================
MAIN: SEND REMINDERS (day-before)
================================================================ */
function sendReminders() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ no real reminders sent.');
Logger.log('Would send to: ' + DEMO.students.map(s => s.name).join(', '));
Logger.log('Summary: ' + JSON.stringify(DEMO.summary));
return;
}
runReminders_(1, 'day-before');
} catch (e) {
Logger.log('sendReminders error: ' + (e.message || e));
notifyAdmin_('Class Reminders โ Error', 'sendReminders failed: ' + String(e.message || e).substring(0, 500));
throw e;
}
}
/** Week-ahead reminder โ runs daily but only fires on Monday for next Saturday's class. */
function sendWeekAheadReminders() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ no week-ahead reminders sent.');
return;
}
if (!CONFIG.SEND_WEEK_AHEAD_REMINDER) return;
// Only run on Mondays
const now = new Date();
const day = parseInt(Utilities.formatDate(now, CONFIG.TIMEZONE, 'u'), 10); // 1=Mon
if (day !== 1) {
Logger.log('Not Monday โ skipping week-ahead reminder.');
return;
}
runReminders_(5, 'week-ahead');
} catch (e) {
Logger.log('sendWeekAheadReminders error: ' + (e.message || e));
notifyAdmin_('Class Reminders โ Error', 'sendWeekAheadReminders failed: ' + String(e.message || e).substring(0, 500));
throw e;
}
}
/* ================================================================
CORE LOGIC
================================================================ */
function runReminders_(daysAhead, reminderType) {
const tz = CONFIG.TIMEZONE;
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + daysAhead);
const targetStr = Utilities.formatDate(targetDate, tz, 'M/d/yyyy');
const targetDay = Utilities.formatDate(targetDate, tz, 'EEEE'); // e.g. "Saturday"
// โโ Get signup data โโ
const ss = SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
const sheet = CONFIG.SIGNUP_SHEET_TAB
? (ss.getSheetByName(CONFIG.SIGNUP_SHEET_TAB) || ss.getSheets()[0])
: ss.getSheets()[0];
if (!sheet || sheet.getLastRow() < 2) {
Logger.log('No data in signup sheet.');
return;
}
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h || '').toLowerCase().trim());
const nameCol = findCol_(headers, ['full name', 'name', 'student name']);
const phoneCol = findCol_(headers, ['phone', 'phone number', 'mobile', 'cell']);
const emailCol = findCol_(headers, ['email', 'student email', 'email address']);
const dateCol = findCol_(headers, ['saturday', 'class date', 'saturday date', 'date', 'scheduled date']);
const carrierCol = findCol_(headers, ['carrier', 'phone carrier', 'mobile carrier', 'network']);
const reminderCol = findCol_(headers, ['reminder sent', 'reminder', 'sent']);
const unsubCol = findCol_(headers, ['unsubscribe', 'opt out', 'opted out', 'sms opt out']);
if (nameCol < 0 || dateCol < 0) {
Logger.log('Missing required columns: need name and class date.');
return;
}
// โโ Also check Registration sheet for phone/email/carrier if missing in signups โโ
const regLookup = buildRegistrationLookup_();
// โโ Get or create log sheet โโ
let logSheet = ss.getSheetByName('Reminder Log');
if (!logSheet) {
fullSetup();
logSheet = ss.getSheetByName('Reminder Log');
}
// โโ Cooldown: check what we already sent today โโ
const todayStr = Utilities.formatDate(new Date(), tz, 'M/d/yyyy');
const alreadySent = new Set();
if (logSheet && logSheet.getLastRow() > 1) {
const logData = logSheet.getDataRange().getValues();
for (let r = 1; r < logData.length; r++) {
const logDate = logData[r][0];
const logName = String(logData[r][1] || '').toLowerCase().trim();
const logType = String(logData[r][7] || '').toLowerCase();
if (logDate instanceof Date) {
const logDateStr = Utilities.formatDate(logDate, tz, 'M/d/yyyy');
if (logDateStr === todayStr && logType.includes('sent')) {
alreadySent.add(logName);
}
}
}
}
const stats = { sms: 0, email: 0, skipped: 0, failed: 0, optedOut: 0 };
const sentThisRun = new Set();
for (let i = 1; i < data.length; i++) {
const row = data[i];
const name = sanitize_(String(row[nameCol] || '').trim());
if (!name) continue;
// โโ Check class date matches target โโ
const classDate = parseDate_(row[dateCol]);
if (!classDate || isNaN(classDate.getTime())) continue;
const classDateStr = Utilities.formatDate(classDate, tz, 'M/d/yyyy');
if (classDateStr !== targetStr) continue;
// โโ Check if already reminded โโ
const nameKey = name.toLowerCase().trim();
if (alreadySent.has(nameKey)) { stats.skipped++; continue; }
if (sentThisRun.has(nameKey)) { stats.skipped++; continue; }
// Check sheet "Reminder Sent" column
if (reminderCol >= 0) {
const val = String(row[reminderCol] || '').toUpperCase().trim();
if (reminderType === 'day-before' && val === 'YES') { stats.skipped++; continue; }
if (reminderType === 'week-ahead' && (val === 'YES' || val === 'WEEK')) { stats.skipped++; continue; }
}
// โโ Check unsubscribe โโ
if (unsubCol >= 0) {
const unsub = String(row[unsubCol] || '').toLowerCase().trim();
if (unsub === 'yes' || unsub === 'true' || unsub === '1') { stats.optedOut++; continue; }
}
// โโ Get contact info (from signup row + registration fallback) โโ
let phone = phoneCol >= 0 ? String(row[phoneCol] || '').trim() : '';
let email = emailCol >= 0 ? String(row[emailCol] || '').trim() : '';
let carrier = carrierCol >= 0 ? String(row[carrierCol] || '').trim() : '';
// Registration lookup for missing fields
if (!phone || !email || !carrier) {
const regInfo = regLookup[nameKey] || regLookup[email.toLowerCase()] || {};
if (!phone && regInfo.phone) phone = regInfo.phone;
if (!email && regInfo.email) email = regInfo.email;
if (!carrier && regInfo.carrier) carrier = regInfo.carrier;
}
// โโ Build message โโ
const firstName = name.split(' ')[0] || name;
const dayLabel = reminderType === 'week-ahead' ? 'this Saturday' : 'tomorrow (Saturday)';
const smsMsg = 'Hi ' + firstName + '! Reminder: Your 5-Hour Pre-Licensing Class is '
+ dayLabel + ' at ' + CONFIG.CLASS_TIME + ' at ' + CONFIG.CLASS_LOCATION
+ '. Please bring ' + CONFIG.WHAT_TO_BRING + '. Arrive 10 min early. See you there! โ '
+ CONFIG.SCHOOL_NAME
+ '\n\nReply STOP to opt out of reminders.';
// โโ Try SMS first, fall back to email โโ
const cleanPhone = cleanPhone_(phone);
let method = '';
let status = '';
if (cleanPhone) {
const gateway = resolveGateway_(carrier);
const smsEmail = cleanPhone + gateway;
try {
MailApp.sendEmail({
to: smsEmail,
subject: '',
body: smsMsg,
name: CONFIG.SCHOOL_NAME
});
method = 'SMS (' + (carrier || 'default') + ')';
status = 'Sent';
stats.sms++;
} catch (e) {
Logger.log('SMS failed for ' + name + ', trying email fallback: ' + e.message);
method = 'SMS Failed';
status = 'Failed';
}
}
// Email fallback if no phone or SMS failed
if (status !== 'Sent' && email) {
try {
const htmlBody = buildReminderEmail_(firstName, dayLabel, classDateStr);
MailApp.sendEmail({
to: email,
subject: 'Reminder: 5-Hour Class ' + dayLabel + ' โ ' + CONFIG.SCHOOL_NAME,
body: smsMsg,
htmlBody: htmlBody,
name: CONFIG.SCHOOL_NAME
});
method = method === 'SMS Failed' ? 'Email (SMS failed)' : 'Email (no phone)';
status = 'Sent';
stats.email++;
} catch (e) {
Logger.log('Email also failed for ' + name + ': ' + e.message);
method = method || 'Email';
status = 'Failed';
stats.failed++;
}
}
if (status !== 'Sent' && !email && !cleanPhone) {
method = 'None';
status = 'No contact info';
stats.failed++;
}
// โโ Log it โโ
if (logSheet) {
logSheet.appendRow([
new Date(), name, phone, carrier, email, classDateStr,
method, status, smsMsg.substring(0, 100) + '...'
]);
}
// โโ Mark reminder sent in signup sheet โโ
if (status === 'Sent' && reminderCol >= 0) {
const mark = reminderType === 'week-ahead' ? 'WEEK' : 'YES';
sheet.getRange(i + 1, reminderCol + 1).setValue(mark);
}
sentThisRun.add(nameKey);
}
// โโ Admin summary โโ
const summaryText = '๐ Class Reminder Summary (' + reminderType + ')\n'
+ 'Target date: ' + targetDay + ' ' + targetStr + '\n\n'
+ '๐ฑ SMS sent: ' + stats.sms + '\n'
+ '๐ง Email fallbacks: ' + stats.email + '\n'
+ 'โญ๏ธ Already reminded: ' + stats.skipped + '\n'
+ '๐ซ Opted out: ' + stats.optedOut + '\n'
+ 'โ Failed: ' + stats.failed + '\n'
+ 'โโโโโโโโโโโโโโโโโ\n'
+ 'Total reached: ' + (stats.sms + stats.email);
Logger.log(summaryText);
if (stats.sms + stats.email + stats.failed > 0) {
sendAdminSummary_(stats, reminderType, targetStr);
}
}
/* ================================================================
REGISTRATION LOOKUP (for phone/email/carrier enrichment)
================================================================ */
function buildRegistrationLookup_() {
const lookup = {};
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = CONFIG.REGISTRATION_SHEET_TAB
? (ss.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB) || ss.getSheets()[0])
: ss.getSheets()[0];
if (!sheet || sheet.getLastRow() <= 1) return lookup;
const data = sheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nCol = findCol_(h, ['student name', 'full name', 'name']);
const eCol = findCol_(h, ['email', 'student email', 'email address']);
const pCol = findCol_(h, ['phone', 'phone number', 'mobile', 'cell']);
const cCol = findCol_(h, ['carrier', 'phone carrier', 'mobile carrier', 'network']);
for (let i = 1; i < data.length; i++) {
const row = data[i];
const info = {
name: nCol >= 0 ? String(row[nCol] || '').trim() : '',
email: eCol >= 0 ? String(row[eCol] || '').trim() : '',
phone: pCol >= 0 ? String(row[pCol] || '').trim() : '',
carrier: cCol >= 0 ? String(row[cCol] || '').trim() : ''
};
if (info.name) lookup[info.name.toLowerCase()] = info;
if (info.email) lookup[info.email.toLowerCase()] = info;
}
} catch (e) {
Logger.log('Registration lookup failed: ' + e.message);
}
return lookup;
}
/* ================================================================
SMS HELPERS
================================================================ */
function cleanPhone_(phone) {
let digits = String(phone || '').replace(/\D/g, '');
if (digits.length === 11 && digits[0] === '1') digits = digits.substring(1);
return digits.length === 10 ? digits : '';
}
function resolveGateway_(carrier) {
if (!carrier) return CONFIG.DEFAULT_SMS_GATEWAY;
const key = String(carrier).toLowerCase().trim();
return CONFIG.CARRIER_GATEWAYS[key] || CONFIG.DEFAULT_SMS_GATEWAY;
}
/* ================================================================
EMAIL FALLBACK (Mission Control theme)
================================================================ */
function buildReminderEmail_(firstName, dayLabel, classDate) {
const f = esc_(firstName);
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe%20Reminders&body=Please%20remove%20me%20from%20class%20reminder%20emails.';
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center">'
+ '<table width="600" cellpadding="0" cellspacing="0" border="0">'
// Header
+ '<tr><td style="background:#0d0d0d;border-bottom:2px solid #ff2d2d;padding:30px 24px;text-align:center;">'
+ '<h1 style="color:#fff;font-size:20px;margin:0 0 4px;">' + sch + '</h1>'
+ '<p style="color:rgba(255,255,255,0.5);font-size:12px;margin:0;">' + tag + '</p>'
+ '</td></tr>'
// Body
+ '<tr><td style="background:#0d0d0d;padding:32px 24px;text-align:center;">'
+ '<div style="font-size:48px;margin-bottom:16px;">๐</div>'
+ '<div style="font-size:24px;font-weight:800;color:#fff;margin-bottom:8px;">Class Reminder</div>'
+ '<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;max-width:440px;margin:0 auto 24px;">'
+ 'Hi <strong style="color:#fff;">' + f + '</strong>! Your <strong style="color:#ff2d2d;">5-Hour Pre-Licensing Class</strong> is '
+ '<strong style="color:#fff;">' + esc_(dayLabel) + '</strong>.</p>'
// Details cards
+ '<table cellpadding="0" cellspacing="8" border="0" align="center"><tr>'
+ detailBox_('๐', 'Time', esc_(CONFIG.CLASS_TIME))
+ detailBox_('๐', 'Location', esc_(CONFIG.CLASS_LOCATION))
+ '</tr><tr>'
+ detailBox_('๐', 'Bring', esc_(CONFIG.WHAT_TO_BRING))
+ detailBox_('โฐ', 'Arrive', '10 minutes early')
+ '</tr></table>'
+ '</td></tr>'
// Footer
+ '<tr><td style="background:#000;border-top:1px solid rgba(255,255,255,0.05);padding:20px 24px;text-align:center;">'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.25);margin:2px 0;">' + sch + ' โ See you there!</p>'
+ '<p style="font-size:10px;color:rgba(255,255,255,0.15);margin:8px 0 0;"><a href="' + unsub + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe from reminders</a></p>'
+ '</td></tr>'
+ '</table></td></tr></table></body></html>';
}
function detailBox_(emoji, label, value) {
return '<td style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:14px 16px;text-align:center;width:50%;">'
+ '<div style="font-size:24px;margin-bottom:4px;">' + emoji + '</div>'
+ '<div style="font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px;">' + esc_(label) + '</div>'
+ '<div style="font-size:13px;color:#fff;margin-top:4px;font-weight:600;">' + value + '</div></td>';
}
/* ================================================================
ADMIN SUMMARY EMAIL
================================================================ */
function sendAdminSummary_(stats, reminderType, targetDate) {
if (!CONFIG.ADMIN_EMAIL) return;
const label = reminderType === 'week-ahead' ? 'Week-Ahead' : 'Day-Before';
const subject = CONFIG.SCHOOL_NAME + ' โ ' + label + ' Reminder Summary';
const total = stats.sms + stats.email;
const html = '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center">'
+ '<table width="600" cellpadding="0" cellspacing="0" border="0">'
+ '<tr><td style="background:#0d0d0d;border-bottom:2px solid #ff2d2d;padding:24px;text-align:center;">'
+ '<h1 style="color:#fff;font-size:18px;margin:0;">๐ ' + label + ' Reminder Summary</h1>'
+ '<p style="color:rgba(255,255,255,0.4);font-size:12px;margin:4px 0 0;">Class date: ' + esc_(targetDate) + '</p>'
+ '</td></tr>'
+ '<tr><td style="background:#0d0d0d;padding:24px;">'
+ '<table width="100%" cellpadding="0" cellspacing="8" border="0">'
+ summaryRow_('๐ฑ SMS Sent', stats.sms)
+ summaryRow_('๐ง Email Fallbacks', stats.email)
+ summaryRow_('โญ๏ธ Already Reminded', stats.skipped)
+ summaryRow_('๐ซ Opted Out', stats.optedOut)
+ summaryRow_('โ Failed', stats.failed)
+ '</table>'
+ '<div style="margin-top:16px;padding:16px;background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;text-align:center;">'
+ '<div style="font-size:28px;font-weight:800;color:#ff2d2d;">' + total + '</div>'
+ '<div style="font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px;margin-top:4px;">Students Reached</div>'
+ '</div>'
+ '</td></tr>'
+ '<tr><td style="background:#000;padding:16px 24px;text-align:center;">'
+ '<p style="font-size:10px;color:rgba(255,255,255,0.2);">' + esc_(CONFIG.SCHOOL_NAME) + ' โ Automated Reminder System</p>'
+ '</td></tr>'
+ '</table></td></tr></table></body></html>';
try {
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: subject,
body: '5-Hour Class Reminders (' + label + ') for ' + targetDate + ': ' + total + ' students reached (' + stats.sms + ' SMS, ' + stats.email + ' email). Failed: ' + stats.failed,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
} catch (e) {
Logger.log('Admin summary email failed: ' + e.message);
}
}
function summaryRow_(label, value) {
return '<tr><td style="padding:8px 12px;background:rgba(255,255,255,0.03);border-radius:8px;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="color:rgba(255,255,255,0.6);font-size:13px;">' + label + '</td>'
+ '<td align="right" style="color:#fff;font-size:14px;font-weight:700;">' + value + '</td>'
+ '</tr></table></td></tr>';
}
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try {
MailApp.sendEmail(CONFIG.ADMIN_EMAIL, subject, body, { name: CONFIG.SCHOOL_NAME });
} catch (_) { /* silent */ }
}
/* ================================================================
MANUAL TOOLS
================================================================ */
/** Test: run a demo reminder check (logs only, no real sends). */
function testDemoReminders() {
const wasDemoMode = CONFIG.DEMO_MODE;
// Force demo mode for safety
Logger.log('๐ญ Running demo test...');
Logger.log('Students who would receive reminders:');
DEMO.students.forEach((s, i) => {
const method = s.phone ? 'SMS (' + (s.carrier || 'default') + ')' : 'Email fallback';
Logger.log(' ' + (i + 1) + '. ' + s.name + ' โ ' + method);
});
Logger.log('\nSummary: ' + DEMO.summary.smsReminders + ' SMS, ' + DEMO.summary.emailFallbacks + ' email fallbacks');
}
/** Force send reminders to all students with class in the next 7 days (use with care). */
function sendAllPendingReminders() {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ cannot send real reminders. Set DEMO_MODE to false first.');
return;
}
runReminders_(1, 'day-before');
}
/* ================================================================
SHARED HELPERS
================================================================ */
/** Header contains any candidate (.includes() matching). Returns 0-based index or -1. */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function esc_(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function sanitize_(str) {
return String(str || '').replace(/[<>{}()\[\]\\\/]/g, '').substring(0, 100).trim();
}
function parseDate_(val) {
if (val instanceof Date) return val;
if (val == null || val === '') return new Date(NaN);
return new Date(val);
}
/**
* =========================================================
* END-OF-DAY SUMMARY EMAIL โ Cherry on Top #1
* Flavors Driving School
* =========================================================
* Daily business briefing delivered at 7 PM
*
* One email. Everything that happened today.
* Tomorrow's schedule. Revenue snapshot. Alerts.
* Mom doesn't lift a finger โ it just shows up.
*
* Features:
* - Demo mode with realistic fake data
* - MailApp (not GmailApp)
* - Email-first + fuzzy name matching for completion detection
* - Attendance counting skips cancelled/no-show/pending/scheduled/upcoming/rescheduled
* - Mission Control theme (#000/#0d0d0d/#ff2d2d)
* - Unsubscribe link
* - Expense tracking + net profit
* - Week-to-date stats
* - Smart contextual greeting
* =========================================================
*/
// โโ CONFIG โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const CONFIG = {
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SIGNUP_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
EXPENSE_TRACKER_ID: '1QyC39fjuslDXk-a_u09XACyHSq782H7NUJajV3vBp8M',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_EMAIL: '[email protected]',
INSTRUCTORS: ['Anisha', 'Carlos', 'Nick'],
WORK_START: 9,
WORK_END: 18,
BOOKINGS_SHEET_TAB: 'Bookings',
REGISTRATION_SHEET_TAB: '',
SIGNUP_SHEET_TAB: '',
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
};
// โโ DEMO DATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const DEMO = {
todaysLessons: [
{ time: '9:00 AM', student: 'Sarah Johnson', instructor: 'Carlos', status: 'Completed' },
{ time: '10:00 AM', student: 'Marcus Williams', instructor: 'Nick', status: 'Completed' },
{ time: '11:00 AM', student: 'Priya Patel', instructor: 'Anisha', status: 'Completed' },
{ time: '1:00 PM', student: 'Alex Rivera', instructor: 'Carlos', status: 'No Show' },
{ time: '2:00 PM', student: 'Sofia Rivera', instructor: 'Anisha', status: 'Completed' },
{ time: '3:00 PM', student: 'David Chen', instructor: 'Nick', status: 'Cancelled' },
{ time: '4:00 PM', student: 'Emma Garcia', instructor: 'Carlos', status: 'Completed' },
],
todaysPayments: [
{ student: 'Sarah Johnson', amount: 200, method: 'Zelle' },
{ student: 'Priya Patel', amount: 150, method: 'Cash' },
{ student: 'Emma Garcia', amount: 300, method: 'Card' },
],
newSignups: [
{ name: 'Tyler Brooks', package: '10 Lessons', email: '[email protected]' },
],
new5HourSignups: [
{ name: 'Jessica Kim' },
],
tomorrowSchedule: [
{ time: '9:00 AM', student: 'Marcus Williams', instructor: 'Nick' },
{ time: '10:00 AM', student: 'Sarah Johnson', instructor: 'Carlos' },
{ time: '11:00 AM', student: 'Priya Patel', instructor: 'Anisha' },
{ time: '1:00 PM', student: 'Sofia Rivera', instructor: 'Carlos' },
{ time: '2:00 PM', student: 'Emma Garcia', instructor: 'Anisha' },
{ time: '3:00 PM', student: 'Tyler Brooks', instructor: 'Nick' },
],
overdueStudents: [
{ name: 'Alex Rivera', balance: 250, daysSince: 21 },
],
completedToday: [
{ name: 'Emma Garcia', package: '10 Lessons', totalLessons: 10 },
],
todaysExpenses: [
{ category: 'Gas', amount: 45.50, notes: 'Instructor vehicles' },
],
weekStats: { weekRevenue: 2150, weekLessons: 32, weekSignups: 4 },
};
// โโ FORCE AUTH โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
SpreadsheetApp.openById(CONFIG.EXPENSE_TRACKER_ID);
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized โ 5 sheets + email.');
}
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function fullSetup() {
setupTrigger_();
Logger.log('โ
End-of-Day Summary fully set up.');
}
function setupTrigger_() {
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'sendEndOfDaySummary') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('sendEndOfDaySummary')
.timeBased()
.everyDays(1)
.atHour(19)
.nearMinute(0)
.create();
Logger.log('โ
Trigger: sendEndOfDaySummary daily at 7 PM');
}
// โโ MAIN โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function sendEndOfDaySummary() {
try {
const isDemo = CONFIG.DEMO_MODE;
if (isDemo) Logger.log('โ ๏ธ DEMO MODE โ using simulated data.');
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const endOfToday = new Date(today);
endOfToday.setHours(23, 59, 59, 999);
let data;
if (isDemo) {
data = {
date: today,
lessonsCompleted: DEMO.todaysLessons.filter(l => !isCancelled_(l.status) && l.status.toLowerCase() !== 'no show').length,
noShows: DEMO.todaysLessons.filter(l => l.status.toLowerCase() === 'no show').length,
cancellations: DEMO.todaysLessons.filter(l => isCancelled_(l.status)).length,
todaysLessons: DEMO.todaysLessons,
revenueToday: DEMO.todaysPayments.reduce((s, p) => s + p.amount, 0),
expensesToday: DEMO.todaysExpenses.reduce((s, e) => s + e.amount, 0),
todaysPayments: DEMO.todaysPayments,
newSignups: DEMO.newSignups,
new5HourSignups: DEMO.new5HourSignups,
tomorrowSchedule: DEMO.tomorrowSchedule,
tomorrowLessonCount: DEMO.tomorrowSchedule.length,
tomorrowOpenSlots: (CONFIG.INSTRUCTORS.length * (CONFIG.WORK_END - CONFIG.WORK_START)) - DEMO.tomorrowSchedule.length,
overdueStudents: DEMO.overdueStudents,
completedToday: DEMO.completedToday,
todaysExpenses: DEMO.todaysExpenses,
weekStats: DEMO.weekStats,
};
} else {
const todaysLessons = getTodaysLessons_(today, endOfToday);
const todaysPayments = getTodaysPayments_(today, endOfToday);
const newSignups = getNewSignups_(today, endOfToday);
const new5HourSignups = getNew5HourSignups_(today, endOfToday);
const tomorrowSchedule = getTomorrowSchedule_(tomorrow);
const overdueStudents = getOverdueStudents_();
const completedToday = getCompletedStudents_(today, endOfToday);
const todaysExpenses = getTodaysExpenses_(today, endOfToday);
const weekStats = getWeekStats_(today);
const tomorrowLessonCount = tomorrowSchedule.length;
data = {
date: today,
lessonsCompleted: todaysLessons.filter(l => !isCancelled_(l.status) && l.status.toLowerCase() !== 'no show').length,
noShows: todaysLessons.filter(l => l.status.toLowerCase() === 'no show').length,
cancellations: todaysLessons.filter(l => isCancelled_(l.status)).length,
todaysLessons,
revenueToday: todaysPayments.reduce((s, p) => s + p.amount, 0),
expensesToday: todaysExpenses.reduce((s, e) => s + e.amount, 0),
todaysPayments,
newSignups,
new5HourSignups,
tomorrowSchedule,
tomorrowLessonCount,
tomorrowOpenSlots: (CONFIG.INSTRUCTORS.length * (CONFIG.WORK_END - CONFIG.WORK_START)) - tomorrowLessonCount,
overdueStudents,
completedToday,
todaysExpenses,
weekStats,
};
}
const html = buildSummaryEmail_(data, isDemo);
const dayName = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][today.getDay()];
const dateStr = formatDate_(today);
MailApp.sendEmail({
to: CONFIG.SCHOOL_EMAIL,
subject: (isDemo ? '[DEMO] ' : '') + '๐ Daily Briefing โ ' + dayName + ', ' + dateStr + ' โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html,
name: CONFIG.SCHOOL_NAME,
});
Logger.log((isDemo ? 'DEMO: ' : '') + 'End-of-day summary sent for ' + dateStr);
} catch (e) {
Logger.log('โ sendEndOfDaySummary error: ' + e.message + '\n' + e.stack);
try {
MailApp.sendEmail({
to: CONFIG.SCHOOL_EMAIL,
subject: 'โ End-of-Day Summary failed โ ' + CONFIG.SCHOOL_NAME,
body: 'The daily briefing did not send.\n\nError: ' + (e.message || e.toString()),
});
} catch (mailErr) { Logger.log('Could not send error email: ' + mailErr.message); }
}
}
/** Test: send summary now without waiting for 7 PM */
function testSendNow() {
sendEndOfDaySummary();
}
// ================================================================
// DATA GATHERING
// ================================================================
function getBookingsSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
if (CONFIG.BOOKINGS_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
function getRegistrationSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
if (CONFIG.REGISTRATION_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
function getSignupSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUP_SHEET_ID);
if (CONFIG.SIGNUP_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.SIGNUP_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
function getTodaysLessons_(today, endOfToday) {
const sheet = getBookingsSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
if (dateCol === -1) return [];
const lessons = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime())) continue;
if (date >= today && date <= endOfToday) {
lessons.push({
time: String(data[i][timeCol] || ''),
student: String(data[i][studentCol] || '').trim(),
instructor: String(data[i][instructorCol] || '').trim(),
status: String(data[i][statusCol] || 'Scheduled').trim(),
});
}
}
lessons.sort((a, b) => parseTime_(a.time) - parseTime_(b.time));
return lessons;
}
function getTodaysPayments_(today, endOfToday) {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const sheet = ss.getSheetByName('Payments') || ss.getSheets()[0];
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'payment date', 'timestamp']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const amountCol = findCol_(headers, ['amount', 'payment amount']);
const methodCol = findCol_(headers, ['method', 'payment method']);
if (dateCol === -1) return [];
const payments = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime()) || date < today || date > endOfToday) continue;
payments.push({
student: String(data[i][studentCol] || '').trim(),
amount: parseFloat(data[i][amountCol]) || 0,
method: methodCol !== -1 ? String(data[i][methodCol] || '').trim() : '',
});
}
return payments;
}
function getNewSignups_(today, endOfToday) {
const sheet = getRegistrationSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const tsCol = findCol_(headers, ['timestamp', 'date', 'submitted']);
const nameCol = findCol_(headers, ['full name', 'name', 'student name']);
const packageCol = findCol_(headers, ['lesson package', 'package', 'class type', 'selected package']);
const emailCol = findCol_(headers, ['email', 'email address', 'student email']);
if (tsCol === -1) return [];
const signups = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][tsCol] instanceof Date ? data[i][tsCol] : new Date(data[i][tsCol]);
if (isNaN(date.getTime()) || date < today || date > endOfToday) continue;
signups.push({
name: String(data[i][nameCol] || '').trim(),
package: packageCol !== -1 ? String(data[i][packageCol] || '').trim() : '',
email: emailCol !== -1 ? String(data[i][emailCol] || '').trim() : '',
});
}
return signups;
}
function getNew5HourSignups_(today, endOfToday) {
const sheet = getSignupSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const tsCol = findCol_(headers, ['timestamp', 'date']);
const nameCol = findCol_(headers, ['full name', 'name']);
if (tsCol === -1) return [];
const signups = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][tsCol] instanceof Date ? data[i][tsCol] : new Date(data[i][tsCol]);
if (isNaN(date.getTime()) || date < today || date > endOfToday) continue;
signups.push({ name: String(data[i][nameCol] || '').trim() });
}
return signups;
}
function getTomorrowSchedule_(tomorrow) {
const endOfTomorrow = new Date(tomorrow);
endOfTomorrow.setHours(23, 59, 59, 999);
const sheet = getBookingsSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
if (dateCol === -1) return [];
const lessons = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime()) || date < tomorrow || date > endOfTomorrow) continue;
if (isCancelled_(String(data[i][statusCol] || ''))) continue;
lessons.push({
time: String(data[i][timeCol] || ''),
student: String(data[i][studentCol] || '').trim(),
instructor: String(data[i][instructorCol] || '').trim(),
});
}
lessons.sort((a, b) => parseTime_(a.time) - parseTime_(b.time));
return lessons;
}
function getOverdueStudents_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const balSheet = ss.getSheetByName('Student Balances');
if (!balSheet || balSheet.getLastRow() <= 1) return [];
const data = balSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const balCol = findCol_(headers, ['balance', 'remaining balance', 'amount due']);
const lastPayCol = findCol_(headers, ['last payment', 'last payment date']);
if (nameCol === -1 || balCol === -1) return [];
const overdue = [];
const now = new Date();
const fourteenDays = 14 * 24 * 60 * 60 * 1000;
for (let i = 1; i < data.length; i++) {
const balance = parseFloat(data[i][balCol]) || 0;
if (balance <= 0) continue;
const lastPay = lastPayCol !== -1 ? (data[i][lastPayCol] instanceof Date ? data[i][lastPayCol] : new Date(data[i][lastPayCol])) : null;
if (lastPay && !isNaN(lastPay.getTime()) && (now - lastPay) > fourteenDays) {
overdue.push({
name: String(data[i][nameCol] || '').trim(),
balance: balance,
daysSince: Math.floor((now - lastPay) / (24 * 60 * 60 * 1000)),
});
}
}
overdue.sort((a, b) => b.daysSince - a.daysSince);
return overdue;
}
function getCompletedStudents_(today, endOfToday) {
const sheet = getBookingsSheet_();
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const studentCol = findCol_(headers, ['student', 'student name', 'name']);
const emailCol = findCol_(headers, ['email', 'student email']);
const statusCol = findCol_(headers, ['status', 'booking status']);
if (dateCol === -1 || studentCol === -1) return [];
// Only count confirmed past lessons
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'pending', 'scheduled', 'upcoming', 'rescheduled'];
const lessonsByName = {};
const lessonsByEmail = {};
const hadLessonToday = {};
for (let i = 1; i < data.length; i++) {
const status = String(data[i][statusCol] || '').toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime()) || date > endOfToday) continue;
const sName = String(data[i][studentCol] || '').trim().toLowerCase();
const sEmail = emailCol !== -1 ? String(data[i][emailCol] || '').trim().toLowerCase() : '';
if (sName) lessonsByName[sName] = (lessonsByName[sName] || 0) + 1;
if (sEmail && sEmail.includes('@')) lessonsByEmail[sEmail] = (lessonsByEmail[sEmail] || 0) + 1;
if (date >= today && date <= endOfToday) {
if (sName) hadLessonToday[sName] = true;
if (sEmail) hadLessonToday[sEmail] = true;
}
}
const regSheet = getRegistrationSheet_();
if (!regSheet || regSheet.getLastRow() <= 1) return [];
const regData = regSheet.getDataRange().getValues();
const regHeaders = regData[0].map(h => String(h).toLowerCase().trim());
const regNameCol = findCol_(regHeaders, ['full name', 'name', 'student name']);
const regEmailCol = findCol_(regHeaders, ['email', 'email address', 'student email']);
const regPkgCol = findCol_(regHeaders, ['lesson package', 'package', 'selected package']);
if (regNameCol === -1) return [];
const completed = [];
for (let i = 1; i < regData.length; i++) {
const name = String(regData[i][regNameCol] || '').trim();
const email = regEmailCol !== -1 ? String(regData[i][regEmailCol] || '').trim().toLowerCase() : '';
const pkg = regPkgCol !== -1 ? String(regData[i][regPkgCol] || '') : '';
const totalLessons = extractLessonCount_(pkg);
if (totalLessons <= 0) continue;
const nameKey = name.toLowerCase();
// Count completed: email-first, then name, then fuzzy
let count = 0;
if (email && lessonsByEmail[email]) {
count = lessonsByEmail[email];
} else if (lessonsByName[nameKey]) {
count = lessonsByName[nameKey];
} else {
for (const [key, c] of Object.entries(lessonsByName)) {
if (fuzzyNameMatch_(nameKey, key)) { count = c; break; }
}
}
// Check if had a lesson today (email or name or fuzzy)
let todayFlag = hadLessonToday[nameKey] || hadLessonToday[email];
if (!todayFlag) {
for (const key of Object.keys(hadLessonToday)) {
if (fuzzyNameMatch_(nameKey, key)) { todayFlag = true; break; }
}
}
if (count >= totalLessons && todayFlag) {
completed.push({ name, package: pkg, totalLessons });
}
}
return completed;
}
function getTodaysExpenses_(today, endOfToday) {
try {
const ss = SpreadsheetApp.openById(CONFIG.EXPENSE_TRACKER_ID);
const sheet = ss.getSheets()[0];
if (!sheet || sheet.getLastRow() <= 1) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'timestamp', 'expense date']);
const categoryCol = findCol_(headers, ['category', 'expense category']);
const amountCol = findCol_(headers, ['amount', 'expense amount']);
const notesCol = findCol_(headers, ['notes', 'description']);
if (dateCol === -1) return [];
const expenses = [];
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime()) || date < today || date > endOfToday) continue;
expenses.push({
category: categoryCol !== -1 ? String(data[i][categoryCol] || '').trim() : '',
amount: parseFloat(data[i][amountCol]) || 0,
notes: notesCol !== -1 ? String(data[i][notesCol] || '').trim() : '',
});
}
return expenses;
} catch (e) { Logger.log('Expense fetch error: ' + e.message); return []; }
}
function getWeekStats_(today) {
const monday = new Date(today);
const dayOfWeek = monday.getDay();
const diff = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
monday.setDate(monday.getDate() - diff);
monday.setHours(0, 0, 0, 0);
const endOfToday = new Date(today);
endOfToday.setHours(23, 59, 59, 999);
let weekRevenue = 0;
try {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const paySheet = ss.getSheetByName('Payments') || ss.getSheets()[0];
if (paySheet && paySheet.getLastRow() > 1) {
const data = paySheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'payment date', 'timestamp']);
const amountCol = findCol_(headers, ['amount', 'payment amount']);
if (dateCol !== -1 && amountCol !== -1) {
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (!isNaN(date.getTime()) && date >= monday && date <= endOfToday) {
weekRevenue += parseFloat(data[i][amountCol]) || 0;
}
}
}
}
} catch (e) { Logger.log('Week revenue error: ' + e.message); }
let weekLessons = 0;
try {
const schedSheet = getBookingsSheet_();
if (schedSheet && schedSheet.getLastRow() > 1) {
const data = schedSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const statusCol = findCol_(headers, ['status', 'booking status']);
if (dateCol !== -1) {
for (let i = 1; i < data.length; i++) {
const date = data[i][dateCol] instanceof Date ? data[i][dateCol] : new Date(data[i][dateCol]);
if (isNaN(date.getTime()) || date < monday || date > endOfToday) continue;
if (isCancelled_(String(data[i][statusCol] || ''))) continue;
weekLessons++;
}
}
}
} catch (e) { Logger.log('Week lessons error: ' + e.message); }
let weekSignups = 0;
try {
const regSheet = getRegistrationSheet_();
if (regSheet && regSheet.getLastRow() > 1) {
const data = regSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const tsCol = findCol_(headers, ['timestamp', 'date', 'submitted']);
if (tsCol !== -1) {
for (let i = 1; i < data.length; i++) {
const date = data[i][tsCol] instanceof Date ? data[i][tsCol] : new Date(data[i][tsCol]);
if (!isNaN(date.getTime()) && date >= monday && date <= endOfToday) weekSignups++;
}
}
}
} catch (e) { Logger.log('Week signups error: ' + e.message); }
return { weekRevenue, weekLessons, weekSignups };
}
// ================================================================
// EMAIL BUILDER โ Mission Control Theme
// ================================================================
function buildSummaryEmail_(d, isDemo) {
const dayName = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][d.date.getDay()];
const dateStr = formatDateLong_(d.date);
const tomorrowDay = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][(d.date.getDay() + 1) % 7];
let greeting = 'Here\'s your daily briefing.';
if (d.lessonsCompleted === 0 && d.newSignups.length === 0) greeting = 'Quiet day today. Here\'s the snapshot.';
else if (d.revenueToday > 500) greeting = 'Great day for revenue! Here\'s the full breakdown.';
else if (d.completedToday.length > 0) greeting = 'We had a graduate today! ๐ Here\'s everything.';
else if (d.newSignups.length > 0) greeting = 'New students signed up today! Here\'s the rundown.';
const demoTag = isDemo ? '<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:10px 16px;margin-bottom:20px;text-align:center;"><span style="color:#ff6b6b;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;">โฆ Demo Mode โ Simulated Data</span></div>' : '';
// Stats cards
const stats =
'<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;"><tr>' +
statCard_('Lessons', d.lessonsCompleted, d.noShows > 0 ? d.noShows + ' no-show' + (d.noShows > 1 ? 's' : '') : 'โ All showed up', '#fff', d.noShows > 0 ? '#ff2d2d' : '#22c55e') +
statCard_('Revenue', '$' + d.revenueToday.toLocaleString(), d.todaysPayments.length + ' payment' + (d.todaysPayments.length !== 1 ? 's' : ''), d.revenueToday > 0 ? '#22c55e' : '#fff', '#555') +
statCard_('New Students', d.newSignups.length + d.new5HourSignups.length, d.newSignups.length + ' lesson + ' + d.new5HourSignups.length + ' 5-hr', d.newSignups.length > 0 ? '#3b82f6' : '#fff', '#555') +
statCard_('Tomorrow', d.tomorrowLessonCount, d.tomorrowOpenSlots + ' slots open', '#fff', '#f59e0b') +
'</tr></table>';
// Week-to-date
const week =
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:16px 20px;margin-bottom:24px;">' +
'<div style="font-size:11px;color:#555;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;">Week-to-Date</div>' +
'<table width="100%" cellpadding="0" cellspacing="0">' +
weekRow_('Revenue this week', '$' + (d.weekStats.weekRevenue || 0).toLocaleString(), '#22c55e') +
weekRow_('Lessons this week', d.weekStats.weekLessons || 0, '#fff') +
weekRow_('New signups this week', d.weekStats.weekSignups || 0, '#3b82f6') +
'</table></div>';
// Graduates
let graduatesBlock = '';
if (d.completedToday.length > 0) {
let gl = '';
d.completedToday.forEach(g => {
gl += '<div style="padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="color:#fff;font-size:14px;font-weight:600;">' + esc_(g.name) + '</span>' +
'<span style="color:#22c55e;font-size:12px;margin-left:10px;">Completed ' + g.totalLessons + ' lessons!</span></div>';
});
graduatesBlock =
'<div style="background:rgba(34,197,94,0.04);border:1px solid rgba(34,197,94,0.15);border-radius:14px;padding:16px 20px;margin-bottom:24px;">' +
'<div style="font-size:13px;font-weight:600;color:#22c55e;margin-bottom:12px;">๐ Package Completed Today!</div>' +
gl +
'<div style="font-size:11px;color:#555;margin-top:8px;">Review request + referral code + social post will be auto-generated</div></div>';
}
// Signups
let signupsBlock = '';
if (d.newSignups.length > 0 || d.new5HourSignups.length > 0) {
let sl = '';
d.newSignups.forEach(s => {
sl += '<div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="color:#fff;font-size:13px;">' + esc_(s.name) + '</span>' +
'<span style="color:#3b82f6;font-size:12px;font-weight:600;">' + esc_(s.package) + '</span></div>';
});
d.new5HourSignups.forEach(s => {
sl += '<div style="display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="color:#fff;font-size:13px;">' + esc_(s.name) + '</span>' +
'<span style="color:#f59e0b;font-size:12px;font-weight:600;">5-Hour Class</span></div>';
});
signupsBlock =
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:16px 20px;margin-bottom:24px;">' +
'<div style="font-size:13px;font-weight:600;color:#fff;margin-bottom:12px;">New Signups Today</div>' + sl + '</div>';
}
// Today's lessons table
let lessonRows = '';
if (d.todaysLessons.length > 0) {
d.todaysLessons.forEach(l => {
const isNoShow = l.status.toLowerCase() === 'no show';
const isCan = isCancelled_(l.status);
const sColor = isNoShow ? '#ff2d2d' : isCan ? '#555' : '#22c55e';
const sIcon = isNoShow ? 'โ' : isCan ? 'โ' : 'โ';
lessonRows +=
'<tr>' +
'<td style="color:#888;font-size:13px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);white-space:nowrap;">' + esc_(l.time) + '</td>' +
'<td style="color:#fff;font-size:13px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(l.student) + '</td>' +
'<td style="color:#888;font-size:13px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(l.instructor) + '</td>' +
'<td align="right" style="padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);"><span style="color:' + sColor + ';font-size:12px;font-weight:600;">' + sIcon + ' ' + esc_(l.status) + '</span></td></tr>';
});
} else {
lessonRows = '<tr><td colspan="4" style="color:#444;font-size:13px;padding:16px 8px;text-align:center;">No lessons scheduled today</td></tr>';
}
const lessonsBlock = sectionTable_('Today\'s Lessons',
'<tr>' +
thCell_('Time') + thCell_('Student') + thCell_('Instructor') +
'<td align="right" style="color:#ff2d2d;font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.06);">Status</td>' +
'</tr>' + lessonRows);
// Payments
let paymentsBlock = '';
if (d.todaysPayments.length > 0) {
let pr = '';
d.todaysPayments.forEach(p => {
pr += '<tr>' +
'<td style="color:#fff;font-size:13px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(p.student) + '</td>' +
'<td style="color:#22c55e;font-size:14px;font-weight:700;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">+$' + p.amount.toFixed(2) + '</td>' +
'<td align="right" style="color:#555;font-size:12px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(p.method) + '</td></tr>';
});
paymentsBlock = sectionTable_('Payments Received', pr);
}
// Expenses
let expensesBlock = '';
if (d.todaysExpenses.length > 0) {
let er = '';
d.todaysExpenses.forEach(e => {
er += '<tr>' +
'<td style="color:#888;font-size:13px;padding:8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(e.category) + '</td>' +
'<td style="color:#ff6b6b;font-size:13px;font-weight:600;padding:8px;border-bottom:1px solid rgba(255,255,255,0.04);">-$' + e.amount.toFixed(2) + '</td>' +
'<td style="color:#555;font-size:12px;padding:8px;border-bottom:1px solid rgba(255,255,255,0.04);">' + esc_(e.notes) + '</td></tr>';
});
const net = d.revenueToday - d.expensesToday;
expensesBlock =
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;overflow:hidden;margin-bottom:24px;">' +
'<div style="padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<span style="font-size:13px;font-weight:600;color:#fff;">Expenses</span>' +
'<span style="font-size:12px;color:' + (net >= 0 ? '#22c55e' : '#ff2d2d') + ';font-weight:600;float:right;">Net: ' + (net >= 0 ? '+' : '') + '$' + net.toFixed(2) + '</span></div>' +
'<table width="100%" cellpadding="0" cellspacing="0">' + er + '</table></div>';
}
// Tomorrow schedule by instructor
const instructorMap = {};
CONFIG.INSTRUCTORS.forEach(i => { instructorMap[i] = []; });
d.tomorrowSchedule.forEach(l => {
const inst = l.instructor || 'Other';
if (!instructorMap[inst]) instructorMap[inst] = [];
instructorMap[inst].push(l);
});
let tomorrowRows = '';
const allInstructors = CONFIG.INSTRUCTORS.slice();
Object.keys(instructorMap).forEach(inst => { if (!allInstructors.includes(inst)) allInstructors.push(inst); });
allInstructors.forEach(inst => {
const lessons = instructorMap[inst] || [];
tomorrowRows +=
'<tr><td colspan="2" style="padding:12px 8px 6px;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="color:#ff2d2d;font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:1px;">' + esc_(inst) + '</span>' +
'<span style="color:#444;font-size:11px;margin-left:8px;">' + lessons.length + ' lesson' + (lessons.length !== 1 ? 's' : '') + '</span></td></tr>';
if (lessons.length > 0) {
lessons.forEach(l => {
tomorrowRows += '<tr>' +
'<td style="color:#888;font-size:13px;padding:6px 8px 6px 20px;border-bottom:1px solid rgba(255,255,255,0.02);">' + esc_(l.time) + '</td>' +
'<td style="color:#ccc;font-size:13px;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.02);">' + esc_(l.student) + '</td></tr>';
});
} else {
tomorrowRows += '<tr><td colspan="2" style="color:#333;font-size:12px;padding:6px 8px 6px 20px;">No lessons booked</td></tr>';
}
});
const tomorrowBlock =
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;overflow:hidden;margin-bottom:24px;">' +
'<div style="padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<span style="font-size:13px;font-weight:600;color:#fff;">Tomorrow โ ' + esc_(tomorrowDay) + '</span>' +
'<span style="font-size:12px;color:#f59e0b;margin-left:10px;">' + d.tomorrowLessonCount + ' booked ยท ' + d.tomorrowOpenSlots + ' open</span></div>' +
'<table width="100%" cellpadding="0" cellspacing="0">' + tomorrowRows + '</table></div>';
// Alerts
let alertsBlock = '';
if (d.overdueStudents.length > 0 || d.cancellations > 0) {
let items = '';
if (d.cancellations > 0) {
items += '<div style="padding:8px 0;"><span style="color:#f59e0b;">' + d.cancellations + ' cancellation' + (d.cancellations > 1 ? 's' : '') + ' today</span></div>';
}
d.overdueStudents.slice(0, 5).forEach(s => {
items += '<div style="padding:8px 0;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="color:#ff6b6b;">' + esc_(s.name) + '</span>' +
'<span style="color:#555;font-size:12px;"> โ $' + s.balance.toFixed(2) + ' overdue (' + s.daysSince + ' days)</span></div>';
});
alertsBlock =
'<div style="background:rgba(255,45,45,0.04);border:1px solid rgba(255,45,45,0.15);border-radius:14px;padding:16px 20px;margin-bottom:24px;">' +
'<div style="font-size:13px;font-weight:600;color:#ff2d2d;margin-bottom:10px;">โ ๏ธ Attention Needed</div>' + items + '</div>';
}
// Unsubscribe
const unsubLink = '<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="mailto:' + esc_(CONFIG.SCHOOL_EMAIL) + '?subject=Unsubscribe%20Daily%20Briefing" style="color:#555;text-decoration:underline;">Unsubscribe</a></p>';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,\'SF Pro Display\',sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:20px;">' +
'<tr><td align="center">' +
'<table width="620" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;overflow:hidden;border:1px solid rgba(255,255,255,0.06);box-shadow:0 8px 30px rgba(0,0,0,0.5);">' +
// Header
'<tr><td style="padding:32px 36px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<table width="100%"><tr><td>' +
'<div style="font-size:10px;color:#ff2d2d;text-transform:uppercase;letter-spacing:2px;font-weight:700;margin-bottom:8px;">DAILY BRIEFING</div>' +
'<div style="font-size:24px;font-weight:800;color:#fff;letter-spacing:-0.5px;">' + esc_(dayName) + ', ' + esc_(dateStr) + '</div>' +
'</td><td align="right" style="vertical-align:top;">' +
'<div style="background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.12);border-radius:12px;padding:8px 14px;">' +
'<div style="font-size:10px;color:#555;text-transform:uppercase;letter-spacing:1px;">7 PM Report</div>' +
'<div style="font-size:14px;color:#ff2d2d;font-weight:600;margin-top:2px;">' + esc_(CONFIG.SCHOOL_NAME) + '</div></div>' +
'</td></tr></table>' +
'<div style="font-size:14px;color:#888;margin-top:12px;">' + esc_(greeting) + '</div>' +
'</td></tr>' +
// Body
'<tr><td style="padding:28px 36px;">' +
demoTag + stats + week + graduatesBlock + signupsBlock + lessonsBlock + paymentsBlock + expensesBlock + tomorrowBlock + alertsBlock +
'<div style="text-align:center;padding:20px 0 0;"><div style="font-size:13px;color:#555;">That\'s your day. See you tomorrow at 7 PM.</div></div>' +
'</td></tr>' +
// Footer
'<tr><td style="padding:16px 36px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">' +
'<p style="color:#333;font-size:11px;margin:0;">End-of-Day Summary โข <span style="color:#ff2d2d;">' + esc_(CONFIG.SCHOOL_NAME) + '</span></p>' +
'<p style="color:#222;font-size:10px;margin:4px 0 0;">Powered by AI Automation</p>' +
unsubLink +
'</td></tr></table></td></tr></table></body></html>';
}
// โโ Email helpers โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function statCard_(label, value, subtitle, valueColor, subColor) {
return '<td width="25%" style="padding:0 4px;">' +
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;padding:18px 14px;text-align:center;">' +
'<div style="font-size:11px;color:#555;text-transform:uppercase;letter-spacing:1px;">' + esc_(label) + '</div>' +
'<div style="font-size:28px;font-weight:800;color:' + valueColor + ';margin:6px 0 2px;">' + value + '</div>' +
'<div style="font-size:11px;color:' + subColor + ';">' + subtitle + '</div></div></td>';
}
function weekRow_(label, value, color) {
return '<tr><td style="color:#888;font-size:13px;padding:4px 0;">' + esc_(label) + '</td>' +
'<td align="right" style="color:' + color + ';font-size:14px;font-weight:700;">' + value + '</td></tr>';
}
function sectionTable_(title, rows) {
return '<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.06);border-radius:14px;overflow:hidden;margin-bottom:24px;">' +
'<div style="padding:16px 20px;border-bottom:1px solid rgba(255,255,255,0.06);"><span style="font-size:13px;font-weight:600;color:#fff;">' + esc_(title) + '</span></div>' +
'<table width="100%" cellpadding="0" cellspacing="0">' + rows + '</table></div>';
}
function thCell_(text) {
return '<td style="color:#ff2d2d;font-size:10px;text-transform:uppercase;letter-spacing:1px;padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.06);">' + esc_(text) + '</td>';
}
// ================================================================
// FUZZY NAME MATCHING
// ================================================================
function fuzzyNameMatch_(name1, name2) {
if (!name1 || !name2) return false;
const n1 = name1.toLowerCase().replace(/\s+/g, ' ').trim();
const n2 = name2.toLowerCase().replace(/\s+/g, ' ').trim();
if (n1 === n2) return true;
if (n1.includes(n2) || n2.includes(n1)) return true;
const p1 = n1.split(' ').filter(Boolean);
const p2 = n2.split(' ').filter(Boolean);
if (p1.length >= 2 && p2.length >= 2) {
if (p1[p1.length-1] === p2[p2.length-1] && p1[0].substring(0,3) === p2[0].substring(0,3)) return true;
if (p1[0] === p2[p2.length-1] && p1[p1.length-1] === p2[0]) return true;
}
if (levenshtein_(n1, n2) <= 2) return true;
return false;
}
function levenshtein_(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
m[i][j] = b.charAt(i-1) === a.charAt(j-1) ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
}
}
return m[b.length][a.length];
}
// ================================================================
// UTILITIES
// ================================================================
function findCol_(headers, candidates) {
for (const c of candidates) {
const idx = headers.findIndex(h => h.includes(c.toLowerCase()));
if (idx !== -1) return idx;
}
return -1;
}
function isCancelled_(status) {
const s = String(status || '').toLowerCase().trim();
return s === 'cancelled' || s === 'canceled';
}
function esc_(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function formatDate_(date) {
if (!(date instanceof Date)) date = new Date(date);
if (isNaN(date.getTime())) return '';
return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear();
}
function formatDateLong_(date) {
if (!(date instanceof Date)) date = new Date(date);
if (isNaN(date.getTime())) return '';
const months = ['January','February','March','April','May','June','July','August','September','October','November','December'];
return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
}
function extractLessonCount_(pkg) {
if (!pkg) return 0;
const raw = String(pkg).toLowerCase().trim();
if (raw.includes('5-hour') || raw.includes('5 hour')) return 1;
const match = raw.match(/(\d+)\s*lesson/i);
if (match) return parseInt(match[1]);
const match2 = raw.match(/^(\d+)$/);
if (match2) return parseInt(match2[1]);
if (raw.includes('beginner')) return 10;
if (raw.includes('standard')) return 10;
if (raw.includes('premium')) return 15;
if (raw.includes('intensive')) return 20;
return 0;
}
function parseTime_(timeStr) {
if (!timeStr) return 0;
const match = timeStr.toString().match(/(\d{1,2}):?(\d{2})?\s*(AM|PM)?/i);
if (!match) return 0;
let h = parseInt(match[1]);
const m = parseInt(match[2] || '0');
const period = (match[3] || '').toUpperCase();
if (period === 'PM' && h < 12) h += 12;
if (period === 'AM' && h === 12) h = 0;
return h * 60 + m;
}
/**
* =========================================================
* EXPENSE TRACKER
* Flavors Driving School
* =========================================================
* Processes expense form submissions, categorizes spending,
* tracks budgets, and generates monthly reports.
*
* - Auto-processes form submissions with Expense IDs (EXP-0001)
* - Budget threshold alerts by category
* - Monthly expense summary emails (1st of each month)
* - Weekly spending digest (Monday mornings)
* - Duplicate receipt detection
* - Category breakdown with YTD totals
* - getExpenseSummary() API for BI Dashboard
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet ID โโ
EXPENSE_SHEET_ID: '1QyC39fjuslDXk-a_u09XACyHSq782H7NUJajV3vBp8M',
// โโ Sheet tabs โโ
FORM_RESPONSES_TAB: 'Form Responses 1',
EXPENSE_LOG_TAB: 'Expense Log',
MONTHLY_REPORT_TAB: 'Monthly Report',
SETTINGS_TAB: 'Settings',
BUDGET_TAB: 'Budgets',
// โโ Admin โโ
ADMIN_EMAIL: '[email protected]',
SCHOOL_NAME: 'Flavors Driving School',
// โโ Demo Mode โโ
DEMO_MODE: true,
// โโ Default Categories โโ
CATEGORIES: [
'Vehicle Maintenance',
'Fuel / Gas',
'Insurance',
'Instructor Pay',
'Office Supplies',
'Marketing / Advertising',
'Rent / Utilities',
'Technology / Software',
'Licensing / Permits',
'Training Materials',
'Miscellaneous'
],
// โโ Default Monthly Budgets (per category) โโ
DEFAULT_BUDGETS: {
'Vehicle Maintenance': 500,
'Fuel / Gas': 800,
'Insurance': 1200,
'Instructor Pay': 5000,
'Office Supplies': 200,
'Marketing / Advertising': 300,
'Rent / Utilities': 1500,
'Technology / Software': 100,
'Licensing / Permits': 150,
'Training Materials': 100,
'Miscellaneous': 200
},
// โโ Alert threshold (% of budget) โโ
BUDGET_ALERT_PERCENT: 80
};
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
AUTH & SETUP
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function forceAuth() {
const ss = SpreadsheetApp.openById(CONFIG.EXPENSE_SHEET_ID);
Logger.log('Expense Tracker sheet: ' + ss.getName());
MailApp.getRemainingDailyQuota();
Logger.log('MailApp authorized. Remaining quota: ' + MailApp.getRemainingDailyQuota());
Logger.log('forceAuth complete โ
');
}
function fullSetup() {
const ss = getSpreadsheet_();
// โโ Expense Log sheet โโ
let logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet) {
logSheet = ss.insertSheet(CONFIG.EXPENSE_LOG_TAB);
logSheet.getRange(1, 1, 1, 12).setValues([[
'Expense ID', 'Timestamp', 'Date of Expense', 'Expense Category',
'Amount ($)', 'Receipt Number', 'Notes / Description', 'Processed Date',
'Month-Year', 'Fiscal Quarter', 'Duplicate Flag', 'Status'
]]);
logSheet.getRange(1, 1, 1, 12).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
logSheet.setFrozenRows(1);
Logger.log('Created Expense Log sheet โ
');
} else {
Logger.log('Expense Log sheet already exists โ
');
}
// โโ Settings sheet โโ
let settingsSheet = ss.getSheetByName(CONFIG.SETTINGS_TAB);
if (!settingsSheet) {
settingsSheet = ss.insertSheet(CONFIG.SETTINGS_TAB);
settingsSheet.getRange(1, 1, 1, 3).setValues([['Setting', 'Value', 'Description']]);
settingsSheet.getRange(1, 1, 1, 3).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
const settings = [
['DEMO_MODE', 'true', 'Set to false for production'],
['ADMIN_EMAIL', CONFIG.ADMIN_EMAIL, 'Admin receives alerts and reports'],
['BUDGET_ALERT_PERCENT', '80', 'Alert when category reaches this % of budget'],
['MONTHLY_REPORT_DAY', '1', 'Day of month to send expense report (1-28)'],
['WEEKLY_DIGEST', 'true', 'Send weekly spending digest on Mondays'],
['SCHOOL_NAME', CONFIG.SCHOOL_NAME, 'Business name for reports']
];
settingsSheet.getRange(2, 1, settings.length, 3).setValues(settings);
settingsSheet.autoResizeColumns(1, 3);
Logger.log('Created Settings sheet โ
');
} else {
Logger.log('Settings sheet already exists โ
');
}
// โโ Budgets sheet โโ
let budgetSheet = ss.getSheetByName(CONFIG.BUDGET_TAB);
if (!budgetSheet) {
budgetSheet = ss.insertSheet(CONFIG.BUDGET_TAB);
budgetSheet.getRange(1, 1, 1, 4).setValues([['Category', 'Monthly Budget ($)', 'Current Month Spent ($)', 'Remaining ($)']]);
budgetSheet.getRange(1, 1, 1, 4).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
const budgetRows = CONFIG.CATEGORIES.map(cat => [cat, CONFIG.DEFAULT_BUDGETS[cat] || 0, 0, 0]);
budgetSheet.getRange(2, 1, budgetRows.length, 4).setValues(budgetRows);
budgetSheet.autoResizeColumns(1, 4);
Logger.log('Created Budgets sheet โ
');
} else {
Logger.log('Budgets sheet already exists โ
');
}
// โโ Monthly Report sheet โโ
let reportSheet = ss.getSheetByName(CONFIG.MONTHLY_REPORT_TAB);
if (!reportSheet) {
reportSheet = ss.insertSheet(CONFIG.MONTHLY_REPORT_TAB);
reportSheet.getRange(1, 1, 1, 6).setValues([['Month-Year', 'Category', 'Total Spent ($)', 'Budget ($)', 'Variance ($)', '% of Budget']]);
reportSheet.getRange(1, 1, 1, 6).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
reportSheet.setFrozenRows(1);
Logger.log('Created Monthly Report sheet โ
');
} else {
Logger.log('Monthly Report sheet already exists โ
');
}
// โโ Triggers (clean duplicates first) โโ
const triggers = ScriptApp.getProjectTriggers();
const wantedFns = ['onExpenseSubmit', 'sendMonthlyExpenseReport', 'sendWeeklyDigest', 'updateBudgetTracking'];
for (const t of triggers) {
if (wantedFns.includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
Logger.log('Removed old trigger: ' + t.getHandlerFunction());
}
}
// onFormSubmit trigger
ScriptApp.newTrigger('onExpenseSubmit')
.forSpreadsheet(CONFIG.EXPENSE_SHEET_ID)
.onFormSubmit()
.create();
Logger.log('Created trigger: onExpenseSubmit (form submit) โ
');
// Monthly report โ 1st of each month at 8 AM
ScriptApp.newTrigger('sendMonthlyExpenseReport')
.timeBased()
.onMonthDay(1)
.atHour(8)
.create();
Logger.log('Created trigger: sendMonthlyExpenseReport (1st of month 8 AM) โ
');
// Weekly digest โ every Monday 9 AM
ScriptApp.newTrigger('sendWeeklyDigest')
.timeBased()
.onWeekDay(ScriptApp.WeekDay.MONDAY)
.atHour(9)
.create();
Logger.log('Created trigger: sendWeeklyDigest (Monday 9 AM) โ
');
// Budget tracking update โ daily at midnight
ScriptApp.newTrigger('updateBudgetTracking')
.timeBased()
.everyDays(1)
.atHour(0)
.create();
Logger.log('Created trigger: updateBudgetTracking (daily midnight) โ
');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
Logger.log('fullSetup complete โ
');
Logger.log('Sheets created: Expense Log, Settings, Budgets, Monthly Report');
Logger.log('Triggers: onExpenseSubmit, sendMonthlyExpenseReport, sendWeeklyDigest, updateBudgetTracking');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
HELPERS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getSpreadsheet_() {
return SpreadsheetApp.openById(CONFIG.EXPENSE_SHEET_ID);
}
function getSetting_(key, fallback) {
try {
const ss = getSpreadsheet_();
const sheet = ss.getSheetByName(CONFIG.SETTINGS_TAB);
if (!sheet) return fallback;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]).trim().toUpperCase() === key.toUpperCase()) {
return String(data[i][1]).trim();
}
}
} catch (e) { /* ignore */ }
return fallback;
}
function isDemoMode_() {
return getSetting_('DEMO_MODE', 'true').toLowerCase() === 'true';
}
function getAdminEmail_() {
return getSetting_('ADMIN_EMAIL', CONFIG.ADMIN_EMAIL);
}
function findCol_(headers, label) {
for (let i = 0; i < headers.length; i++) {
if (String(headers[i]).toLowerCase().includes(label.toLowerCase())) return i;
}
return -1;
}
function escHtml_(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function sanitize_(s, maxLen) {
return String(s || '').replace(/[^\w\s@.\-\/,#$()&+:;'"!?]/g, '').substring(0, maxLen || 200);
}
function nextExpenseId_() {
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet || logSheet.getLastRow() < 2) return 'EXP-0001';
const ids = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 1).getValues().flat().filter(Boolean);
let maxNum = 0;
for (const id of ids) {
const match = String(id).match(/EXP-(\d+)/);
if (match) maxNum = Math.max(maxNum, parseInt(match[1], 10));
}
return 'EXP-' + String(maxNum + 1).padStart(4, '0');
}
function getMonthYear_(date) {
const d = date instanceof Date ? date : new Date(date);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return months[d.getMonth()] + ' ' + d.getFullYear();
}
function getFiscalQuarter_(date) {
const d = date instanceof Date ? date : new Date(date);
const m = d.getMonth();
if (m < 3) return 'Q1';
if (m < 6) return 'Q2';
if (m < 9) return 'Q3';
return 'Q4';
}
function formatCurrency_(amount) {
return '$' + Number(amount || 0).toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FORM SUBMIT HANDLER
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function onExpenseSubmit(e) {
try {
const demo = isDemoMode_();
if (demo) {
Logger.log('DEMO MODE โ processing expense but no real alerts');
}
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet) {
Logger.log('ERROR: Expense Log sheet not found. Run fullSetup() first.');
return;
}
// Get submitted data
let timestamp, dateOfExpense, category, amount, receiptNum, notes;
if (e && e.namedValues) {
timestamp = e.namedValues['Timestamp'] ? e.namedValues['Timestamp'][0] : new Date();
dateOfExpense = e.namedValues['Date of Expense'] ? e.namedValues['Date of Expense'][0] : '';
category = e.namedValues['Expense Category'] ? e.namedValues['Expense Category'][0] : '';
amount = e.namedValues['Amount ($)'] ? e.namedValues['Amount ($)'][0] : '0';
receiptNum = e.namedValues['Receipt Number'] ? e.namedValues['Receipt Number'][0] : '';
notes = e.namedValues['Notes / Description'] ? e.namedValues['Notes / Description'][0] : '';
} else if (e && e.range) {
const row = e.range.getRow();
const sheet = ss.getSheetByName(CONFIG.FORM_RESPONSES_TAB);
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const data = sheet.getRange(row, 1, 1, sheet.getLastColumn()).getValues()[0];
const cTimestamp = findCol_(headers, 'timestamp');
const cDate = findCol_(headers, 'date of expense');
const cCategory = findCol_(headers, 'expense category');
const cAmount = findCol_(headers, 'amount');
const cReceipt = findCol_(headers, 'receipt');
const cNotes = findCol_(headers, 'notes');
timestamp = cTimestamp > -1 ? data[cTimestamp] : new Date();
dateOfExpense = cDate > -1 ? data[cDate] : '';
category = cCategory > -1 ? data[cCategory] : '';
amount = cAmount > -1 ? data[cAmount] : 0;
receiptNum = cReceipt > -1 ? data[cReceipt] : '';
notes = cNotes > -1 ? data[cNotes] : '';
} else {
Logger.log('No event data โ skipping');
return;
}
// Clean and validate
category = sanitize_(category, 100);
amount = parseFloat(String(amount).replace(/[^0-9.\-]/g, '')) || 0;
receiptNum = sanitize_(receiptNum, 50);
notes = sanitize_(notes, 500);
if (amount <= 0) {
Logger.log('WARNING: Amount is $0 or negative for receipt ' + receiptNum);
}
const expenseDate = dateOfExpense ? new Date(dateOfExpense) : new Date();
const expenseId = nextExpenseId_();
const monthYear = getMonthYear_(expenseDate);
const quarter = getFiscalQuarter_(expenseDate);
// Duplicate receipt check
let dupFlag = '';
if (receiptNum) {
const existingReceipts = logSheet.getLastRow() > 1
? logSheet.getRange(2, 6, logSheet.getLastRow() - 1, 1).getValues().flat()
: [];
if (existingReceipts.some(r => String(r).trim().toUpperCase() === receiptNum.trim().toUpperCase())) {
dupFlag = 'โ ๏ธ DUPLICATE';
Logger.log('DUPLICATE RECEIPT DETECTED: ' + receiptNum);
}
}
// Write to Expense Log
logSheet.appendRow([
expenseId,
timestamp,
expenseDate,
category,
amount,
receiptNum,
notes,
new Date(), // Processed Date
monthYear,
quarter,
dupFlag,
'Approved' // Status (auto-approved from form)
]);
Logger.log('Expense logged: ' + expenseId + ' | ' + category + ' | ' + formatCurrency_(amount));
// Check budget threshold
checkBudgetAlert_(category, amount, expenseId, demo);
// Send duplicate alert if needed
if (dupFlag && !demo) {
sendDuplicateAlert_(expenseId, category, amount, receiptNum);
}
} catch (err) {
Logger.log('ERROR in onExpenseSubmit: ' + err.message);
try {
if (!isDemoMode_()) {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: 'โ ๏ธ Expense Tracker Error',
htmlBody: buildEmailHtml_('Expense Tracker Error',
'<p style="color:#ccc;">An error occurred processing an expense:</p>' +
'<p style="color:#ff2d2d;font-family:monospace;">' + escHtml_(err.message) + '</p>')
});
}
} catch (e2) { /* silent */ }
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
BUDGET TRACKING
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function checkBudgetAlert_(category, newAmount, expenseId, demo) {
const ss = getSpreadsheet_();
const budgetSheet = ss.getSheetByName(CONFIG.BUDGET_TAB);
if (!budgetSheet) return;
const data = budgetSheet.getDataRange().getValues();
const headers = data[0];
const cCat = findCol_(headers, 'category');
const cBudget = findCol_(headers, 'monthly budget');
const cSpent = findCol_(headers, 'current month spent');
const cRemaining = findCol_(headers, 'remaining');
if (cCat === -1 || cBudget === -1) return;
for (let i = 1; i < data.length; i++) {
if (String(data[i][cCat]).trim().toLowerCase() === category.trim().toLowerCase()) {
const budget = parseFloat(data[i][cBudget]) || 0;
const currentSpent = parseFloat(data[i][cSpent]) || 0;
const newTotal = currentSpent + newAmount;
const remaining = budget - newTotal;
// Update spent and remaining
if (cSpent > -1) budgetSheet.getRange(i + 1, cSpent + 1).setValue(newTotal);
if (cRemaining > -1) budgetSheet.getRange(i + 1, cRemaining + 1).setValue(remaining);
// Check threshold
const alertPercent = parseInt(getSetting_('BUDGET_ALERT_PERCENT', '80'), 10);
if (budget > 0 && (newTotal / budget * 100) >= alertPercent) {
const pct = Math.round(newTotal / budget * 100);
Logger.log('BUDGET ALERT: ' + category + ' at ' + pct + '% (' + formatCurrency_(newTotal) + ' of ' + formatCurrency_(budget) + ')');
if (!demo) {
const overBudget = newTotal > budget;
const alertColor = overBudget ? '#ff2d2d' : '#ffaa00';
const alertIcon = overBudget ? '๐จ' : 'โ ๏ธ';
const alertTitle = overBudget
? alertIcon + ' OVER BUDGET: ' + category
: alertIcon + ' Budget Alert: ' + category;
MailApp.sendEmail({
to: getAdminEmail_(),
subject: alertTitle,
htmlBody: buildEmailHtml_('Budget Alert',
'<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">' + alertIcon + '</span>' +
'</div>' +
'<table style="width:100%;border-collapse:collapse;">' +
buildRow_('Category', escHtml_(category)) +
buildRow_('This Expense', formatCurrency_(newAmount) + ' (' + escHtml_(expenseId) + ')') +
buildRow_('Month Total', '<span style="color:' + alertColor + ';font-weight:bold;">' + formatCurrency_(newTotal) + '</span>') +
buildRow_('Monthly Budget', formatCurrency_(budget)) +
buildRow_('Usage', '<span style="color:' + alertColor + ';font-weight:bold;">' + pct + '%</span>') +
buildRow_('Remaining', remaining >= 0 ? formatCurrency_(remaining) : '<span style="color:#ff2d2d;">-' + formatCurrency_(Math.abs(remaining)) + '</span>') +
'</table>')
});
}
}
break;
}
}
}
function updateBudgetTracking() {
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
const budgetSheet = ss.getSheetByName(CONFIG.BUDGET_TAB);
if (!logSheet || !budgetSheet) return;
const now = new Date();
const currentMonthYear = getMonthYear_(now);
// Get all expenses for current month
const logData = logSheet.getLastRow() > 1
? logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues()
: [];
const spending = {};
for (const row of logData) {
const monthYear = String(row[8]); // Month-Year column
const category = String(row[3]); // Category column
const amount = parseFloat(row[4]) || 0;
const status = String(row[11]);
if (monthYear === currentMonthYear && status !== 'Voided') {
spending[category] = (spending[category] || 0) + amount;
}
}
// Update budget sheet
const budgetData = budgetSheet.getDataRange().getValues();
const headers = budgetData[0];
const cCat = findCol_(headers, 'category');
const cBudget = findCol_(headers, 'monthly budget');
const cSpent = findCol_(headers, 'current month spent');
const cRemaining = findCol_(headers, 'remaining');
if (cCat === -1) return;
for (let i = 1; i < budgetData.length; i++) {
const cat = String(budgetData[i][cCat]).trim();
const budget = parseFloat(budgetData[i][cBudget]) || 0;
const spent = spending[cat] || 0;
if (cSpent > -1) budgetSheet.getRange(i + 1, cSpent + 1).setValue(spent);
if (cRemaining > -1) budgetSheet.getRange(i + 1, cRemaining + 1).setValue(budget - spent);
}
Logger.log('Budget tracking updated for ' + currentMonthYear + ' โ
');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DUPLICATE ALERT
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendDuplicateAlert_(expenseId, category, amount, receiptNum) {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: 'โ ๏ธ Duplicate Receipt Detected โ ' + receiptNum,
htmlBody: buildEmailHtml_('Duplicate Receipt Alert',
'<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">โ ๏ธ</span>' +
'</div>' +
'<p style="color:#ccc;text-align:center;">A duplicate receipt number was detected.</p>' +
'<table style="width:100%;border-collapse:collapse;">' +
buildRow_('Expense ID', expenseId) +
buildRow_('Category', escHtml_(category)) +
buildRow_('Amount', formatCurrency_(amount)) +
buildRow_('Receipt #', escHtml_(receiptNum)) +
'</table>' +
'<p style="color:#999;text-align:center;margin-top:16px;">Please verify this is not a duplicate entry.</p>')
});
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
MONTHLY EXPENSE REPORT
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendMonthlyExpenseReport() {
const demo = isDemoMode_();
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
const budgetSheet = ss.getSheetByName(CONFIG.BUDGET_TAB);
// Get previous month
const now = new Date();
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const reportMonthYear = getMonthYear_(prevMonth);
let categoryTotals = {};
let totalSpent = 0;
let expenseCount = 0;
if (demo) {
// Demo data
categoryTotals = {
'Fuel / Gas': 645.50,
'Insurance': 1200.00,
'Instructor Pay': 4500.00,
'Vehicle Maintenance': 325.75,
'Office Supplies': 89.99,
'Marketing / Advertising': 150.00,
'Miscellaneous': 42.30
};
totalSpent = Object.values(categoryTotals).reduce((a, b) => a + b, 0);
expenseCount = 23;
} else {
// Real data from Expense Log
if (!logSheet || logSheet.getLastRow() < 2) {
Logger.log('No expense data for ' + reportMonthYear);
return;
}
const logData = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues();
for (const row of logData) {
if (String(row[8]) === reportMonthYear && String(row[11]) !== 'Voided') {
const cat = String(row[3]);
const amt = parseFloat(row[4]) || 0;
categoryTotals[cat] = (categoryTotals[cat] || 0) + amt;
totalSpent += amt;
expenseCount++;
}
}
}
if (expenseCount === 0) {
Logger.log('No expenses for ' + reportMonthYear);
return;
}
// Get budgets for comparison
const budgets = {};
if (budgetSheet && budgetSheet.getLastRow() > 1) {
const bData = budgetSheet.getDataRange().getValues();
const bHeaders = bData[0];
const cCat = findCol_(bHeaders, 'category');
const cBudget = findCol_(bHeaders, 'monthly budget');
if (cCat > -1 && cBudget > -1) {
for (let i = 1; i < bData.length; i++) {
budgets[String(bData[i][cCat]).trim()] = parseFloat(bData[i][cBudget]) || 0;
}
}
}
// Save to Monthly Report sheet
const reportSheet = ss.getSheetByName(CONFIG.MONTHLY_REPORT_TAB);
if (reportSheet) {
const reportRows = Object.entries(categoryTotals)
.sort((a, b) => b[1] - a[1])
.map(([cat, spent]) => {
const budget = budgets[cat] || 0;
const variance = budget - spent;
const pct = budget > 0 ? Math.round(spent / budget * 100) : 0;
return [reportMonthYear, cat, spent, budget, variance, pct + '%'];
});
// Add total row
const totalBudget = Object.values(budgets).reduce((a, b) => a + b, 0);
reportRows.push([reportMonthYear, 'โโโ TOTAL โโโ', totalSpent, totalBudget, totalBudget - totalSpent, totalBudget > 0 ? Math.round(totalSpent / totalBudget * 100) + '%' : 'N/A']);
reportSheet.getRange(reportSheet.getLastRow() + 1, 1, reportRows.length, 6).setValues(reportRows);
}
// Build email
const sorted = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1]);
const totalBudget = Object.values(budgets).reduce((a, b) => a + b, 0);
let categoryRows = '';
for (const [cat, spent] of sorted) {
const budget = budgets[cat] || 0;
const pct = budget > 0 ? Math.round(spent / budget * 100) : 0;
const color = pct > 100 ? '#ff2d2d' : pct >= 80 ? '#ffaa00' : '#4CAF50';
const bar = budget > 0
? '<div style="background:#1a1a1a;border-radius:4px;height:8px;width:100%;margin-top:4px;">' +
'<div style="background:' + color + ';border-radius:4px;height:8px;width:' + Math.min(pct, 100) + '%;"></div></div>'
: '';
categoryRows +=
'<tr>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#ccc;">' + escHtml_(cat) + '</td>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#fff;text-align:right;font-weight:bold;">' + formatCurrency_(spent) + '</td>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#999;text-align:right;">' + (budget > 0 ? formatCurrency_(budget) : 'โ') + '</td>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:' + color + ';text-align:center;width:100px;">' + (budget > 0 ? pct + '%' : 'โ') + bar + '</td>' +
'</tr>';
}
const overBudgetCount = sorted.filter(([cat, spent]) => (budgets[cat] || 0) > 0 && spent > (budgets[cat] || 0)).length;
const avgExpense = totalSpent / expenseCount;
const body =
'<div style="text-align:center;margin-bottom:24px;">' +
'<span style="font-size:48px;">๐</span>' +
'<h2 style="color:#ff2d2d;margin:12px 0 4px;">' + reportMonthYear + '</h2>' +
'<p style="color:#999;margin:0;">Monthly Expense Report</p>' +
'</div>' +
// Summary cards
'<table style="width:100%;border-collapse:collapse;margin-bottom:20px;"><tr>' +
'<td style="width:33%;text-align:center;padding:12px;background:#1a1a1a;border-radius:8px;">' +
'<div style="color:#ff2d2d;font-size:24px;font-weight:bold;">' + formatCurrency_(totalSpent) + '</div>' +
'<div style="color:#999;font-size:12px;">Total Spent</div>' +
'</td>' +
'<td style="width:4px;"></td>' +
'<td style="width:33%;text-align:center;padding:12px;background:#1a1a1a;border-radius:8px;">' +
'<div style="color:#fff;font-size:24px;font-weight:bold;">' + expenseCount + '</div>' +
'<div style="color:#999;font-size:12px;">Expenses</div>' +
'</td>' +
'<td style="width:4px;"></td>' +
'<td style="width:33%;text-align:center;padding:12px;background:#1a1a1a;border-radius:8px;">' +
'<div style="color:' + (overBudgetCount > 0 ? '#ff2d2d' : '#4CAF50') + ';font-size:24px;font-weight:bold;">' + overBudgetCount + '</div>' +
'<div style="color:#999;font-size:12px;">Over Budget</div>' +
'</td>' +
'</tr></table>' +
// Category breakdown
'<table style="width:100%;border-collapse:collapse;background:#0d0d0d;border-radius:8px;overflow:hidden;">' +
'<tr style="background:#1a1a1a;">' +
'<th style="padding:10px 12px;text-align:left;color:#ff2d2d;font-size:12px;text-transform:uppercase;">Category</th>' +
'<th style="padding:10px 12px;text-align:right;color:#ff2d2d;font-size:12px;text-transform:uppercase;">Spent</th>' +
'<th style="padding:10px 12px;text-align:right;color:#ff2d2d;font-size:12px;text-transform:uppercase;">Budget</th>' +
'<th style="padding:10px 12px;text-align:center;color:#ff2d2d;font-size:12px;text-transform:uppercase;">Usage</th>' +
'</tr>' +
categoryRows +
'<tr style="background:#1a1a1a;">' +
'<td style="padding:12px;color:#ff2d2d;font-weight:bold;">TOTAL</td>' +
'<td style="padding:12px;color:#ff2d2d;font-weight:bold;text-align:right;">' + formatCurrency_(totalSpent) + '</td>' +
'<td style="padding:12px;color:#999;text-align:right;">' + formatCurrency_(totalBudget) + '</td>' +
'<td style="padding:12px;color:#ff2d2d;font-weight:bold;text-align:center;">' + (totalBudget > 0 ? Math.round(totalSpent / totalBudget * 100) + '%' : 'โ') + '</td>' +
'</tr>' +
'</table>' +
'<p style="color:#999;text-align:center;margin-top:16px;font-size:12px;">Avg expense: ' + formatCurrency_(avgExpense) + ' | Top category: ' + escHtml_(sorted[0][0]) + '</p>';
const subject = (demo ? '[DEMO] ' : '') + '๐ ' + reportMonthYear + ' Expense Report โ ' + formatCurrency_(totalSpent);
if (!demo) {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: subject,
htmlBody: buildEmailHtml_('Monthly Expense Report', body)
});
}
Logger.log('Monthly expense report sent for ' + reportMonthYear + ': ' + formatCurrency_(totalSpent) + ' across ' + expenseCount + ' expenses');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
WEEKLY DIGEST
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendWeeklyDigest() {
const demo = isDemoMode_();
if (getSetting_('WEEKLY_DIGEST', 'true').toLowerCase() !== 'true') {
Logger.log('Weekly digest disabled in Settings');
return;
}
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
let weekExpenses = [];
let weekTotal = 0;
if (demo) {
weekExpenses = [
{ id: 'EXP-0042', date: 'Feb 12', category: 'Fuel / Gas', amount: 85.50, notes: 'Shell station โ Car #1' },
{ id: 'EXP-0043', date: 'Feb 13', category: 'Office Supplies', amount: 34.99, notes: 'Printer paper + ink' },
{ id: 'EXP-0044', date: 'Feb 14', category: 'Vehicle Maintenance', amount: 125.00, notes: 'Oil change โ Car #2' },
{ id: 'EXP-0045', date: 'Feb 15', category: 'Fuel / Gas', amount: 72.30, notes: 'BP station โ Car #2' },
{ id: 'EXP-0046', date: 'Feb 16', category: 'Marketing / Advertising', amount: 50.00, notes: 'Facebook ad boost' }
];
weekTotal = weekExpenses.reduce((sum, e) => sum + e.amount, 0);
} else {
if (!logSheet || logSheet.getLastRow() < 2) {
Logger.log('No expenses to report');
return;
}
const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const logData = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues();
for (const row of logData) {
const processedDate = row[7] instanceof Date ? row[7] : new Date(row[7]);
if (processedDate >= weekAgo && String(row[11]) !== 'Voided') {
weekExpenses.push({
id: String(row[0]),
date: Utilities.formatDate(row[2] instanceof Date ? row[2] : new Date(row[2]), Session.getScriptTimeZone(), 'MMM d'),
category: String(row[3]),
amount: parseFloat(row[4]) || 0,
notes: String(row[6]).substring(0, 60)
});
weekTotal += parseFloat(row[4]) || 0;
}
}
}
if (weekExpenses.length === 0) {
Logger.log('No expenses this week');
return;
}
let expenseRows = '';
for (const exp of weekExpenses) {
expenseRows +=
'<tr>' +
'<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#999;font-size:12px;">' + escHtml_(exp.id) + '</td>' +
'<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#ccc;">' + escHtml_(exp.date) + '</td>' +
'<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#ccc;">' + escHtml_(exp.category) + '</td>' +
'<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#fff;text-align:right;font-weight:bold;">' + formatCurrency_(exp.amount) + '</td>' +
'<td style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#666;font-size:11px;">' + escHtml_(exp.notes) + '</td>' +
'</tr>';
}
const avgDaily = weekTotal / 7;
const body =
'<div style="text-align:center;margin-bottom:24px;">' +
'<span style="font-size:48px;">๐ฐ</span>' +
'<h2 style="color:#ff2d2d;margin:12px 0 4px;">Weekly Spending Digest</h2>' +
'<p style="color:#999;margin:0;">' + weekExpenses.length + ' expenses | ' + formatCurrency_(weekTotal) + ' total | ~' + formatCurrency_(avgDaily) + '/day</p>' +
'</div>' +
'<table style="width:100%;border-collapse:collapse;background:#0d0d0d;border-radius:8px;overflow:hidden;">' +
'<tr style="background:#1a1a1a;">' +
'<th style="padding:8px 10px;text-align:left;color:#ff2d2d;font-size:11px;text-transform:uppercase;">ID</th>' +
'<th style="padding:8px 10px;text-align:left;color:#ff2d2d;font-size:11px;text-transform:uppercase;">Date</th>' +
'<th style="padding:8px 10px;text-align:left;color:#ff2d2d;font-size:11px;text-transform:uppercase;">Category</th>' +
'<th style="padding:8px 10px;text-align:right;color:#ff2d2d;font-size:11px;text-transform:uppercase;">Amount</th>' +
'<th style="padding:8px 10px;text-align:left;color:#ff2d2d;font-size:11px;text-transform:uppercase;">Notes</th>' +
'</tr>' +
expenseRows +
'<tr style="background:#1a1a1a;">' +
'<td colspan="3" style="padding:10px;color:#ff2d2d;font-weight:bold;">TOTAL</td>' +
'<td style="padding:10px;color:#ff2d2d;font-weight:bold;text-align:right;">' + formatCurrency_(weekTotal) + '</td>' +
'<td></td>' +
'</tr>' +
'</table>';
const subject = (demo ? '[DEMO] ' : '') + '๐ฐ Weekly Expenses: ' + formatCurrency_(weekTotal) + ' (' + weekExpenses.length + ' items)';
if (!demo) {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: subject,
htmlBody: buildEmailHtml_('Weekly Spending Digest', body)
});
}
Logger.log('Weekly digest: ' + formatCurrency_(weekTotal) + ' across ' + weekExpenses.length + ' expenses');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
API โ For BI Dashboard
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getExpenseSummary(monthYear) {
const demo = isDemoMode_();
if (demo) {
return {
demo: true,
monthYear: monthYear || 'Feb 2026',
totalSpent: 6953.54,
expenseCount: 23,
categories: {
'Instructor Pay': 4500.00,
'Insurance': 1200.00,
'Fuel / Gas': 645.50,
'Vehicle Maintenance': 325.75,
'Marketing / Advertising': 150.00,
'Office Supplies': 89.99,
'Miscellaneous': 42.30
},
topCategory: 'Instructor Pay',
avgExpense: 302.33,
overBudget: ['Insurance']
};
}
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet || logSheet.getLastRow() < 2) return { totalSpent: 0, expenseCount: 0, categories: {} };
const target = monthYear || getMonthYear_(new Date());
const logData = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues();
const categories = {};
let totalSpent = 0;
let count = 0;
for (const row of logData) {
if (String(row[8]) === target && String(row[11]) !== 'Voided') {
const cat = String(row[3]);
const amt = parseFloat(row[4]) || 0;
categories[cat] = (categories[cat] || 0) + amt;
totalSpent += amt;
count++;
}
}
// Check which are over budget
const budgetSheet = ss.getSheetByName(CONFIG.BUDGET_TAB);
const overBudget = [];
if (budgetSheet && budgetSheet.getLastRow() > 1) {
const bData = budgetSheet.getDataRange().getValues();
const cCat = findCol_(bData[0], 'category');
const cBudget = findCol_(bData[0], 'monthly budget');
if (cCat > -1 && cBudget > -1) {
for (let i = 1; i < bData.length; i++) {
const cat = String(bData[i][cCat]).trim();
const budget = parseFloat(bData[i][cBudget]) || 0;
if (budget > 0 && (categories[cat] || 0) > budget) overBudget.push(cat);
}
}
}
const sorted = Object.entries(categories).sort((a, b) => b[1] - a[1]);
return {
demo: false,
monthYear: target,
totalSpent: totalSpent,
expenseCount: count,
categories: categories,
topCategory: sorted.length > 0 ? sorted[0][0] : 'N/A',
avgExpense: count > 0 ? Math.round(totalSpent / count * 100) / 100 : 0,
overBudget: overBudget
};
}
/** YTD totals by category */
function getYTDSummary() {
const demo = isDemoMode_();
const now = new Date();
const year = now.getFullYear();
if (demo) {
return {
demo: true,
year: year,
totalSpent: 13907.08,
categories: {
'Instructor Pay': 9000.00,
'Insurance': 2400.00,
'Fuel / Gas': 1291.00,
'Vehicle Maintenance': 651.50,
'Marketing / Advertising': 300.00,
'Office Supplies': 179.98,
'Miscellaneous': 84.60
}
};
}
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet || logSheet.getLastRow() < 2) return { year: year, totalSpent: 0, categories: {} };
const logData = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues();
const categories = {};
let totalSpent = 0;
for (const row of logData) {
const expDate = row[2] instanceof Date ? row[2] : new Date(row[2]);
if (expDate.getFullYear() === year && String(row[11]) !== 'Voided') {
const cat = String(row[3]);
const amt = parseFloat(row[4]) || 0;
categories[cat] = (categories[cat] || 0) + amt;
totalSpent += amt;
}
}
return { demo: false, year: year, totalSpent: totalSpent, categories: categories };
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
VOID / MANAGE EXPENSES
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
/** Void an expense by ID (marks as Voided, doesn't delete) */
function voidExpense(expenseId, reason) {
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet || logSheet.getLastRow() < 2) return { success: false, error: 'No expenses found' };
const data = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 12).getValues();
for (let i = 0; i < data.length; i++) {
if (String(data[i][0]).trim() === expenseId.trim()) {
const statusCol = 12; // Column L (Status)
logSheet.getRange(i + 2, statusCol).setValue('Voided');
// Append reason to notes
if (reason) {
const notesCol = 7;
const currentNotes = String(data[i][6]);
logSheet.getRange(i + 2, notesCol).setValue(currentNotes + ' [VOIDED: ' + sanitize_(reason, 200) + ']');
}
Logger.log('Voided expense: ' + expenseId);
return { success: true, expenseId: expenseId };
}
}
return { success: false, error: 'Expense ID not found: ' + expenseId };
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DEMO DATA GENERATOR
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function generateDemoExpenses() {
const ss = getSpreadsheet_();
const logSheet = ss.getSheetByName(CONFIG.EXPENSE_LOG_TAB);
if (!logSheet) {
Logger.log('Run fullSetup() first');
return;
}
const demoExpenses = [
['2026-02-03', 'Fuel / Gas', 85.50, 'REC-1001', 'Shell station โ Car #1'],
['2026-02-05', 'Instructor Pay', 750.00, 'REC-1002', 'Anisha โ Week 1 Feb'],
['2026-02-05', 'Instructor Pay', 750.00, 'REC-1003', 'Carlos โ Week 1 Feb'],
['2026-02-05', 'Instructor Pay', 750.00, 'REC-1004', 'Nick โ Week 1 Feb'],
['2026-02-07', 'Vehicle Maintenance', 125.00, 'REC-1005', 'Oil change โ Car #2'],
['2026-02-08', 'Office Supplies', 34.99, 'REC-1006', 'Printer paper + ink cartridge'],
['2026-02-10', 'Fuel / Gas', 72.30, 'REC-1007', 'BP station โ Car #2'],
['2026-02-10', 'Marketing / Advertising', 50.00, 'REC-1008', 'Facebook ad boost โ Feb special'],
['2026-02-12', 'Insurance', 600.00, 'REC-1009', 'Monthly vehicle insurance โ 2 cars'],
['2026-02-12', 'Instructor Pay', 750.00, 'REC-1010', 'Anisha โ Week 2 Feb'],
['2026-02-12', 'Instructor Pay', 750.00, 'REC-1011', 'Carlos โ Week 2 Feb'],
['2026-02-12', 'Instructor Pay', 750.00, 'REC-1012', 'Nick โ Week 2 Feb'],
['2026-02-14', 'Fuel / Gas', 90.20, 'REC-1013', 'Mobil station โ Car #1'],
['2026-02-15', 'Rent / Utilities', 1500.00, 'REC-1014', 'Monthly office rent'],
['2026-02-16', 'Technology / Software', 29.99, 'REC-1015', 'Scheduling software subscription'],
['2026-02-17', 'Training Materials', 45.00, 'REC-1016', 'Student handbooks (25 copies)'],
['2026-02-18', 'Fuel / Gas', 78.40, 'REC-1017', 'Shell station โ Car #2'],
['2026-02-18', 'Miscellaneous', 22.50, 'REC-1018', 'Parking โ DMV trips'],
['2026-02-19', 'Vehicle Maintenance', 200.00, 'REC-1019', 'Brake pads โ Car #1'],
['2026-02-19', 'Marketing / Advertising', 100.00, 'REC-1020', 'Instagram promoted post']
];
const rows = demoExpenses.map((exp, i) => {
const expDate = new Date(exp[0]);
const id = 'EXP-' + String(i + 1).padStart(4, '0');
return [
id,
expDate, // Timestamp
expDate, // Date of Expense
exp[1], // Category
exp[2], // Amount
exp[3], // Receipt Number
exp[4], // Notes
new Date(), // Processed Date
getMonthYear_(expDate), // Month-Year
getFiscalQuarter_(expDate), // Quarter
'', // Duplicate Flag
'Approved' // Status
];
});
logSheet.getRange(2, 1, rows.length, 12).setValues(rows);
Logger.log('Generated ' + rows.length + ' demo expenses โ
');
Logger.log('Total: ' + formatCurrency_(demoExpenses.reduce((s, e) => s + e[2], 0)));
// Update budget tracking
updateBudgetTracking();
Logger.log('Budget tracking updated with demo data โ
');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
EMAIL TEMPLATE โ Mission Control Theme
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function buildEmailHtml_(title, bodyContent) {
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;"><tr><td align="center" style="padding:20px;">' +
'<table width="600" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border:1px solid rgba(255,45,45,0.2);border-radius:12px;overflow:hidden;">' +
// Header
'<tr><td style="background:linear-gradient(135deg,#1a0000,#0d0d0d);padding:24px 32px;border-bottom:1px solid rgba(255,45,45,0.15);">' +
'<table width="100%"><tr>' +
'<td style="color:#ff2d2d;font-size:20px;font-weight:bold;">๐ซ ' + escHtml_(CONFIG.SCHOOL_NAME) + '</td>' +
'</tr></table>' +
'</td></tr>' +
// Title bar
'<tr><td style="padding:20px 32px 0;">' +
'<h1 style="color:#fff;font-size:22px;margin:0 0 4px;font-weight:600;">' + title + '</h1>' +
'<div style="width:40px;height:3px;background:#ff2d2d;border-radius:2px;"></div>' +
'</td></tr>' +
// Body
'<tr><td style="padding:20px 32px 32px;">' + bodyContent + '</td></tr>' +
// Footer
'<tr><td style="padding:20px 32px;border-top:1px solid rgba(255,255,255,0.05);text-align:center;">' +
'<p style="color:#444;font-size:11px;margin:0;">' + CONFIG.SCHOOL_NAME + ' โ Expense Tracker</p>' +
'<p style="color:#333;font-size:10px;margin:4px 0 0;">Automated by Mission Control</p>' +
'</td></tr>' +
'</table>' +
'</td></tr></table></body></html>';
}
function buildRow_(label, value) {
return '<tr>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#999;width:40%;">' + label + '</td>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#fff;">' + value + '</td>' +
'</tr>';
}
/**
* Google Review Demo Landing Page
*
* Simulates what happens when a student clicks "Leave a Review" in the email.
* Shows a branded demo page instead of the real Google review form.
*
* Deploy as: Web App (Anyone can access)
*/
function doGet(e) {
const studentName = e && e.parameter && e.parameter.name ? e.parameter.name : 'Student';
const schoolName = e && e.parameter && e.parameter.school ? e.parameter.school : 'Flavors Driving School';
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Leave a Review โ ${schoolName}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #000000;
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.container {
max-width: 480px;
width: 100%;
background: #0d0d0d;
border-radius: 24px;
border: 1px solid rgba(255,255,255,0.06);
overflow: hidden;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.header {
padding: 40px 32px 24px;
text-align: center;
background: linear-gradient(180deg, rgba(255,45,45,0.08) 0%, transparent 100%);
}
.stars { font-size: 42px; letter-spacing: 6px; margin-bottom: 16px; }
.header h1 {
color: #ffffff;
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
}
.header p {
color: #888;
font-size: 14px;
}
.school-name { color: #ff2d2d; font-weight: 600; }
.review-box {
padding: 24px 32px;
}
.star-rating {
display: flex;
justify-content: center;
gap: 8px;
margin-bottom: 20px;
}
.star-btn {
background: none;
border: none;
font-size: 36px;
cursor: pointer;
filter: grayscale(1) opacity(0.4);
transition: all 0.2s ease;
}
.star-btn.active,
.star-btn:hover {
filter: none;
transform: scale(1.15);
}
.review-textarea {
width: 100%;
min-height: 120px;
background: #1a1a1a;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
padding: 16px;
color: #fff;
font-size: 14px;
font-family: inherit;
resize: vertical;
outline: none;
transition: border-color 0.2s;
}
.review-textarea:focus {
border-color: rgba(255,45,45,0.4);
}
.review-textarea::placeholder {
color: #555;
}
.submit-btn {
display: block;
width: 100%;
margin-top: 16px;
padding: 14px;
background: linear-gradient(135deg, #ff2d2d, #cc0000);
color: #fff;
font-size: 16px;
font-weight: 700;
border: none;
border-radius: 14px;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 16px rgba(255,45,45,0.3);
}
.submit-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 24px rgba(255,45,45,0.4);
}
/* Demo banner */
.demo-banner {
background: linear-gradient(135deg, #1a0a0a, #0d0d0d);
border-top: 1px solid rgba(255,45,45,0.15);
padding: 16px 32px;
text-align: center;
}
.demo-badge {
display: inline-block;
background: rgba(255,45,45,0.12);
color: #ff6b6b;
font-size: 11px;
font-weight: 700;
letter-spacing: 1.5px;
text-transform: uppercase;
padding: 5px 14px;
border-radius: 20px;
border: 1px solid rgba(255,45,45,0.2);
}
.demo-text {
color: #555;
font-size: 12px;
margin-top: 8px;
line-height: 1.5;
}
/* Success state */
.success-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.85);
z-index: 100;
align-items: center;
justify-content: center;
}
.success-card {
background: #0d0d0d;
border-radius: 24px;
border: 1px solid rgba(255,255,255,0.06);
padding: 48px 40px;
text-align: center;
max-width: 400px;
width: 90%;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
}
.success-icon { font-size: 56px; margin-bottom: 16px; }
.success-card h2 {
color: #fff;
font-size: 20px;
margin-bottom: 8px;
}
.success-card p {
color: #888;
font-size: 14px;
line-height: 1.6;
}
.success-note {
margin-top: 20px;
color: #ff6b6b;
font-size: 12px;
font-weight: 600;
}
/* Powered by footer */
.powered-by {
padding: 12px 32px 16px;
text-align: center;
}
.powered-by span {
color: #333;
font-size: 11px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="stars">โญโญโญโญโญ</div>
<h1>How was your experience?</h1>
<p>Tell us about your time at <span class="school-name">${schoolName}</span></p>
</div>
<div class="review-box">
<div class="star-rating" id="starRating">
<button class="star-btn" data-rating="1" onclick="setRating(1)">โญ</button>
<button class="star-btn" data-rating="2" onclick="setRating(2)">โญ</button>
<button class="star-btn" data-rating="3" onclick="setRating(3)">โญ</button>
<button class="star-btn" data-rating="4" onclick="setRating(4)">โญ</button>
<button class="star-btn" data-rating="5" onclick="setRating(5)">โญ</button>
</div>
<textarea class="review-textarea" placeholder="Share your experience... What did you enjoy most about your driving lessons?"></textarea>
<button class="submit-btn" onclick="submitReview()">Submit Review</button>
</div>
<div class="demo-banner">
<div class="demo-badge">โฆ Demo Mode</div>
<p class="demo-text">This is a demo review page. In production, this button links directly to your Google Business review form.</p>
</div>
<div class="powered-by">
<span>Powered by Automated Review System</span>
</div>
</div>
<!-- Success overlay -->
<div class="success-overlay" id="successOverlay">
<div class="success-card">
<div class="success-icon">๐</div>
<h2>Thank you for your review!</h2>
<p>Your feedback helps us improve and helps future students find great driving instruction.</p>
<p class="success-note">โก DEMO โ In production, this submits to Google Reviews</p>
</div>
</div>
<script>
let selectedRating = 0;
function setRating(rating) {
selectedRating = rating;
const buttons = document.querySelectorAll('.star-btn');
buttons.forEach((btn, i) => {
btn.classList.toggle('active', i < rating);
});
}
function submitReview() {
const overlay = document.getElementById('successOverlay');
overlay.style.display = 'flex';
setTimeout(() => {
overlay.style.display = 'none';
}, 4000);
}
// Hover effect for stars
const stars = document.querySelectorAll('.star-btn');
stars.forEach((star, i) => {
star.addEventListener('mouseenter', () => {
stars.forEach((s, j) => {
s.classList.toggle('active', j <= i);
});
});
star.addEventListener('mouseleave', () => {
stars.forEach((s, j) => {
s.classList.toggle('active', j < selectedRating);
});
});
});
</script>
</body>
</html>`;
return HtmlService.createHtmlOutput(html)
.setTitle('Leave a Review โ ' + schoolName)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function forceAuth() {
Logger.log('Auth complete.');
}
/**
* =========================================================
* GOOGLE REVIEW REQUEST
* Flavors Driving School
* =========================================================
* Automatically asks students for a Google review after
* completing their lesson package.
*
* - Detects completion from Schedule Board attendance
* - 24h delay after last lesson (configurable)
* - Review request email + 7-day reminder + 14-day 2nd nudge
* - Review tracking with conversion stats
* - Thank-you email when review is confirmed
* - Incentive text (ties into Referral Rewards)
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
REVIEW_SHEET_ID: '1GcitpI5ULneXiKSLwBB_-VnHOJiJ22qXxwn8ZAgJklc',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
// โโ Sheet tabs โโ
REGISTRATION_SHEET_TAB: '',
BOOKINGS_SHEET_TAB: 'Bookings',
// โโ Google Review URL โโ
// Real URL (swap DEMO_REVIEW_URL to this when going live):
// https://search.google.com/local/writereview?placeid=ChIJ-0FMs_VdwokReC3dHtvqXnQ
GOOGLE_REVIEW_URL: 'https://search.google.com/local/writereview?placeid=DEMO_PLACEHOLDER',
GOOGLE_PLACE_ID: 'ChIJ-0FMs_VdwokReC3dHtvqXnQ',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
SCHOOL_PHONE: '(718) 555-0100',
TIMEZONE: 'America/New_York',
// โโ Timing โโ
DELAY_HOURS: 24,
REMINDER_1_DAYS: 7,
REMINDER_2_DAYS: 14,
// โโ Limits โโ
MAX_EMAILS_PER_RUN: 20,
// โโ Packages โโ
PACKAGES: {
'3 Lessons': 3,
'5 Lessons': 5,
'10 Lessons': 10,
'15 Lessons': 15,
'25 Lessons': 25,
'5-Hour Class': 1,
'Beginner': 10,
'Standard': 10,
'Premium': 15,
'Intensive': 20
},
LESSON_PACKAGES_ONLY: false,
// โโ Confirmed attendance statuses โโ
CONFIRMED_STATUSES: ['completed', 'on time', 'late', 'present', 'attended'],
SKIP_STATUSES: ['cancelled', 'canceled', 'no-show', 'no show', 'pending', 'scheduled', 'upcoming', 'rescheduled'],
// โโ Incentive text โโ
INCENTIVE_TEXT: 'As a thank you, mention your review when you refer a friend and you\'ll both get $25 off!',
// โโ Demo mode โโ
DEMO_MODE: true
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO = {
completedStudents: [
{ name: 'Sarah Johnson', email: '[email protected]', pkg: 'Premium Package', lessons: 15 },
{ name: 'Marcus Williams', email: '[email protected]', pkg: '10 Lessons', lessons: 10 }
],
stats: {
totalRequested: 24,
totalReviewed: 14,
conversionRate: '58.3%',
avgDaysToReview: 3.2,
pendingReminders: 5
}
};
/* ================================================================
SETUP & AUTH
================================================================ */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID).getSheetByName('test_auth_ignore');
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID);
// โโ Review Tracker sheet โโ
let tracker = ss.getSheetByName('Review Tracker');
if (!tracker) {
tracker = ss.insertSheet('Review Tracker');
tracker.appendRow([
'Student Name', 'Email', 'Phone', 'Package', 'Lessons Completed',
'Lessons Purchased', 'Completion Date', 'Request Sent', 'Sent Date',
'Reminder 1', 'Reminder 2', 'Status', 'Review Date', 'Unsubscribe'
]);
styleHeader_(tracker, 14);
Logger.log('โ
Created "Review Tracker" sheet.');
}
// โโ Settings sheet โโ
let settings = ss.getSheetByName('Settings');
if (!settings) {
settings = ss.insertSheet('Settings');
const rows = [
['Setting', 'Value'],
['Google Review URL', CONFIG.GOOGLE_REVIEW_URL],
['School Name', CONFIG.SCHOOL_NAME],
['Delay Hours After Last Lesson', CONFIG.DELAY_HOURS],
['Send to 5-Hour Class Students', 'Yes'],
['Reminder 1 After Days', CONFIG.REMINDER_1_DAYS],
['Reminder 2 After Days', CONFIG.REMINDER_2_DAYS],
['Incentive Text', CONFIG.INCENTIVE_TEXT],
['Admin Email', CONFIG.ADMIN_EMAIL]
];
settings.getRange(1, 1, rows.length, 2).setValues(rows);
styleHeader_(settings, 2);
settings.setColumnWidth(1, 280);
settings.setColumnWidth(2, 400);
settings.getRange(2, 1, rows.length - 1, 1).setFontColor('#888888');
settings.getRange(2, 2, rows.length - 1, 1).setFontColor('#ffffff').setFontWeight('bold');
settings.getRange(1, 1, rows.length, 2).setBackground('#0a0a0a');
settings.getRange(2, 2).setFontColor('#ff2d2d').setFontSize(11);
Logger.log('โ
Created "Settings" sheet.');
}
// โโ Review Stats sheet โโ
let statsSheet = ss.getSheetByName('Review Stats');
if (!statsSheet) {
statsSheet = ss.insertSheet('Review Stats');
statsSheet.appendRow(['Metric', 'Value']);
styleHeader_(statsSheet, 2);
Logger.log('โ
Created "Review Stats" sheet.');
}
// โโ Triggers โโ
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'checkForCompletedStudents') ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('checkForCompletedStudents')
.timeBased().everyDays(1).atHour(10).nearMinute(0).create();
Logger.log('โ
Daily trigger set: 10 AM.');
Logger.log('โ
Google Review Request setup complete.');
Logger.log('โ ๏ธ IMPORTANT: Update the Google Review URL in the Settings sheet!');
}
function styleHeader_(sheet, numCols) {
sheet.getRange(1, 1, 1, numCols).setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontWeight('bold').setFontSize(10);
sheet.setFrozenRows(1);
}
/* ================================================================
DAILY CHECK
================================================================ */
function checkForCompletedStudents() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would check for completed students.');
Logger.log('Would find: ' + DEMO.completedStudents.map(s => s.name).join(', '));
return;
}
runDailyCheck_();
} catch (e) {
Logger.log('checkForCompletedStudents error: ' + (e.message || e));
notifyAdmin_('Review Request โ Daily Check Failed', String(e.message || e).substring(0, 500));
throw e;
}
}
function runDailyCheck_() {
const ss = SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID);
const settings = getSettings_(ss);
let tracker = ss.getSheetByName('Review Tracker');
if (!tracker) { fullSetup(); tracker = ss.getSheetByName('Review Tracker'); }
// โโ Already tracked (by email + name) โโ
const trackerData = tracker.getDataRange().getValues();
const trackerHeaders = trackerData[0].map(h => String(h).toLowerCase().trim());
const tEmailCol = findCol_(trackerHeaders, ['email']);
const tNameCol = findCol_(trackerHeaders, ['student name', 'name']);
const tracked = new Set();
for (let i = 1; i < trackerData.length; i++) {
tracked.add(String(trackerData[i][tNameCol] || '').toLowerCase().trim());
if (tEmailCol >= 0 && trackerData[i][tEmailCol]) {
tracked.add(String(trackerData[i][tEmailCol]).toLowerCase().trim());
}
}
// โโ Get registered students โโ
const students = getRegisteredStudents_();
// โโ Get attendance โโ
const attendance = getAttendance_();
const skipFiveHour = CONFIG.LESSON_PACKAGES_ONLY || settings.includeFiveHour === false;
let newCompletions = 0;
for (const student of students) {
// Already tracked?
if (tracked.has(student.email.toLowerCase())) continue;
if (tracked.has(student.name.toLowerCase())) continue;
// Skip 5-Hour if configured
if (skipFiveHour && student.pkg === '5-Hour Class') continue;
if (student.lessonCount <= 0) continue;
// โโ Email-first attendance lookup, then fuzzy name โโ
let completed = 0;
let lastDate = null;
if (student.email && attendance.byEmail[student.email.toLowerCase()]) {
const data = attendance.byEmail[student.email.toLowerCase()];
completed = data.count;
lastDate = data.lastDate;
}
if (completed === 0) {
const nameKey = student.name.toLowerCase().trim();
// Exact name
if (attendance.byName[nameKey]) {
completed = attendance.byName[nameKey].count;
lastDate = attendance.byName[nameKey].lastDate;
} else {
// Fuzzy match
const match = fuzzyFind_(nameKey, Object.keys(attendance.byName));
if (match) {
completed = attendance.byName[match].count;
lastDate = attendance.byName[match].lastDate;
}
}
}
if (completed >= student.lessonCount) {
tracker.appendRow([
student.name, student.email, student.phone, student.pkg,
completed, student.lessonCount,
lastDate || new Date(),
'No', '', 'No', 'No', 'Pending', '', ''
]);
tracked.add(student.email.toLowerCase());
tracked.add(student.name.toLowerCase());
newCompletions++;
}
}
// โโ Send requests + reminders โโ
const requestsSent = sendPendingRequests_(ss, settings);
const remindersSent = sendReminders_(ss, settings);
// โโ Update stats โโ
updateStats_(ss);
Logger.log('Daily check: ' + newCompletions + ' new, ' + requestsSent + ' requests, ' + remindersSent + ' reminders.');
}
/* ================================================================
SEND REVIEW REQUESTS
================================================================ */
function sendPendingRequests_(ss, settings) {
const tracker = ss.getSheetByName('Review Tracker');
if (!tracker || tracker.getLastRow() < 2) return 0;
const data = tracker.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['student name', 'name']);
const emailCol = findCol_(h, ['email']);
const compCol = findCol_(h, ['completion date']);
const sentCol = findCol_(h, ['request sent']);
const sentDtCol = findCol_(h, ['sent date']);
const statusCol = findCol_(h, ['status']);
const unsubCol = findCol_(h, ['unsubscribe']);
const reviewUrl = safeUrl_(settings.reviewUrl) || CONFIG.GOOGLE_REVIEW_URL;
let sent = 0;
for (let i = 1; i < data.length && sent < CONFIG.MAX_EMAILS_PER_RUN; i++) {
const row = data[i];
if (String(row[sentCol] || '').toLowerCase() === 'yes') continue;
if (unsubCol >= 0 && String(row[unsubCol] || '').toLowerCase() === 'yes') continue;
const name = String(row[nameCol] || '').trim();
const email = String(row[emailCol] || '').trim();
if (!email.includes('@')) continue;
// Check delay
let compDate = row[compCol];
if (compDate && !(compDate instanceof Date)) compDate = new Date(compDate);
if (compDate instanceof Date && !isNaN(compDate.getTime())) {
const hoursSince = (Date.now() - compDate.getTime()) / 3600000;
if (hoursSince < (settings.delayHours || CONFIG.DELAY_HOURS)) continue;
}
const html = buildReviewEmail_(name, reviewUrl, settings.schoolName, settings.incentiveText);
try {
MailApp.sendEmail({
to: email,
subject: 'How was your experience at ' + settings.schoolName + '? โญ',
body: 'Hi ' + name.split(' ')[0] + '! We\'d love to hear about your experience. Leave a review: ' + reviewUrl,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
const r = i + 1;
tracker.getRange(r, sentCol + 1).setValue('Yes').setBackground('#0a2e0a').setFontColor('#22c55e');
if (sentDtCol >= 0) tracker.getRange(r, sentDtCol + 1).setValue(new Date());
if (statusCol >= 0) tracker.getRange(r, statusCol + 1).setValue('Requested');
sent++;
} catch (e) {
Logger.log('Review email failed for ' + email + ': ' + e.message);
}
}
// Admin summary
if (sent > 0) {
notifyAdmin_('๐ฌ ' + sent + ' Review Request(s) Sent', sent + ' students were sent a Google review request today.');
}
return sent;
}
/* ================================================================
REMINDERS (1st at 7 days, 2nd at 14 days)
================================================================ */
function sendReminders_(ss, settings) {
const tracker = ss.getSheetByName('Review Tracker');
if (!tracker || tracker.getLastRow() < 2) return 0;
const data = tracker.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['student name', 'name']);
const emailCol = findCol_(h, ['email']);
const sentCol = findCol_(h, ['request sent']);
const sentDtCol = findCol_(h, ['sent date']);
const rem1Col = findCol_(h, ['reminder 1']);
const rem2Col = findCol_(h, ['reminder 2']);
const statusCol = findCol_(h, ['status']);
const unsubCol = findCol_(h, ['unsubscribe']);
const reviewUrl = safeUrl_(settings.reviewUrl) || CONFIG.GOOGLE_REVIEW_URL;
const rem1Days = settings.reminder1Days || CONFIG.REMINDER_1_DAYS;
const rem2Days = settings.reminder2Days || CONFIG.REMINDER_2_DAYS;
let sent = 0;
for (let i = 1; i < data.length && sent < CONFIG.MAX_EMAILS_PER_RUN; i++) {
const row = data[i];
if (String(row[sentCol] || '').toLowerCase() !== 'yes') continue;
if (unsubCol >= 0 && String(row[unsubCol] || '').toLowerCase() === 'yes') continue;
const status = statusCol >= 0 ? String(row[statusCol] || '').toLowerCase() : '';
if (status === 'reviewed' || status === 'thanked') continue;
const name = String(row[nameCol] || '').trim();
const email = String(row[emailCol] || '').trim();
if (!email.includes('@')) continue;
let sentDate = row[sentDtCol];
if (sentDate && !(sentDate instanceof Date)) sentDate = new Date(sentDate);
if (!(sentDate instanceof Date) || isNaN(sentDate.getTime())) continue;
const daysSince = (Date.now() - sentDate.getTime()) / 86400000;
const rem1Sent = rem1Col >= 0 ? String(row[rem1Col] || '').toLowerCase() === 'yes' : false;
const rem2Sent = rem2Col >= 0 ? String(row[rem2Col] || '').toLowerCase() === 'yes' : false;
let sendReminder = false;
let reminderNum = 0;
if (!rem1Sent && daysSince >= rem1Days) {
sendReminder = true;
reminderNum = 1;
} else if (rem1Sent && !rem2Sent && daysSince >= rem2Days) {
sendReminder = true;
reminderNum = 2;
}
if (!sendReminder) continue;
const html = buildReminderEmail_(name, reviewUrl, settings.schoolName, reminderNum);
try {
MailApp.sendEmail({
to: email,
subject: (reminderNum === 2 ? 'Last chance โ ' : 'Quick reminder โ ') + 'we\'d love your feedback! โญ',
body: 'Hi ' + name.split(' ')[0] + '! Quick reminder to leave us a review: ' + reviewUrl,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
const r = i + 1;
if (reminderNum === 1 && rem1Col >= 0) tracker.getRange(r, rem1Col + 1).setValue('Yes');
if (reminderNum === 2 && rem2Col >= 0) tracker.getRange(r, rem2Col + 1).setValue('Yes');
if (statusCol >= 0) tracker.getRange(r, statusCol + 1).setValue('Reminder ' + reminderNum);
sent++;
} catch (e) {
Logger.log('Reminder failed for ' + email + ': ' + e.message);
}
}
return sent;
}
/* ================================================================
THANK YOU EMAIL (call when status manually set to "Reviewed")
================================================================ */
function sendThankYouEmails() {
if (CONFIG.DEMO_MODE) { Logger.log('๐ญ DEMO MODE'); return; }
const ss = SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID);
const tracker = ss.getSheetByName('Review Tracker');
if (!tracker || tracker.getLastRow() < 2) return;
const data = tracker.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['student name', 'name']);
const emailCol = findCol_(h, ['email']);
const statusCol = findCol_(h, ['status']);
const revDtCol = findCol_(h, ['review date']);
const settings = getSettings_(ss);
for (let i = 1; i < data.length; i++) {
const status = statusCol >= 0 ? String(data[i][statusCol] || '').toLowerCase() : '';
if (status !== 'reviewed') continue;
const name = String(data[i][nameCol] || '').trim();
const email = String(data[i][emailCol] || '').trim();
if (!email.includes('@')) continue;
const firstName = esc_(name.split(' ')[0]);
const sch = esc_(settings.schoolName);
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Remove%20' + encodeURIComponent(email);
const html = emailWrap_(
'<div style="text-align:center;">'
+ '<div style="font-size:48px;margin-bottom:12px;">๐โค๏ธ</div>'
+ '<div style="font-size:22px;font-weight:800;color:#fff;">Thank You, ' + firstName + '!</div></div>',
'<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;text-align:center;">'
+ 'Your review means the world to us! It helps other students find quality driving instruction and motivates our team to keep doing great work.</p>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.5);text-align:center;margin-top:16px;">'
+ esc_(CONFIG.INCENTIVE_TEXT) + '</p>',
unsub
);
try {
MailApp.sendEmail({
to: email,
subject: 'Thank you for your review! โค๏ธ โ ' + settings.schoolName,
body: 'Thank you for leaving a review, ' + name.split(' ')[0] + '! We really appreciate it.',
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
const r = i + 1;
if (statusCol >= 0) tracker.getRange(r, statusCol + 1).setValue('Thanked');
if (revDtCol >= 0 && !data[i][revDtCol]) tracker.getRange(r, revDtCol + 1).setValue(new Date());
} catch (e) {
Logger.log('Thank you email failed for ' + email + ': ' + e.message);
}
}
}
/* ================================================================
REVIEW STATS
================================================================ */
function updateStats_(ss) {
let statsSheet = ss.getSheetByName('Review Stats');
if (!statsSheet) return;
const tracker = ss.getSheetByName('Review Tracker');
if (!tracker || tracker.getLastRow() < 2) return;
const data = tracker.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const statusCol = findCol_(h, ['status']);
const sentDtCol = findCol_(h, ['sent date']);
const revDtCol = findCol_(h, ['review date']);
let requested = 0, reviewed = 0, pending = 0, totalDays = 0;
for (let i = 1; i < data.length; i++) {
const status = statusCol >= 0 ? String(data[i][statusCol] || '').toLowerCase() : '';
if (status === 'requested' || status.includes('reminder') || status === 'reviewed' || status === 'thanked') requested++;
if (status === 'reviewed' || status === 'thanked') {
reviewed++;
if (sentDtCol >= 0 && revDtCol >= 0 && data[i][sentDtCol] instanceof Date && data[i][revDtCol] instanceof Date) {
totalDays += (data[i][revDtCol] - data[i][sentDtCol]) / 86400000;
}
}
if (status === 'pending') pending++;
}
const rate = requested > 0 ? (reviewed / requested * 100).toFixed(1) + '%' : '0%';
const avgDays = reviewed > 0 ? (totalDays / reviewed).toFixed(1) : 'N/A';
const rows = [
['Metric', 'Value'],
['๐ฌ Total Requested', requested],
['โญ Total Reviewed', reviewed],
['๐ Conversion Rate', rate],
['โฑ๏ธ Avg Days to Review', avgDays],
['โณ Pending (not yet sent)', pending],
['', ''],
['Last Updated', Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'M/d/yyyy h:mm a')]
];
statsSheet.clear();
statsSheet.getRange(1, 1, rows.length, 2).setValues(rows);
styleHeader_(statsSheet, 2);
statsSheet.getRange(1, 1, rows.length, 2).setBackground('#0a0a0a').setFontColor('#cccccc');
statsSheet.getRange(2, 2, 5, 1).setFontColor('#fff').setFontWeight('bold').setFontSize(13);
statsSheet.getRange(4, 2).setFontColor('#22c55e');
statsSheet.setColumnWidth(1, 250);
statsSheet.setColumnWidth(2, 200);
}
/** Get review stats for BI Dashboard integration */
function getReviewStats() {
if (CONFIG.DEMO_MODE) return DEMO.stats;
const ss = SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID);
const tracker = ss.getSheetByName('Review Tracker');
if (!tracker || tracker.getLastRow() < 2) return { totalRequested: 0, totalReviewed: 0, conversionRate: '0%' };
const data = tracker.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const statusCol = findCol_(h, ['status']);
let requested = 0, reviewed = 0;
for (let i = 1; i < data.length; i++) {
const status = statusCol >= 0 ? String(data[i][statusCol] || '').toLowerCase() : '';
if (status !== 'pending' && status) requested++;
if (status === 'reviewed' || status === 'thanked') reviewed++;
}
return {
totalRequested: requested,
totalReviewed: reviewed,
conversionRate: requested > 0 ? (reviewed / requested * 100).toFixed(1) + '%' : '0%'
};
}
/* ================================================================
DATA FUNCTIONS
================================================================ */
function getRegisteredStudents_() {
const students = [];
try {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const regSheet = CONFIG.REGISTRATION_SHEET_TAB
? (regSS.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB) || regSS.getSheets()[0])
: regSS.getSheets()[0];
if (!regSheet || regSheet.getLastRow() < 2) return students;
const data = regSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['student name', 'full name', 'name']);
const emailCol = findCol_(h, ['email', 'student email']);
const phoneCol = findCol_(h, ['phone', 'phone number', 'mobile']);
const pkgCol = findCol_(h, ['package', 'lesson package', 'program']);
const seen = new Set();
for (let i = 1; i < data.length; i++) {
const name = sanitize_(String(data[i][nameCol] || '').trim());
if (!name) continue;
const email = emailCol >= 0 ? String(data[i][emailCol] || '').trim().toLowerCase() : '';
const phone = phoneCol >= 0 ? String(data[i][phoneCol] || '').trim() : '';
const pkgRaw = pkgCol >= 0 ? String(data[i][pkgCol] || '') : '';
// Dedup by email first, then name
const key = email || name.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const { pkg, count } = resolvePackage_(pkgRaw);
students.push({ name, email, phone, pkg, lessonCount: count });
}
} catch (e) {
Logger.log('Registration read error: ' + e.message);
}
return students;
}
function resolvePackage_(pkgRaw) {
const raw = String(pkgRaw || '').toLowerCase().trim();
if (!raw) return { pkg: 'Unknown', count: 0 };
const keys = Object.keys(CONFIG.PACKAGES).sort((a, b) => b.length - a.length);
for (const key of keys) {
if (raw.includes(key.toLowerCase())) return { pkg: key, count: CONFIG.PACKAGES[key] };
}
const numMatch = raw.match(/(\d+)/);
if (numMatch) {
const num = numMatch[1];
for (const key of keys) {
if (key.includes(num)) return { pkg: key, count: CONFIG.PACKAGES[key] };
}
}
return { pkg: pkgRaw || 'Unknown', count: 0 };
}
function getAttendance_() {
const byEmail = {};
const byName = {};
try {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = CONFIG.BOOKINGS_SHEET_TAB
? (ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB) || ss.getSheets()[0])
: ss.getSheets()[0];
if (!sheet || sheet.getLastRow() < 2) return { byEmail, byName };
const data = sheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['student name', 'student', 'name']);
const emailCol = findCol_(h, ['student email', 'email']);
const statusCol = findCol_(h, ['status', 'attendance', 'booking status']);
const dateCol = findCol_(h, ['date', 'lesson date']);
const now = new Date();
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = statusCol >= 0 ? String(row[statusCol] || '').toLowerCase().trim() : '';
if (!isConfirmedStatus_(status)) continue;
// Only past lessons
if (dateCol >= 0 && row[dateCol]) {
const d = row[dateCol] instanceof Date ? row[dateCol] : new Date(row[dateCol]);
if (!isNaN(d.getTime()) && d > now) continue;
}
const name = nameCol >= 0 ? String(row[nameCol] || '').trim() : '';
const email = emailCol >= 0 ? String(row[emailCol] || '').trim().toLowerCase() : '';
const lessonDate = dateCol >= 0 ? (row[dateCol] instanceof Date ? row[dateCol] : new Date(row[dateCol])) : null;
const validDate = lessonDate && !isNaN(lessonDate.getTime()) ? lessonDate : null;
if (email) {
if (!byEmail[email]) byEmail[email] = { count: 0, lastDate: null };
byEmail[email].count++;
if (validDate && (!byEmail[email].lastDate || validDate > byEmail[email].lastDate)) byEmail[email].lastDate = validDate;
}
if (name) {
const nk = name.toLowerCase();
if (!byName[nk]) byName[nk] = { count: 0, lastDate: null };
byName[nk].count++;
if (validDate && (!byName[nk].lastDate || validDate > byName[nk].lastDate)) byName[nk].lastDate = validDate;
}
}
} catch (e) {
Logger.log('Attendance read error: ' + e.message);
}
return { byEmail, byName };
}
function isConfirmedStatus_(status) {
const s = String(status || '').toLowerCase().trim();
if (CONFIG.SKIP_STATUSES.some(skip => s === skip)) return false;
return CONFIG.CONFIRMED_STATUSES.some(ok => s === ok);
}
/* ================================================================
SETTINGS READER
================================================================ */
function getSettings_(ss) {
const defaults = {
reviewUrl: CONFIG.GOOGLE_REVIEW_URL,
schoolName: CONFIG.SCHOOL_NAME,
delayHours: CONFIG.DELAY_HOURS,
reminder1Days: CONFIG.REMINDER_1_DAYS,
reminder2Days: CONFIG.REMINDER_2_DAYS,
incentiveText: CONFIG.INCENTIVE_TEXT,
adminEmail: CONFIG.ADMIN_EMAIL,
includeFiveHour: !CONFIG.LESSON_PACKAGES_ONLY
};
const sheet = ss.getSheetByName('Settings');
if (!sheet || sheet.getLastRow() < 2) return defaults;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
const key = String(data[i][0] || '').toLowerCase();
const val = String(data[i][1] || '').trim();
if (!key || !val) continue;
if (key.includes('review url')) defaults.reviewUrl = val;
else if (key.includes('school name')) defaults.schoolName = val;
else if (key.includes('delay hours')) defaults.delayHours = parseInt(val, 10) || 24;
else if (key.includes('5-hour')) defaults.includeFiveHour = val.toLowerCase() === 'yes';
else if (key.includes('reminder 1')) defaults.reminder1Days = parseInt(val, 10) || 7;
else if (key.includes('reminder 2')) defaults.reminder2Days = parseInt(val, 10) || 14;
else if (key.includes('incentive')) defaults.incentiveText = val;
else if (key.includes('admin')) defaults.adminEmail = val;
}
return defaults;
}
/* ================================================================
EMAIL TEMPLATES (Mission Control theme)
================================================================ */
function buildReviewEmail_(name, reviewUrl, schoolName, incentiveText) {
const f = esc_(name.split(' ')[0]);
const sch = esc_(schoolName);
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe%20Review%20Emails&body=Please%20remove%20me.';
return emailWrap_(
'<div style="text-align:center;">'
+ '<div style="font-size:40px;letter-spacing:4px;">โญโญโญโญโญ</div>'
+ '<h1 style="margin:16px 0 0;color:#fff;font-size:22px;">Congratulations, ' + f + '!</h1>'
+ '<p style="margin:8px 0 0;color:#888;font-size:14px;">You\'ve completed your driving lessons at <span style="color:#ff2d2d;font-weight:600;">' + sch + '</span></p></div>',
'<p style="color:rgba(255,255,255,0.6);font-size:14px;line-height:1.7;text-align:center;">'
+ 'We hope you had an amazing experience! Your feedback helps other students find great instruction and means the world to our team.</p>'
+ '<p style="color:rgba(255,255,255,0.6);font-size:14px;text-align:center;">Would you take 30 seconds to share your experience?</p>'
+ '<div style="text-align:center;margin:24px 0;">'
+ '<a href="' + reviewUrl + '" style="display:inline-block;background:#ff2d2d;color:#fff;font-size:16px;font-weight:700;padding:14px 36px;border-radius:12px;text-decoration:none;">โญ Leave a Review</a>'
+ '<p style="margin:8px 0 0;color:rgba(255,255,255,0.3);font-size:12px;">Takes less than 30 seconds</p></div>'
+ (incentiveText ? '<p style="font-size:12px;color:rgba(255,255,255,0.4);text-align:center;margin-top:16px;padding:12px;background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.1);border-radius:8px;">๐ก ' + esc_(incentiveText) + '</p>' : ''),
unsub
);
}
function buildReminderEmail_(name, reviewUrl, schoolName, reminderNum) {
const f = esc_(name.split(' ')[0]);
const sch = esc_(schoolName);
const isLast = reminderNum === 2;
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe%20Review%20Emails&body=Please%20remove%20me.';
const emoji = isLast ? '๐' : '๐ฌ';
const title = isLast ? 'Last chance, ' + f + '!' : 'Hey ' + f + ', quick reminder!';
const text = isLast
? 'This is our last nudge โ we promise! If you have 30 seconds, we\'d really appreciate a quick review.'
: 'We\'d really appreciate it if you could take a moment to leave us a review. Your feedback helps future students find us!';
return emailWrap_(
'<div style="text-align:center;"><div style="font-size:32px;">' + emoji + '</div>'
+ '<h1 style="margin:12px 0 0;color:#fff;font-size:20px;">' + title + '</h1></div>',
'<p style="color:rgba(255,255,255,0.6);font-size:14px;line-height:1.7;text-align:center;">' + text + '</p>'
+ '<div style="text-align:center;margin:24px 0;">'
+ '<a href="' + reviewUrl + '" style="display:inline-block;background:#ff2d2d;color:#fff;font-size:15px;font-weight:700;padding:12px 32px;border-radius:12px;text-decoration:none;">โญ Leave a Review</a></div>',
unsub
);
}
function emailWrap_(header, body, unsubLink) {
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center" style="padding:20px;">'
+ '<table width="560" cellpadding="0" cellspacing="0" border="0" style="background:#0d0d0d;border-radius:16px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">'
+ '<tr><td style="padding:32px 40px 16px;">' + header + '</td></tr>'
+ '<tr><td style="padding:8px 40px 24px;">' + body + '</td></tr>'
+ '<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">'
+ '<p style="margin:0;color:rgba(255,255,255,0.2);font-size:12px;">' + sch + ' โ ' + tag + '</p>'
+ (unsubLink ? '<p style="font-size:10px;margin:6px 0 0;"><a href="' + unsubLink + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe</a></p>' : '')
+ '</td></tr></table></td></tr></table></body></html>';
}
/* ================================================================
SHARED HELPERS
================================================================ */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function safeUrl_(url) {
const u = String(url || '').trim();
return (u.startsWith('https://') || u.startsWith('http://')) ? u : '';
}
function esc_(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function sanitize_(str) {
return String(str || '').replace(/[<>{}()\[\]\\\/]/g, '').substring(0, 200).trim();
}
function fuzzyFind_(target, keys) {
if (!target) return null;
if (keys.includes(target)) return target;
const tParts = target.split(/\s+/);
const tFirst = tParts[0] || '';
const tLast = tParts[tParts.length - 1] || '';
for (const key of keys) {
if (levenshtein_(target, key) <= 2) return key;
const kParts = key.split(/\s+/);
const kFirst = kParts[0] || '';
const kLast = kParts[kParts.length - 1] || '';
if (tFirst.length >= 3 && kFirst.length >= 3 && tFirst.substring(0, 3) === kFirst.substring(0, 3) && tLast === kLast) return key;
if (tFirst === kLast && tLast === kFirst) return key;
}
return null;
}
function levenshtein_(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0]; dp[0] = j;
for (let i = 1; i <= m; i++) {
const temp = dp[i];
dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
prev = temp;
}
}
return dp[m];
}
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try { MailApp.sendEmail(CONFIG.ADMIN_EMAIL, subject, body, { name: CONFIG.SCHOOL_NAME }); } catch (_) {}
}
/* ================================================================
MANUAL TOOLS
================================================================ */
function testDemoReview() {
Logger.log('๐ญ Demo review stats: ' + JSON.stringify(DEMO.stats));
Logger.log('Would send to: ' + DEMO.completedStudents.map(s => s.name).join(', '));
}
function testReviewEmail() {
if (CONFIG.DEMO_MODE) { Logger.log('๐ญ DEMO MODE'); return; }
const ss = SpreadsheetApp.openById(CONFIG.REVIEW_SHEET_ID);
const settings = getSettings_(ss);
const url = safeUrl_(settings.reviewUrl) || CONFIG.GOOGLE_REVIEW_URL;
const html = buildReviewEmail_('Test Student', url, settings.schoolName, settings.incentiveText);
MailApp.sendEmail({ to: CONFIG.ADMIN_EMAIL, subject: 'โญ [TEST] Review Request', body: 'Test', htmlBody: html, name: CONFIG.SCHOOL_NAME });
Logger.log('Test email sent to ' + CONFIG.ADMIN_EMAIL);
}
/**
* =========================================================
* INSTRUCTOR SCHEDULE BOARD
* Flavors Driving School
* =========================================================
* Automated scheduling system with:
* - Double-booking prevention
* - Booking confirmation emails (Mission Control theme)
* - Weekly visual grid (auto-refresh)
* - Cancellation + reschedule flows
* - Daily instructor schedule emails
* - Waitlist auto-fill on cancellation
* - Student booking history API
* - Utilization stats
* - Past-date + daily booking limit validation
* - Demo mode for safe presentations
*
* SHEETS:
* "Bookings" โ Master booking log
* "Instructors" โ Instructor info
* "Weekly View" โ Auto-generated visual board
* "Booking Log" โ Audit trail
* =========================================================
*/
const CONFIG = {
// โโ Sheet ID โโ
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
// โโ Sheet tabs โโ
BOOKINGS_SHEET_TAB: 'Bookings',
INSTRUCTORS_TAB: 'Instructors',
WEEKLY_VIEW_TAB: 'Weekly View',
WAITLIST_TAB: 'Waitlist',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
SCHOOL_PHONE: '(718) 555-0100',
// โโ Instructors โโ
INSTRUCTORS: [
{ name: 'Anisha', car: 'Car 1 (update model)', email: '' },
{ name: 'Carlos', car: 'Car 2 (update model)', email: '' },
{ name: 'Nick', car: 'Car 3 (update model)', email: '' }
],
// โโ Schedule settings โโ
START_HOUR: 9,
END_HOUR: 18,
SLOT_DURATION_MIN: 60,
OPERATING_DAYS: [0, 1, 2, 3, 4, 5, 6],
TIMEZONE: 'America/New_York',
MAX_BOOKINGS_PER_STUDENT_PER_DAY: 2,
TIME_SLOTS: [
'9:00 AM', '10:00 AM', '11:00 AM', '12:00 PM',
'1:00 PM', '2:00 PM', '3:00 PM', '4:00 PM', '5:00 PM'
],
FORM_RESPONSE_SHEET: 'Form Responses 1',
// โโ Demo mode โโ
DEMO_MODE: true
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO = {
booking: {
id: 'BK-0042',
instructor: 'Anisha',
student: 'Sarah Johnson',
email: '[email protected]',
phone: '917-555-0123',
date: '02/22/2026',
time: '10:00 AM',
car: 'Honda Civic 2023',
status: 'Confirmed'
},
weekSummary: {
totalBookings: 18,
utilization: { Anisha: 67, Carlos: 56, Nick: 44 },
cancelledThisWeek: 2
}
};
/* ================================================================
SETUP & AUTH
================================================================ */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
MailApp.getRemainingDailyQuota();
FormApp.getActiveForm();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
// โโ Bookings sheet (create if missing, don't wipe if exists) โโ
let bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet) {
bookSheet = ss.insertSheet(CONFIG.BOOKINGS_SHEET_TAB);
bookSheet.appendRow([
'Booking ID', 'Timestamp', 'Instructor', 'Student Name', 'Student Email',
'Student Phone', 'Date', 'Time Slot', 'Status', 'Car', 'Notes', 'Confirmed By'
]);
styleHeader_(bookSheet, 12);
Logger.log('โ
Created "' + CONFIG.BOOKINGS_SHEET_TAB + '" sheet.');
}
// โโ Instructors sheet (create if missing, don't wipe if exists) โโ
let instSheet = ss.getSheetByName(CONFIG.INSTRUCTORS_TAB);
if (!instSheet) {
instSheet = ss.insertSheet(CONFIG.INSTRUCTORS_TAB);
instSheet.appendRow(['Instructor Name', 'Car / Vehicle', 'Email', 'Phone', 'Notes']);
for (const inst of CONFIG.INSTRUCTORS) {
instSheet.appendRow([inst.name, inst.car, inst.email, '', '']);
}
styleHeader_(instSheet, 5);
Logger.log('โ
Created "' + CONFIG.INSTRUCTORS_TAB + '" sheet.');
}
// โโ Weekly View โโ
let weekSheet = ss.getSheetByName(CONFIG.WEEKLY_VIEW_TAB);
if (!weekSheet) {
ss.insertSheet(CONFIG.WEEKLY_VIEW_TAB);
Logger.log('โ
Created "' + CONFIG.WEEKLY_VIEW_TAB + '" sheet.');
}
// โโ Booking Log (audit trail) โโ
let logSheet = ss.getSheetByName('Booking Log');
if (!logSheet) {
logSheet = ss.insertSheet('Booking Log');
logSheet.appendRow([
'Timestamp', 'Action', 'Booking ID', 'Student', 'Instructor',
'Date', 'Time', 'Status', 'Details'
]);
styleHeader_(logSheet, 9);
Logger.log('โ
Created "Booking Log" sheet.');
}
// โโ Triggers (clean old first) โโ
ScriptApp.getProjectTriggers().forEach(t => {
const fn = t.getHandlerFunction();
if (['onFormSubmit', 'generateWeeklyView', 'sendDailyInstructorSchedules'].includes(fn)) {
ScriptApp.deleteTrigger(t);
}
});
// Daily midnight โ refresh weekly view
ScriptApp.newTrigger('generateWeeklyView')
.timeBased().everyDays(1).atHour(0).nearMinute(5).create();
// Daily 7 AM โ email instructors their schedule
ScriptApp.newTrigger('sendDailyInstructorSchedules')
.timeBased().everyDays(1).atHour(7).create();
Logger.log('โ
Triggers set: Weekly View midnight, Instructor Schedules 7 AM.');
Logger.log('โ
Instructor Schedule Board setup complete.');
Logger.log('๐ Next: Update instructor car models and emails in the Instructors sheet.');
}
function styleHeader_(sheet, numCols) {
const range = sheet.getRange(1, 1, 1, numCols);
range.setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontWeight('bold');
sheet.setFrozenRows(1);
}
/* ================================================================
BOOKING FUNCTION
================================================================ */
function bookLesson(instructor, studentName, studentEmail, studentPhone, dateStr, timeSlot, notes) {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would book: ' + studentName + ' with ' + instructor + ' on ' + dateStr + ' at ' + timeSlot);
return { success: true, bookingId: DEMO.booking.id, message: '๐ญ Demo booking created.' };
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB) || ss.getSheets()[0];
const tz = CONFIG.TIMEZONE;
// Sanitize inputs
studentName = sanitize_(studentName);
studentEmail = String(studentEmail || '').trim().toLowerCase();
studentPhone = String(studentPhone || '').trim();
notes = sanitize_(notes || '');
instructor = sanitize_(instructor);
// Normalize date
const parsedDate = parseDate_(dateStr);
const normalizedDateStr = parsedDate ? fmtDate_(parsedDate) : String(dateStr).trim();
// โโ Validate operating day โโ
if (parsedDate && !CONFIG.OPERATING_DAYS.includes(parsedDate.getDay())) {
return { success: false, message: 'We are closed on that day. Please choose an operating day.' };
}
// โโ Validate not in the past โโ
if (parsedDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (parsedDate < today) {
return { success: false, message: 'Cannot book lessons in the past. Please choose a future date.' };
}
}
// โโ Conflict check โโ
const conflict = checkConflict_(ss, instructor, normalizedDateStr, timeSlot);
if (conflict) {
const available = getAvailableSlots(normalizedDateStr, instructor);
const altSlots = (available[instructor] || []).slice(0, 3).join(', ');
const altMsg = altSlots ? ' Available slots: ' + altSlots + '.' : '';
return {
success: false,
message: 'CONFLICT: ' + instructor + ' is already booked at ' + timeSlot + ' on ' + normalizedDateStr + ' with ' + conflict.student + '.' + altMsg
};
}
// โโ Daily booking limit โโ
if (studentEmail && CONFIG.MAX_BOOKINGS_PER_STUDENT_PER_DAY > 0) {
const todayCount = countStudentBookingsOnDate_(ss, studentEmail, studentName, normalizedDateStr);
if (todayCount >= CONFIG.MAX_BOOKINGS_PER_STUDENT_PER_DAY) {
return { success: false, message: 'Booking limit reached: max ' + CONFIG.MAX_BOOKINGS_PER_STUDENT_PER_DAY + ' lessons per day.' };
}
}
// โโ Generate booking ID โโ
const bookingId = generateBookingId_(bookSheet);
const car = getInstructorCar_(ss, instructor);
try {
bookSheet.appendRow([
bookingId, new Date(), instructor, studentName, studentEmail,
studentPhone, normalizedDateStr, timeSlot, 'Confirmed', car, notes, 'System'
]);
sendBookingConfirmation_(bookingId, instructor, studentName, studentEmail, normalizedDateStr, timeSlot, car);
logAction_('BOOK', bookingId, studentName, instructor, normalizedDateStr, timeSlot, 'Confirmed', '');
// Refresh weekly view (async-safe)
try { generateWeeklyView(); } catch (_) {}
return {
success: true,
bookingId: bookingId,
message: 'Lesson booked! ' + studentName + ' with ' + instructor + ' on ' + normalizedDateStr + ' at ' + timeSlot + '. Booking ID: ' + bookingId
};
} catch (err) {
Logger.log('bookLesson error: ' + (err.message || err));
return { success: false, message: 'Booking could not be saved: ' + (err.message || String(err)) };
}
}
/* ================================================================
BOOKING ID GENERATOR (short, memorable)
================================================================ */
function generateBookingId_(bookSheet) {
let maxNum = 0;
if (bookSheet && bookSheet.getLastRow() > 1) {
const ids = bookSheet.getRange(2, 1, bookSheet.getLastRow() - 1, 1).getValues();
for (const row of ids) {
const match = String(row[0] || '').match(/BK-(\d+)/);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
}
return 'BK-' + String(maxNum + 1).padStart(4, '0');
}
/* ================================================================
CONFLICT CHECK
================================================================ */
function checkConflict_(ss, instructor, dateStr, timeSlot) {
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB) || ss.getSheets()[0];
if (!bookSheet || bookSheet.getLastRow() < 2) return null;
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const instCol = findCol_(headers, ['instructor', 'instructor name']);
const dateCol = findCol_(headers, ['date', 'lesson date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
const statusCol = findCol_(headers, ['status', 'booking status']);
const nameCol = findCol_(headers, ['student name', 'student', 'name']);
if (instCol < 0 || dateCol < 0 || timeCol < 0) return null;
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = String(row[statusCol] != null ? row[statusCol] : '').toLowerCase();
if (status === 'cancelled' || status === 'canceled') continue;
const rowInst = String(row[instCol] || '').trim();
const rowDate = fmtDate_(row[dateCol]);
const rowTime = String(row[timeCol] || '').trim();
if (rowInst === instructor && rowDate === dateStr && rowTime === timeSlot) {
return { student: nameCol >= 0 ? String(row[nameCol] || '') : 'Unknown' };
}
}
return null;
}
/* ================================================================
DAILY BOOKING LIMIT
================================================================ */
function countStudentBookingsOnDate_(ss, email, name, dateStr) {
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return 0;
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const emailCol = findCol_(headers, ['student email', 'email']);
const nameCol = findCol_(headers, ['student name', 'student', 'name']);
const dateCol = findCol_(headers, ['date', 'lesson date']);
const statusCol = findCol_(headers, ['status']);
let count = 0;
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = String(row[statusCol] != null ? row[statusCol] : '').toLowerCase();
if (status === 'cancelled' || status === 'canceled') continue;
const rowDate = fmtDate_(row[dateCol]);
if (rowDate !== dateStr) continue;
// Email-first matching
if (email && emailCol >= 0 && String(row[emailCol] || '').toLowerCase().trim() === email) { count++; continue; }
// Fuzzy name fallback
if (name && nameCol >= 0) {
const rowName = String(row[nameCol] || '').toLowerCase().trim();
if (rowName === name.toLowerCase() || levenshtein_(rowName, name.toLowerCase()) <= 2) count++;
}
}
return count;
}
/* ================================================================
AVAILABLE SLOTS
================================================================ */
function getAvailableSlots(dateStr, instructor) {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const allSlots = CONFIG.TIME_SLOTS;
const instructors = instructor ? [instructor] : CONFIG.INSTRUCTORS.map(i => i.name);
const available = {};
for (const inst of instructors) {
available[inst] = allSlots.filter(slot => !checkConflict_(ss, inst, dateStr, slot));
}
return available;
}
/* ================================================================
CANCEL BOOKING
================================================================ */
function cancelBooking(bookingId) {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would cancel booking: ' + bookingId);
return true;
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return false;
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const idCol = findCol_(headers, ['booking id', 'id']);
const statusCol = findCol_(headers, ['status']);
const nameCol = findCol_(headers, ['student name', 'student']);
const emailCol = findCol_(headers, ['student email', 'email']);
const instCol = findCol_(headers, ['instructor']);
const dateCol = findCol_(headers, ['date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
for (let i = 1; i < data.length; i++) {
if (String(data[i][idCol] || '').trim() === bookingId) {
bookSheet.getRange(i + 1, statusCol + 1).setValue('Cancelled');
const row = data[i];
const studentName = String(row[nameCol] || '');
const studentEmail = emailCol >= 0 ? String(row[emailCol] || '') : '';
const instructor = String(row[instCol] || '');
const dateStr = fmtDate_(row[dateCol]);
const timeSlot = String(row[timeCol] || '');
sendCancellationEmail_(bookingId, studentName, studentEmail, instructor, dateStr, timeSlot);
logAction_('CANCEL', bookingId, studentName, instructor, dateStr, timeSlot, 'Cancelled', '');
// โโ Auto-fill from waitlist โโ
tryFillFromWaitlist_(ss, instructor, dateStr, timeSlot);
try { generateWeeklyView(); } catch (_) {}
Logger.log('โ
Booking ' + bookingId + ' cancelled.');
return true;
}
}
Logger.log('Booking ' + bookingId + ' not found.');
return false;
}
/* ================================================================
RESCHEDULE BOOKING
================================================================ */
function rescheduleBooking(bookingId, newDateStr, newTimeSlot) {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would reschedule ' + bookingId + ' to ' + newDateStr + ' ' + newTimeSlot);
return { success: true, message: '๐ญ Demo reschedule.' };
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return { success: false, message: 'No bookings found.' };
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const idCol = findCol_(headers, ['booking id', 'id']);
const instCol = findCol_(headers, ['instructor']);
const nameCol = findCol_(headers, ['student name', 'student']);
const emailCol = findCol_(headers, ['student email', 'email']);
const phoneCol = findCol_(headers, ['student phone', 'phone']);
const dateCol = findCol_(headers, ['date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
const statusCol = findCol_(headers, ['status']);
const notesCol = findCol_(headers, ['notes']);
for (let i = 1; i < data.length; i++) {
if (String(data[i][idCol] || '').trim() !== bookingId) continue;
const row = data[i];
const oldStatus = String(row[statusCol] || '').toLowerCase();
if (oldStatus === 'cancelled' || oldStatus === 'canceled') {
return { success: false, message: 'Cannot reschedule a cancelled booking.' };
}
const instructor = String(row[instCol] || '');
const studentName = String(row[nameCol] || '');
const studentEmail = emailCol >= 0 ? String(row[emailCol] || '') : '';
const studentPhone = phoneCol >= 0 ? String(row[phoneCol] || '') : '';
const oldDate = fmtDate_(row[dateCol]);
const oldTime = String(row[timeCol] || '');
const notes = notesCol >= 0 ? String(row[notesCol] || '') : '';
const newDate = parseDate_(newDateStr);
const normalizedNew = newDate ? fmtDate_(newDate) : newDateStr;
// Validate new date not in past
if (newDate) {
const today = new Date();
today.setHours(0, 0, 0, 0);
if (newDate < today) return { success: false, message: 'Cannot reschedule to a past date.' };
}
// Check conflict on new slot
const conflict = checkConflict_(ss, instructor, normalizedNew, newTimeSlot);
if (conflict) {
return { success: false, message: 'CONFLICT: ' + instructor + ' is already booked at ' + newTimeSlot + ' on ' + normalizedNew + '.' };
}
// Cancel old
bookSheet.getRange(i + 1, statusCol + 1).setValue('Rescheduled');
logAction_('RESCHEDULE', bookingId, studentName, instructor, oldDate, oldTime, 'Rescheduled โ ' + normalizedNew + ' ' + newTimeSlot, '');
// Create new booking
const result = bookLesson(instructor, studentName, studentEmail, studentPhone, normalizedNew, newTimeSlot, notes + ' (rescheduled from ' + oldDate + ' ' + oldTime + ')');
// Notify student about reschedule
if (studentEmail) {
sendRescheduleEmail_(studentName, studentEmail, instructor, oldDate, oldTime, normalizedNew, newTimeSlot, result.bookingId || '');
}
// Try fill old slot from waitlist
tryFillFromWaitlist_(ss, instructor, oldDate, oldTime);
return result;
}
return { success: false, message: 'Booking ' + bookingId + ' not found.' };
}
/* ================================================================
WAITLIST AUTO-FILL
================================================================ */
function tryFillFromWaitlist_(ss, instructor, dateStr, timeSlot) {
try {
const waitSheet = ss.getSheetByName(CONFIG.WAITLIST_TAB);
if (!waitSheet || waitSheet.getLastRow() < 2) return;
const data = waitSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const wNameCol = findCol_(headers, ['student', 'student name', 'name']);
const wEmailCol = findCol_(headers, ['email', 'student email']);
const wInstCol = findCol_(headers, ['instructor', 'preferred instructor']);
const wDateCol = findCol_(headers, ['date', 'preferred date']);
const wTimeCol = findCol_(headers, ['time', 'preferred time', 'time slot']);
const wStatusCol = findCol_(headers, ['status', 'waitlist status']);
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = wStatusCol >= 0 ? String(row[wStatusCol] || '').toLowerCase() : '';
if (status === 'filled' || status === 'cancelled') continue;
const wInst = wInstCol >= 0 ? String(row[wInstCol] || '').trim() : '';
const wDate = wDateCol >= 0 ? fmtDate_(row[wDateCol]) : '';
const wTime = wTimeCol >= 0 ? String(row[wTimeCol] || '').trim() : '';
// Match instructor + date + time (or any instructor if blank)
if ((!wInst || wInst === instructor) && (!wDate || wDate === dateStr) && (!wTime || wTime === timeSlot)) {
const name = wNameCol >= 0 ? String(row[wNameCol] || '') : '';
const email = wEmailCol >= 0 ? String(row[wEmailCol] || '') : '';
if (name && email) {
// Notify waitlisted student about the opening
sendWaitlistOfferEmail_(name, email, instructor, dateStr, timeSlot);
if (wStatusCol >= 0) {
waitSheet.getRange(i + 1, wStatusCol + 1).setValue('Offered');
}
logAction_('WAITLIST_OFFER', '', name, instructor, dateStr, timeSlot, 'Offered open slot', '');
Logger.log('๐ Waitlist offer sent to ' + name + ' for ' + instructor + ' ' + dateStr + ' ' + timeSlot);
return; // Only offer to first match
}
}
}
} catch (e) {
Logger.log('Waitlist auto-fill error: ' + e.message);
}
}
/* ================================================================
STUDENT BOOKING HISTORY (API for Student Portal)
================================================================ */
function getStudentBookings(email) {
if (CONFIG.DEMO_MODE) {
return [
{ bookingId: 'BK-0040', instructor: 'Anisha', date: '02/20/2026', time: '10:00 AM', status: 'Confirmed', car: 'Honda Civic 2023' },
{ bookingId: 'BK-0038', instructor: 'Carlos', date: '02/18/2026', time: '2:00 PM', status: 'Completed', car: 'Toyota Corolla 2022' },
{ bookingId: 'BK-0035', instructor: 'Anisha', date: '02/15/2026', time: '11:00 AM', status: 'Completed', car: 'Honda Civic 2023' }
];
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return [];
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const idCol = findCol_(headers, ['booking id', 'id']);
const instCol = findCol_(headers, ['instructor']);
const nameCol = findCol_(headers, ['student name', 'student']);
const emailCol = findCol_(headers, ['student email', 'email']);
const dateCol = findCol_(headers, ['date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
const statusCol = findCol_(headers, ['status']);
const carCol = findCol_(headers, ['car', 'vehicle']);
const emailLower = String(email || '').toLowerCase().trim();
const results = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
const rowEmail = emailCol >= 0 ? String(row[emailCol] || '').toLowerCase().trim() : '';
if (rowEmail !== emailLower) continue;
results.push({
bookingId: idCol >= 0 ? String(row[idCol] || '') : '',
instructor: instCol >= 0 ? String(row[instCol] || '') : '',
date: dateCol >= 0 ? fmtDate_(row[dateCol]) : '',
time: timeCol >= 0 ? String(row[timeCol] || '') : '',
status: statusCol >= 0 ? String(row[statusCol] || '') : '',
car: carCol >= 0 ? String(row[carCol] || '') : ''
});
}
// Sort by date descending
results.sort((a, b) => {
const da = new Date(a.date), db = new Date(b.date);
return db - da;
});
return results;
}
/* ================================================================
UTILIZATION STATS
================================================================ */
function getWeeklyUtilization() {
if (CONFIG.DEMO_MODE) return DEMO.weekSummary.utilization;
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return {};
const today = new Date();
const dayOfWeek = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
monday.setHours(0, 0, 0, 0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23, 59, 59, 999);
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const instCol = findCol_(headers, ['instructor']);
const dateCol = findCol_(headers, ['date']);
const statusCol = findCol_(headers, ['status']);
const counts = {};
CONFIG.INSTRUCTORS.forEach(i => { counts[i.name] = 0; });
const maxSlotsPerWeek = CONFIG.TIME_SLOTS.length * 7;
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = String(row[statusCol] != null ? row[statusCol] : '').toLowerCase();
if (status === 'cancelled' || status === 'canceled') continue;
const rowDate = row[dateCol] instanceof Date ? row[dateCol] : new Date(row[dateCol]);
if (isNaN(rowDate.getTime()) || rowDate < monday || rowDate > sunday) continue;
const inst = String(row[instCol] || '').trim();
if (counts[inst] !== undefined) counts[inst]++;
}
const util = {};
for (const [name, count] of Object.entries(counts)) {
util[name] = Math.round((count / maxSlotsPerWeek) * 100);
}
return util;
}
/* ================================================================
DAILY INSTRUCTOR SCHEDULE EMAILS
================================================================ */
function sendDailyInstructorSchedules() {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would send daily schedules to instructors.');
return;
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return;
const tz = CONFIG.TIMEZONE;
const today = new Date();
const todayStr = Utilities.formatDate(today, tz, 'MM/dd/yyyy');
const todayNice = Utilities.formatDate(today, tz, 'EEEE, MMMM d');
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const instCol = findCol_(headers, ['instructor']);
const nameCol = findCol_(headers, ['student name', 'student']);
const dateCol = findCol_(headers, ['date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
const statusCol = findCol_(headers, ['status']);
const carCol = findCol_(headers, ['car', 'vehicle']);
const phoneCol = findCol_(headers, ['student phone', 'phone']);
// Group by instructor
const schedules = {};
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = String(row[statusCol] != null ? row[statusCol] : '').toLowerCase();
if (status === 'cancelled' || status === 'canceled') continue;
const rowDate = fmtDate_(row[dateCol]);
if (rowDate !== todayStr) continue;
const inst = String(row[instCol] || '').trim();
if (!schedules[inst]) schedules[inst] = [];
schedules[inst].push({
time: timeCol >= 0 ? String(row[timeCol] || '') : '',
student: nameCol >= 0 ? String(row[nameCol] || '') : '',
phone: phoneCol >= 0 ? String(row[phoneCol] || '') : '',
car: carCol >= 0 ? String(row[carCol] || '') : ''
});
}
// Send to each instructor
const instSheet = ss.getSheetByName(CONFIG.INSTRUCTORS_TAB);
const instData = instSheet ? instSheet.getDataRange().getValues() : [];
for (const inst of CONFIG.INSTRUCTORS) {
const email = getInstructorEmail_(ss, inst.name);
const lessons = schedules[inst.name] || [];
// Sort by time
lessons.sort((a, b) => {
const ta = parseTimeSlot_(a.time), tb = parseTimeSlot_(b.time);
return ta - tb;
});
// Always send to admin, and to instructor if they have email
const recipients = [CONFIG.ADMIN_EMAIL];
if (email && email.includes('@')) recipients.push(email);
const subject = '๐
' + inst.name + '\'s Schedule โ ' + todayNice + ' (' + lessons.length + ' lessons)';
const htmlBody = buildDailyScheduleEmail_(inst.name, todayNice, lessons);
for (const to of recipients) {
try {
MailApp.sendEmail({ to: to, subject: subject, body: plainSchedule_(inst.name, todayNice, lessons), htmlBody: htmlBody, name: CONFIG.SCHOOL_NAME });
} catch (e) {
Logger.log('Failed to send schedule to ' + to + ': ' + e.message);
}
}
}
Logger.log('โ
Daily schedules sent for ' + todayNice);
}
function plainSchedule_(instName, date, lessons) {
let text = instName + '\'s Schedule โ ' + date + '\n\n';
if (lessons.length === 0) { text += 'No lessons scheduled today. Enjoy your day off!\n'; return text; }
for (const l of lessons) {
text += l.time + ' โ ' + l.student + (l.phone ? ' (' + l.phone + ')' : '') + '\n';
}
return text;
}
function parseTimeSlot_(timeStr) {
const match = String(timeStr || '').match(/(\d+):(\d+)\s*(AM|PM)/i);
if (!match) return 0;
let h = parseInt(match[1], 10);
const m = parseInt(match[2], 10);
if (match[3].toUpperCase() === 'PM' && h !== 12) h += 12;
if (match[3].toUpperCase() === 'AM' && h === 12) h = 0;
return h * 60 + m;
}
/* ================================================================
WEEKLY VIEW GENERATOR (batch writes)
================================================================ */
function generateWeeklyView() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
let weekSheet = ss.getSheetByName(CONFIG.WEEKLY_VIEW_TAB);
if (!weekSheet) weekSheet = ss.insertSheet(CONFIG.WEEKLY_VIEW_TAB);
else weekSheet.clear();
const tz = CONFIG.TIMEZONE;
const today = new Date();
const dayOfWeek = today.getDay();
const monday = new Date(today);
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
const days = [];
for (let i = 0; i < 7; i++) {
const d = new Date(monday);
d.setDate(monday.getDate() + i);
days.push(d);
}
const dayNames = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const instructors = CONFIG.INSTRUCTORS;
const timeSlots = CONFIG.TIME_SLOTS;
// Get all bookings for this week
const bookings = getWeekBookings_(ss, monday);
// Build all rows in memory
const allRows = [];
// Header row 1: day labels
const headerRow1 = [''];
for (let d = 0; d < days.length; d++) {
const dateStr = Utilities.formatDate(days[d], tz, 'MM/dd');
for (let j = 0; j < instructors.length; j++) {
headerRow1.push(j === 0 ? dayNames[d] + ' ' + dateStr : '');
}
}
allRows.push(headerRow1);
// Header row 2: instructor names
const headerRow2 = ['Time'];
for (let d = 0; d < days.length; d++) {
for (const inst of instructors) headerRow2.push(inst.name);
}
allRows.push(headerRow2);
// Data rows
for (const slot of timeSlots) {
const row = [slot];
for (const day of days) {
const dateStr = Utilities.formatDate(day, tz, 'MM/dd/yyyy');
for (const inst of instructors) {
const booking = bookings.find(b => b.instructor === inst.name && b.date === dateStr && b.time === slot);
row.push(booking ? booking.student : '');
}
}
allRows.push(row);
}
// Batch write
const totalCols = 1 + (7 * instructors.length);
weekSheet.getRange(1, 1, allRows.length, totalCols).setValues(allRows);
// โโ Styling โโ
const totalRows = allRows.length;
weekSheet.getRange(1, 1, 1, totalCols)
.setBackground('#0d0d0d').setFontColor('#ff2d2d').setFontWeight('bold').setFontSize(11).setHorizontalAlignment('center');
weekSheet.getRange(2, 1, 1, totalCols)
.setBackground('#1a1a1a').setFontColor('#ffffff').setFontWeight('bold').setFontSize(10).setHorizontalAlignment('center');
weekSheet.getRange(3, 1, totalRows - 2, 1)
.setBackground('#111111').setFontColor('#888888').setFontWeight('bold').setHorizontalAlignment('center');
weekSheet.getRange(3, 2, totalRows - 2, totalCols - 1)
.setBackground('#0a0a0a').setFontColor('#cccccc').setHorizontalAlignment('center').setFontSize(9);
// Highlight booked cells
for (let r = 3; r <= totalRows; r++) {
for (let c = 2; c <= totalCols; c++) {
const cell = weekSheet.getRange(r, c);
if (String(cell.getValue()).trim()) {
cell.setBackground('#1a0000').setFontColor('#ff4444').setFontWeight('bold');
cell.setBorder(true, true, true, true, false, false, '#ff2d2d', SpreadsheetApp.BorderStyle.SOLID);
}
}
}
weekSheet.getRange(1, 1, totalRows, totalCols).setBorder(true, true, true, true, true, true, '#333333', SpreadsheetApp.BorderStyle.SOLID);
weekSheet.setColumnWidth(1, 80);
for (let c = 2; c <= totalCols; c++) weekSheet.setColumnWidth(c, 90);
weekSheet.setFrozenRows(2);
weekSheet.setFrozenColumns(1);
Logger.log('โ
Weekly view generated for week of ' + Utilities.formatDate(monday, tz, 'MM/dd/yyyy'));
}
function getWeekBookings_(ss, mondayDate) {
const bookSheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (!bookSheet || bookSheet.getLastRow() < 2) return [];
const data = bookSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const instCol = findCol_(headers, ['instructor']);
const dateCol = findCol_(headers, ['date']);
const timeCol = findCol_(headers, ['time slot', 'time']);
const statusCol = findCol_(headers, ['status']);
const nameCol = findCol_(headers, ['student name', 'student']);
const mondayStart = new Date(mondayDate);
mondayStart.setHours(0, 0, 0, 0);
const sundayEnd = new Date(mondayDate);
sundayEnd.setDate(mondayDate.getDate() + 6);
sundayEnd.setHours(23, 59, 59, 999);
const bookings = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
const status = String(row[statusCol] != null ? row[statusCol] : '').toLowerCase();
if (status === 'cancelled' || status === 'canceled') continue;
const rowDate = row[dateCol] instanceof Date ? row[dateCol] : new Date(row[dateCol]);
if (isNaN(rowDate.getTime()) || rowDate < mondayStart || rowDate > sundayEnd) continue;
bookings.push({
instructor: String(row[instCol] || '').trim(),
date: fmtDate_(row[dateCol]),
time: String(row[timeCol] || '').trim(),
student: String(row[nameCol] || '').trim()
});
}
return bookings;
}
/* ================================================================
FORM SUBMISSION HANDLER
================================================================ */
function onFormSubmit(e) {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ form submit ignored.');
return;
}
if (!e || !e.namedValues) {
Logger.log('onFormSubmit: no event data.');
return;
}
const val = (keys) => {
for (const k of keys) {
const v = e.namedValues[k];
if (v && v[0]) return sanitize_(v[0].trim());
}
return '';
};
const instructor = val(['Instructor', 'instructor']);
const studentName = val(['Student Name', 'student name', 'Full Name']);
const studentEmail = val(['Student Email', 'Email', 'email']);
const studentPhone = val(['Phone', 'phone', 'Student Phone']);
const dateRaw = val(['Lesson Date', 'Date', 'date']);
const timeSlot = val(['Time Slot', 'Time', 'time slot']);
const notes = val(['Notes', 'notes']);
const dateStr = dateRaw ? fmtDate_(new Date(dateRaw)) : '';
const result = bookLesson(instructor, studentName, studentEmail, studentPhone, dateStr, timeSlot, notes);
if (!result.success) {
// Send conflict notification
const conflictHtml = buildConflictEmail_(instructor, studentName, dateStr, timeSlot, result.message);
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: 'โ ๏ธ Booking Conflict โ ' + instructor + ' on ' + dateStr,
body: result.message,
htmlBody: conflictHtml,
name: CONFIG.SCHOOL_NAME
});
Logger.log('CONFLICT: ' + result.message);
} else {
Logger.log('BOOKED: ' + result.message);
}
} catch (err) {
Logger.log('onFormSubmit error: ' + (err.message || err));
notifyAdmin_('Schedule Board โ Form Submit Error', 'onFormSubmit failed: ' + String(err.message || err).substring(0, 500));
throw err;
}
}
/* ================================================================
EMAIL TEMPLATES (Mission Control theme)
================================================================ */
function sendBookingConfirmation_(bookingId, instructor, studentName, studentEmail, dateStr, timeSlot, car) {
const sch = esc_(CONFIG.SCHOOL_NAME);
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(studentEmail) + '%20from%20booking%20emails.';
const html = emailWrap_(
iconHeader_('โ
', '#22c55e', 'Lesson Confirmed'),
'<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Booking ID', esc_(bookingId))
+ detailRow_('Student', esc_(studentName))
+ detailRow_('Instructor', esc_(instructor), '#ff2d2d')
+ detailRow_('Date', esc_(dateStr))
+ detailRow_('Time', esc_(timeSlot))
+ detailRow_('Vehicle', esc_(car))
+ '</table>'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.3);margin:16px 0 0;text-align:center;">Please arrive 10 minutes early. Bring your learner permit and valid photo ID.</p>',
unsub
);
// Admin
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: 'โ
Lesson Booked โ ' + studentName + ' with ' + instructor + ' on ' + dateStr,
body: 'Booking confirmed: ' + studentName + ' with ' + instructor + ' on ' + dateStr + ' at ' + timeSlot + '. ID: ' + bookingId,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
// Student
if (studentEmail && studentEmail.includes('@')) {
MailApp.sendEmail({
to: studentEmail,
subject: 'Your Driving Lesson is Confirmed! โ ' + dateStr + ' at ' + timeSlot,
body: 'Hi ' + studentName + '! Your lesson with ' + instructor + ' on ' + dateStr + ' at ' + timeSlot + ' is confirmed. Booking ID: ' + bookingId,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
}
// Instructor
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const instEmail = getInstructorEmail_(ss, instructor);
if (instEmail && instEmail.includes('@')) {
MailApp.sendEmail({
to: instEmail,
subject: '๐
New Lesson โ ' + studentName + ' on ' + dateStr + ' at ' + timeSlot,
body: 'New lesson: ' + studentName + ' on ' + dateStr + ' at ' + timeSlot + '. ID: ' + bookingId,
htmlBody: html,
name: CONFIG.SCHOOL_NAME
});
}
}
function sendCancellationEmail_(bookingId, studentName, studentEmail, instructor, dateStr, timeSlot) {
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20me%20from%20booking%20emails.';
const html = emailWrap_(
iconHeader_('โ', '#ff2d2d', 'Lesson Cancelled'),
'<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Booking ID', esc_(bookingId))
+ detailRow_('Student', esc_(studentName))
+ detailRow_('Instructor', esc_(instructor), '#ff2d2d')
+ detailRow_('Date', esc_(dateStr))
+ detailRow_('Time', esc_(timeSlot))
+ '</table>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.5);margin:16px 0 0;text-align:center;">Please contact us to rebook when you\'re ready.</p>',
unsub
);
if (studentEmail && studentEmail.includes('@')) {
MailApp.sendEmail({ to: studentEmail, subject: 'Driving Lesson Cancelled โ ' + dateStr + ' at ' + timeSlot,
body: 'Your lesson on ' + dateStr + ' at ' + timeSlot + ' has been cancelled. Contact us to rebook.', htmlBody: html, name: CONFIG.SCHOOL_NAME });
}
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const instEmail = getInstructorEmail_(ss, instructor);
if (instEmail && instEmail.includes('@')) {
MailApp.sendEmail({ to: instEmail, subject: 'Lesson Cancelled โ ' + studentName + ' on ' + dateStr,
body: 'Cancelled: ' + studentName + ' on ' + dateStr + ' at ' + timeSlot, htmlBody: html, name: CONFIG.SCHOOL_NAME });
}
}
function sendRescheduleEmail_(studentName, studentEmail, instructor, oldDate, oldTime, newDate, newTime, newBookingId) {
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20me%20from%20booking%20emails.';
const html = emailWrap_(
iconHeader_('๐', '#f59e0b', 'Lesson Rescheduled'),
'<p style="font-size:13px;color:rgba(255,255,255,0.5);margin:0 0 16px;text-align:center;">Your lesson has been moved to a new time.</p>'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Old Date/Time', esc_(oldDate + ' at ' + oldTime))
+ detailRow_('New Date/Time', esc_(newDate + ' at ' + newTime), '#22c55e')
+ detailRow_('Instructor', esc_(instructor), '#ff2d2d')
+ (newBookingId ? detailRow_('New Booking ID', esc_(newBookingId)) : '')
+ '</table>',
unsub
);
if (studentEmail && studentEmail.includes('@')) {
MailApp.sendEmail({ to: studentEmail, subject: 'Lesson Rescheduled โ Now ' + newDate + ' at ' + newTime,
body: 'Your lesson has been rescheduled from ' + oldDate + ' ' + oldTime + ' to ' + newDate + ' ' + newTime + ' with ' + instructor + '.',
htmlBody: html, name: CONFIG.SCHOOL_NAME });
}
}
function sendWaitlistOfferEmail_(studentName, email, instructor, dateStr, timeSlot) {
const firstName = (studentName || '').split(' ')[0] || studentName;
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20me%20from%20waitlist%20emails.';
const html = emailWrap_(
iconHeader_('๐', '#22c55e', 'A Slot Opened Up!'),
'<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;text-align:center;margin:0 0 16px;">'
+ 'Hi <strong style="color:#fff;">' + esc_(firstName) + '</strong>! A slot you were waiting for just opened up.</p>'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Instructor', esc_(instructor), '#ff2d2d')
+ detailRow_('Date', esc_(dateStr))
+ detailRow_('Time', esc_(timeSlot))
+ '</table>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.5);margin:16px 0 0;text-align:center;">Reply to this email or call ' + esc_(CONFIG.SCHOOL_PHONE) + ' to grab this slot before someone else does!</p>',
unsub
);
MailApp.sendEmail({ to: email, subject: '๐ Open Slot Available โ ' + instructor + ' on ' + dateStr + ' at ' + timeSlot,
body: 'Hi ' + firstName + '! A slot opened up: ' + instructor + ' on ' + dateStr + ' at ' + timeSlot + '. Contact us to book it!',
htmlBody: html, name: CONFIG.SCHOOL_NAME });
}
function buildConflictEmail_(instructor, studentName, dateStr, timeSlot, message) {
return emailWrap_(
iconHeader_('โ ๏ธ', '#ff2d2d', 'Double-Booking Detected'),
'<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.5;margin:0 0 16px;">' + esc_(message) + '</p>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.4);">Please contact <strong style="color:#fff;">' + esc_(studentName) + '</strong> to reschedule.</p>',
''
);
}
function buildDailyScheduleEmail_(instName, dateNice, lessons) {
let body = '';
if (lessons.length === 0) {
body = '<div style="text-align:center;padding:20px;">'
+ '<div style="font-size:48px;margin-bottom:12px;">โ๏ธ</div>'
+ '<p style="font-size:16px;color:#fff;font-weight:700;">No lessons today!</p>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.4);">Enjoy your day off.</p></div>';
} else {
body = '<table width="100%" cellpadding="0" cellspacing="4" border="0">';
for (const l of lessons) {
body += '<tr><td style="padding:12px;background:rgba(255,255,255,0.03);border-radius:8px;border-left:3px solid #ff2d2d;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:80px;vertical-align:top;"><div style="font-size:14px;color:#ff2d2d;font-weight:700;">' + esc_(l.time) + '</div></td>'
+ '<td><div style="font-size:14px;color:#fff;font-weight:600;">' + esc_(l.student) + '</div>'
+ (l.phone ? '<div style="font-size:11px;color:rgba(255,255,255,0.4);margin-top:2px;">๐ ' + esc_(l.phone) + '</div>' : '')
+ (l.car ? '<div style="font-size:11px;color:rgba(255,255,255,0.4);margin-top:2px;">๐ ' + esc_(l.car) + '</div>' : '')
+ '</td></tr></table></td></tr>';
}
body += '</table>';
}
return emailWrap_(
'<div style="text-align:center;padding-bottom:8px;">'
+ '<div style="font-size:18px;font-weight:800;color:#fff;">' + esc_(instName) + '\'s Schedule</div>'
+ '<div style="font-size:12px;color:rgba(255,255,255,0.4);margin-top:4px;">' + esc_(dateNice) + ' โ ' + lessons.length + ' lesson' + (lessons.length !== 1 ? 's' : '') + '</div></div>',
body, ''
);
}
/* ================================================================
EMAIL BUILDING BLOCKS
================================================================ */
function emailWrap_(header, body, unsubLink) {
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center" style="padding:20px;">'
+ '<table width="560" cellpadding="0" cellspacing="0" border="0" style="background:#0d0d0d;border-radius:16px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">'
+ '<tr><td style="padding:24px 30px;border-bottom:1px solid rgba(255,255,255,0.06);">' + header + '</td></tr>'
+ '<tr><td style="padding:24px 30px;">' + body + '</td></tr>'
+ '<tr><td style="padding:16px 30px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.2);margin:0;">' + sch + ' โ ' + tag + '</p>'
+ (unsubLink ? '<p style="font-size:10px;margin:6px 0 0;"><a href="' + unsubLink + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe</a></p>' : '')
+ '</td></tr></table></td></tr></table></body></html>';
}
function iconHeader_(emoji, color, title) {
return '<div style="display:inline-block;width:40px;height:40px;background:' + color + ';border-radius:12px;line-height:40px;font-size:20px;text-align:center;">' + emoji + '</div>'
+ '<span style="color:#fff;font-size:18px;font-weight:700;margin-left:12px;">' + esc_(title) + '</span>';
}
function detailRow_(label, value, color) {
return '<tr><td style="padding:8px 0;color:#666;font-size:13px;width:130px;">' + label + '</td>'
+ '<td style="padding:8px 0;color:' + (color || '#fff') + ';font-size:13px;font-weight:600;">' + value + '</td></tr>';
}
/* ================================================================
AUDIT LOG
================================================================ */
function logAction_(action, bookingId, student, instructor, date, time, status, details) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const logSheet = ss.getSheetByName('Booking Log');
if (!logSheet) return;
logSheet.appendRow([new Date(), action, bookingId, student, instructor, date, time, status, details]);
} catch (_) {}
}
/* ================================================================
INSTRUCTOR HELPERS
================================================================ */
function getInstructorCar_(ss, name) {
const instSheet = ss.getSheetByName(CONFIG.INSTRUCTORS_TAB);
if (!instSheet || instSheet.getLastRow() < 2) return 'N/A';
const data = instSheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]).trim() === name) return String(data[i][1] || '').trim() || 'N/A';
}
return 'N/A';
}
function getInstructorEmail_(ss, name) {
const instSheet = ss.getSheetByName(CONFIG.INSTRUCTORS_TAB);
if (!instSheet || instSheet.getLastRow() < 2) return '';
const data = instSheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]).trim() === name) return String(data[i][2] || '').trim();
}
return '';
}
/* ================================================================
SHARED HELPERS
================================================================ */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function esc_(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function sanitize_(str) {
return String(str || '').replace(/[<>{}()\[\]\\\/]/g, '').substring(0, 200).trim();
}
function parseDate_(val) {
if (val instanceof Date) return isNaN(val.getTime()) ? null : val;
if (val == null || val === '') return null;
const d = new Date(val);
return isNaN(d.getTime()) ? null : d;
}
function fmtDate_(val) {
const tz = CONFIG.TIMEZONE;
if (val instanceof Date) return isNaN(val.getTime()) ? '' : Utilities.formatDate(val, tz, 'MM/dd/yyyy');
const d = new Date(val);
return isNaN(d.getTime()) ? String(val || '').trim() : Utilities.formatDate(d, tz, 'MM/dd/yyyy');
}
function levenshtein_(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0]; dp[0] = j;
for (let i = 1; i <= m; i++) {
const temp = dp[i];
dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
prev = temp;
}
}
return dp[m];
}
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try { MailApp.sendEmail(CONFIG.ADMIN_EMAIL, subject, body, { name: CONFIG.SCHOOL_NAME }); } catch (_) {}
}
/* ================================================================
MANUAL TOOLS
================================================================ */
function testDemoBooking() {
const d = DEMO.booking;
Logger.log('๐ญ Demo booking:');
Logger.log(' ' + d.student + ' with ' + d.instructor + ' on ' + d.date + ' at ' + d.time);
Logger.log(' Car: ' + d.car + ' | Booking ID: ' + d.id);
Logger.log(' Utilization: ' + JSON.stringify(DEMO.weekSummary.utilization));
}
function manualBook() {
const result = bookLesson('Anisha', 'Test Student', '[email protected]', '555-0123', '03/01/2026', '10:00 AM', 'Test booking');
Logger.log(JSON.stringify(result));
}
/**
* =========================================================
* LEAD FOLLOW-UP SYSTEM
* Flavors Driving School
* =========================================================
* Auto follows up with unconfirmed 5-Hour Class signups.
*
* - 1st follow-up: 48 hours after signup (friendly reminder)
* - 2nd follow-up: 5 days after signup (urgency nudge)
* - 3rd follow-up: 10 days after signup (last chance + offer)
* - Auto-confirm: cross-checks Registration sheet
* - Multi-carrier SMS (16 carriers) + email fallback
* - Mission Control themed HTML emails
* - Admin summary after each run
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
SIGNUPS_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
// โโ Sheet tabs โโ
SIGNUPS_TAB: 'Form Responses 1',
REGISTRATION_TAB: '', // first sheet
// โโ Admin โโ
ADMIN_EMAIL: '[email protected]',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_PHONE: '', // fill in when available
// โโ Demo Mode โโ
DEMO_MODE: true,
// โโ Timing โโ
FOLLOWUP_1_HOURS: 48,
FOLLOWUP_2_DAYS: 5,
FOLLOWUP_3_DAYS: 10,
// โโ Cooldown (prevent double-sends if trigger fires twice) โโ
COOLDOWN_HOURS: 12,
// โโ SMS Carrier Gateways โโ
CARRIER_GATEWAYS: {
'verizon': 'vtext.com',
'att': 'txt.att.net',
'at&t': 'txt.att.net',
'tmobile': 'tmomail.net',
't-mobile': 'tmomail.net',
'sprint': 'messaging.sprintpcs.com',
'metro': 'mymetropcs.com',
'metropcs': 'mymetropcs.com',
'boost': 'sms.myboostmobile.com',
'cricket': 'sms.cricketwireless.net',
'uscellular': 'email.uscc.net',
'mint': 'mailmymobile.net',
'visible': 'visible.com',
'xfinity': 'vtext.com',
'googlefi': 'msg.fi.google.com',
'google fi': 'msg.fi.google.com',
'spectrum': 'vtext.com',
'consumer': 'mailmymobile.net'
},
DEFAULT_GATEWAY: 'vtext.com',
TIMEZONE: 'America/New_York'
};
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
AUTH & SETUP
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function forceAuth() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
Logger.log('Signups sheet: ' + ss.getName());
const reg = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
Logger.log('Registration sheet: ' + reg.getName());
MailApp.getRemainingDailyQuota();
Logger.log('MailApp authorized. Remaining quota: ' + MailApp.getRemainingDailyQuota());
Logger.log('forceAuth complete โ
');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SIGNUPS_TAB);
if (!sheet) {
Logger.log('ERROR: Tab "' + CONFIG.SIGNUPS_TAB + '" not found');
return;
}
// Ensure tracking columns exist
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const neededCols = ['Follow-Up Stage', 'Status', 'Follow-Up 1 Date', 'Follow-Up 2 Date', 'Follow-Up 3 Date', 'Carrier', 'Unsubscribe'];
let nextCol = headers.length + 1;
for (const col of neededCols) {
if (findCol_(headers, col) === -1) {
sheet.getRange(1, nextCol).setValue(col).setFontWeight('bold');
Logger.log('Added column: ' + col + ' at column ' + nextCol);
nextCol++;
}
}
// โโ Follow-Up Log sheet โโ
let logSheet = ss.getSheetByName('Follow-Up Log');
if (!logSheet) {
logSheet = ss.insertSheet('Follow-Up Log');
logSheet.getRange(1, 1, 1, 8).setValues([[
'Timestamp', 'Student Name', 'Email', 'Phone', 'Stage', 'Channel', 'Status', 'Notes'
]]);
logSheet.getRange(1, 1, 1, 8).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
logSheet.setFrozenRows(1);
Logger.log('Created Follow-Up Log sheet โ
');
}
// โโ Settings sheet โโ
let settingsSheet = ss.getSheetByName('Settings');
if (!settingsSheet) {
settingsSheet = ss.insertSheet('Settings');
settingsSheet.getRange(1, 1, 1, 3).setValues([['Setting', 'Value', 'Description']]);
settingsSheet.getRange(1, 1, 1, 3).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
const settings = [
['DEMO_MODE', 'true', 'Set to false for production'],
['ADMIN_EMAIL', CONFIG.ADMIN_EMAIL, 'Admin receives run summaries'],
['FOLLOWUP_1_HOURS', '48', 'Hours before 1st follow-up'],
['FOLLOWUP_2_DAYS', '5', 'Days before 2nd follow-up'],
['FOLLOWUP_3_DAYS', '10', 'Days before 3rd follow-up (last chance)'],
['COOLDOWN_HOURS', '12', 'Minimum hours between sends to same student'],
['SCHOOL_PHONE', '', 'School phone number for emails'],
['NEXT_CLASS_DATE', '', 'Next Saturday class date (shows in emails)']
];
settingsSheet.getRange(2, 1, settings.length, 3).setValues(settings);
settingsSheet.autoResizeColumns(1, 3);
Logger.log('Created Settings sheet โ
');
}
// โโ Triggers (clean duplicates first) โโ
const triggers = ScriptApp.getProjectTriggers();
const wantedFns = ['sendLeadFollowUps'];
for (const t of triggers) {
if (wantedFns.includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
Logger.log('Removed old trigger: ' + t.getHandlerFunction());
}
}
// Daily at 9 AM
ScriptApp.newTrigger('sendLeadFollowUps')
.timeBased()
.everyDays(1)
.atHour(9)
.create();
Logger.log('Created trigger: sendLeadFollowUps (daily 9 AM) โ
');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
Logger.log('fullSetup complete โ
');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
HELPERS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function findCol_(headers, label) {
for (let i = 0; i < headers.length; i++) {
if (String(headers[i]).toLowerCase().includes(String(label).toLowerCase())) return i;
}
return -1;
}
function escHtml_(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function sanitize_(s, maxLen) {
return String(s || '').replace(/[^\w\s@.\-\/,#$()&+:;'"!?]/g, '').substring(0, maxLen || 200);
}
function getSetting_(key, fallback) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName('Settings');
if (!sheet) return fallback;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]).trim().toUpperCase() === key.toUpperCase()) {
return String(data[i][1]).trim();
}
}
} catch (e) { /* ignore */ }
return fallback;
}
function isDemoMode_() {
return getSetting_('DEMO_MODE', 'true').toLowerCase() === 'true';
}
function getAdminEmail_() {
return getSetting_('ADMIN_EMAIL', CONFIG.ADMIN_EMAIL);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
AUTO-CONFIRM FROM REGISTRATION
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getRegisteredEmails_() {
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = ss.getSheets()[0];
if (sheet.getLastRow() < 2) return new Set();
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const cEmail = findCol_(headers, 'email');
if (cEmail === -1) return new Set();
const data = sheet.getRange(2, cEmail + 1, sheet.getLastRow() - 1, 1).getValues();
const emails = new Set();
for (const row of data) {
const e = String(row[0] || '').trim().toLowerCase();
if (e && e.includes('@')) emails.add(e);
}
return emails;
} catch (e) {
Logger.log('Could not read Registration sheet: ' + e.message);
return new Set();
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
MAIN FOLLOW-UP ENGINE
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendLeadFollowUps() {
const demo = isDemoMode_();
const now = new Date();
if (demo) {
Logger.log('โโ DEMO MODE โ no real emails will be sent โโ');
}
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SIGNUPS_TAB);
if (!sheet || sheet.getLastRow() < 2) {
Logger.log('No signups to process');
return;
}
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
// Find columns dynamically
const cTimestamp = findCol_(headers, 'timestamp');
const cName = findCol_(headers, 'name');
const cPhone = findCol_(headers, 'phone');
const cEmail = findCol_(headers, 'email');
const cStage = findCol_(headers, 'follow-up stage');
const cStatus = findCol_(headers, 'status');
const cFU1 = findCol_(headers, 'follow-up 1 date');
const cFU2 = findCol_(headers, 'follow-up 2 date');
const cFU3 = findCol_(headers, 'follow-up 3 date');
const cCarrier = findCol_(headers, 'carrier');
const cUnsub = findCol_(headers, 'unsubscribe');
if (cTimestamp === -1 || cName === -1) {
Logger.log('ERROR: Missing required columns (Timestamp, Name)');
return;
}
// Get timing settings
const fu1Ms = parseInt(getSetting_('FOLLOWUP_1_HOURS', '48'), 10) * 60 * 60 * 1000;
const fu2Ms = parseInt(getSetting_('FOLLOWUP_2_DAYS', '5'), 10) * 24 * 60 * 60 * 1000;
const fu3Ms = parseInt(getSetting_('FOLLOWUP_3_DAYS', '10'), 10) * 24 * 60 * 60 * 1000;
const cooldownMs = parseInt(getSetting_('COOLDOWN_HOURS', '12'), 10) * 60 * 60 * 1000;
// Get registered emails for auto-confirm
const registeredEmails = getRegisteredEmails_();
// Get next class date for emails
const nextClassDate = getSetting_('NEXT_CLASS_DATE', '');
// Track stats
const stats = { processed: 0, fu1Sent: 0, fu2Sent: 0, fu3Sent: 0, autoConfirmed: 0, skipped: 0, errors: 0 };
// Get Follow-Up Log for cooldown check
const logSheet = ss.getSheetByName('Follow-Up Log');
const recentSends = new Map();
if (logSheet && logSheet.getLastRow() > 1) {
const logData = logSheet.getRange(2, 1, logSheet.getLastRow() - 1, 3).getValues();
for (const row of logData) {
const logTime = row[0] instanceof Date ? row[0] : new Date(row[0]);
const logEmail = String(row[2] || '').trim().toLowerCase();
if (logEmail && logTime > new Date(now.getTime() - cooldownMs)) {
recentSends.set(logEmail, logTime);
}
}
}
// Batch updates array
const updates = [];
for (let i = 1; i < allData.length; i++) {
const row = allData[i];
const name = sanitize_(String(row[cName] || '').trim(), 100);
if (!name) continue;
const timestamp = row[cTimestamp] instanceof Date ? row[cTimestamp] : new Date(row[cTimestamp]);
if (isNaN(timestamp.getTime())) continue;
const email = cEmail > -1 ? String(row[cEmail] || '').trim().toLowerCase() : '';
const phone = cPhone > -1 ? String(row[cPhone] || '').replace(/[^\d]/g, '') : '';
const carrier = cCarrier > -1 ? String(row[cCarrier] || '').trim().toLowerCase() : '';
const stage = cStage > -1 ? String(row[cStage] || '').trim() : '';
const status = cStatus > -1 ? String(row[cStatus] || '').trim() : '';
const unsub = cUnsub > -1 ? String(row[cUnsub] || '').trim().toUpperCase() : '';
// Skip if confirmed, unsubscribed, or completed all follow-ups
if (status === 'Confirmed' || unsub === 'YES' || unsub === 'TRUE' || stage === 'DONE') continue;
stats.processed++;
const timeDiff = now.getTime() - timestamp.getTime();
const rowNum = i + 1;
// Auto-confirm if they registered
if (email && registeredEmails.has(email)) {
updates.push({ row: rowNum, col: cStatus > -1 ? cStatus + 1 : null, val: 'Confirmed (Auto-Detected)' });
updates.push({ row: rowNum, col: cStage > -1 ? cStage + 1 : null, val: 'DONE' });
stats.autoConfirmed++;
logFollowUp_(logSheet, now, name, email, phone, 'Auto-Confirm', 'success', 'Found in Registration sheet');
continue;
}
// Cooldown check
if (email && recentSends.has(email)) {
stats.skipped++;
continue;
}
// No contact info
if (!email && phone.length < 10) {
if (cStatus > -1) updates.push({ row: rowNum, col: cStatus + 1, val: 'No Contact Info' });
stats.skipped++;
continue;
}
const firstName = escHtml_(name.split(' ')[0]);
const safeName = escHtml_(name);
// โโ 1st Follow-Up: 48 hours โโ
if (!stage && timeDiff >= fu1Ms) {
const subject = '๐ Confirm Your 5-Hour Class Spot โ ' + CONFIG.SCHOOL_NAME;
const body = buildFollowUp1_(firstName, safeName, nextClassDate);
const result = demo ? 'demo' : sendFollowUp_(email, phone, carrier, subject, body);
if (result) {
if (cStage > -1) updates.push({ row: rowNum, col: cStage + 1, val: '1ST' });
if (cStatus > -1) updates.push({ row: rowNum, col: cStatus + 1, val: 'Followed Up โ Awaiting Response' });
if (cFU1 > -1) updates.push({ row: rowNum, col: cFU1 + 1, val: now });
stats.fu1Sent++;
logFollowUp_(logSheet, now, name, email, phone, '1st', result === 'demo' ? 'demo' : 'sent', '');
} else {
if (cStatus > -1) updates.push({ row: rowNum, col: cStatus + 1, val: 'Send Failed โ Will Retry' });
stats.errors++;
logFollowUp_(logSheet, now, name, email, phone, '1st', 'failed', 'MailApp error');
}
continue;
}
// โโ 2nd Follow-Up: 5 days โโ
if (stage === '1ST' && timeDiff >= fu2Ms) {
const subject = 'โฐ Last Few Spots โ Your 5-Hour Class';
const body = buildFollowUp2_(firstName, nextClassDate);
const result = demo ? 'demo' : sendFollowUp_(email, phone, carrier, subject, body);
if (result) {
if (cStage > -1) updates.push({ row: rowNum, col: cStage + 1, val: '2ND' });
if (cStatus > -1) updates.push({ row: rowNum, col: cStatus + 1, val: '2nd Follow-Up โ Final Nudge' });
if (cFU2 > -1) updates.push({ row: rowNum, col: cFU2 + 1, val: now });
stats.fu2Sent++;
logFollowUp_(logSheet, now, name, email, phone, '2nd', result === 'demo' ? 'demo' : 'sent', '');
} else {
stats.errors++;
logFollowUp_(logSheet, now, name, email, phone, '2nd', 'failed', 'MailApp error');
}
continue;
}
// โโ 3rd Follow-Up: 10 days (last chance) โโ
if (stage === '2ND' && timeDiff >= fu3Ms) {
const subject = '๐ Last Chance โ Special Offer on 5-Hour Class';
const body = buildFollowUp3_(firstName, nextClassDate);
const result = demo ? 'demo' : sendFollowUp_(email, phone, carrier, subject, body);
if (result) {
if (cStage > -1) updates.push({ row: rowNum, col: cStage + 1, val: 'DONE' });
if (cStatus > -1) updates.push({ row: rowNum, col: cStatus + 1, val: 'All Follow-Ups Complete' });
if (cFU3 > -1) updates.push({ row: rowNum, col: cFU3 + 1, val: now });
stats.fu3Sent++;
logFollowUp_(logSheet, now, name, email, phone, '3rd', result === 'demo' ? 'demo' : 'sent', 'Last chance offer');
} else {
stats.errors++;
logFollowUp_(logSheet, now, name, email, phone, '3rd', 'failed', 'MailApp error');
}
continue;
}
}
// Apply all updates
for (const u of updates) {
if (u.col) sheet.getRange(u.row, u.col).setValue(u.val);
}
// Send admin summary
sendAdminSummary_(stats, demo);
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
Logger.log('Lead follow-up complete');
Logger.log('Processed: ' + stats.processed + ' | 1st: ' + stats.fu1Sent + ' | 2nd: ' + stats.fu2Sent +
' | 3rd: ' + stats.fu3Sent + ' | Auto-confirmed: ' + stats.autoConfirmed +
' | Skipped: ' + stats.skipped + ' | Errors: ' + stats.errors);
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
SEND FOLLOW-UP (EMAIL + SMS)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendFollowUp_(email, phone, carrier, subject, htmlBody) {
let sent = false;
try {
// Email
if (email && email.includes('@')) {
MailApp.sendEmail({
to: email,
subject: subject,
htmlBody: buildEmailHtml_(subject, htmlBody)
});
sent = true;
}
// SMS via carrier gateway
if (phone && phone.length >= 10) {
const gateway = CONFIG.CARRIER_GATEWAYS[carrier] || CONFIG.DEFAULT_GATEWAY;
const smsAddress = phone + '@' + gateway;
// SMS gets plain text version (strip HTML)
const plainText = htmlBody.replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/\s+/g, ' ').trim().substring(0, 160);
MailApp.sendEmail({
to: smsAddress,
subject: '',
body: plainText
});
sent = true;
}
return sent ? 'sent' : false;
} catch (e) {
Logger.log('Send error: ' + e.message);
return false;
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FOLLOW-UP EMAIL TEMPLATES
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function buildFollowUp1_(firstName, fullName, nextDate) {
const dateInfo = nextDate
? '<p style="color:#ff2d2d;font-weight:bold;text-align:center;font-size:16px;">๐
Next class: ' + escHtml_(nextDate) + '</p>'
: '';
return '<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">๐</span>' +
'</div>' +
'<p style="color:#ccc;">Hi ' + firstName + '!</p>' +
'<p style="color:#ccc;">We noticed you signed up for the <strong style="color:#fff;">5-Hour Pre-Licensing Class</strong> but haven\'t confirmed your spot yet.</p>' +
'<p style="color:#ccc;">This class is <strong style="color:#fff;">required by NY State</strong> before you can take your road test โ and our Saturday classes fill up fast!</p>' +
dateInfo +
'<div style="text-align:center;margin:24px 0;">' +
'<div style="display:inline-block;background:#ff2d2d;color:#fff;padding:12px 32px;border-radius:8px;font-weight:bold;font-size:16px;">' +
'Reply to confirm your spot โ' +
'</div>' +
'</div>' +
'<p style="color:#999;">Just reply to this email or give us a call to lock in your date. We\'d love to have you!</p>' +
buildUnsubscribeText_();
}
function buildFollowUp2_(firstName, nextDate) {
const dateInfo = nextDate
? '<p style="color:#ffaa00;font-weight:bold;text-align:center;">๐
Next available: ' + escHtml_(nextDate) + ' โ only a few seats left</p>'
: '';
return '<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">โฐ</span>' +
'</div>' +
'<p style="color:#ccc;">Hey ' + firstName + '!</p>' +
'<p style="color:#ccc;">Quick check-in โ we still have a spot open for you in our <strong style="color:#fff;">5-Hour Pre-Licensing Class</strong>.</p>' +
'<p style="color:#ccc;">Just a heads up: this is a <strong style="color:#fff;">required course</strong> to get your license, and we typically have <strong style="color:#ffaa00;">limited seats</strong> each Saturday.</p>' +
dateInfo +
'<table style="width:100%;border-collapse:collapse;margin:20px 0;background:#1a1a1a;border-radius:8px;">' +
'<tr><td style="padding:16px;text-align:center;">' +
'<div style="color:#ff2d2d;font-size:20px;font-weight:bold;">$65</div>' +
'<div style="color:#999;font-size:12px;">One-time โข Certificate included</div>' +
'</td></tr>' +
'</table>' +
'<p style="color:#999;">Let us know if you\'d like to lock in your date โ just hit reply!</p>' +
buildUnsubscribeText_();
}
function buildFollowUp3_(firstName, nextDate) {
return '<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">๐</span>' +
'</div>' +
'<p style="color:#ccc;">Hey ' + firstName + '!</p>' +
'<p style="color:#ccc;">This is our last reach-out โ we don\'t want to bug you, but we also don\'t want you to miss out.</p>' +
'<p style="color:#ccc;">You signed up for the 5-Hour Pre-Licensing Class and your spot is still available. Once you complete this class, you\'re one step closer to your license! ๐</p>' +
'<table style="width:100%;border-collapse:collapse;margin:20px 0;background:linear-gradient(135deg,#1a0000,#0d0d0d);border:1px solid rgba(255,45,45,0.3);border-radius:12px;">' +
'<tr><td style="padding:24px;text-align:center;">' +
'<div style="color:#ff2d2d;font-size:14px;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;">Special Offer</div>' +
'<div style="color:#fff;font-size:18px;font-weight:bold;">Book your 5-Hour Class + first driving lesson</div>' +
'<div style="color:#ffaa00;font-size:14px;margin-top:8px;">and get <strong>$10 off</strong> your first lesson package ๐</div>' +
'<div style="color:#666;font-size:11px;margin-top:12px;">Just mention this email when you call</div>' +
'</td></tr>' +
'</table>' +
'<p style="color:#999;">Reply to this email or call us anytime. Either way โ good luck on your driving journey!</p>' +
buildUnsubscribeText_();
}
function buildUnsubscribeText_() {
return '<p style="color:#444;font-size:10px;text-align:center;margin-top:24px;border-top:1px solid rgba(255,255,255,0.05);padding-top:12px;">' +
'Don\'t want to hear from us? Reply "STOP" and we\'ll remove you from follow-ups.</p>';
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FOLLOW-UP LOG
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function logFollowUp_(logSheet, timestamp, name, email, phone, stage, status, notes) {
if (!logSheet) return;
try {
logSheet.appendRow([timestamp, name, email, phone, stage, status === 'demo' ? 'Demo โ not sent' : status, status, notes]);
} catch (e) { /* silent */ }
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
MARK CONFIRMED
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
/** Mark a student as confirmed by name (case-insensitive) or email */
function markConfirmed(studentNameOrEmail) {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SIGNUPS_TAB);
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cName = findCol_(headers, 'name');
const cEmail = findCol_(headers, 'email');
const cStatus = findCol_(headers, 'status');
const cStage = findCol_(headers, 'follow-up stage');
const search = String(studentNameOrEmail || '').trim().toLowerCase();
let found = 0;
for (let i = 1; i < allData.length; i++) {
const name = cName > -1 ? String(allData[i][cName] || '').trim().toLowerCase() : '';
const email = cEmail > -1 ? String(allData[i][cEmail] || '').trim().toLowerCase() : '';
if (name === search || email === search) {
const rowNum = i + 1;
if (cStatus > -1) sheet.getRange(rowNum, cStatus + 1).setValue('Confirmed');
if (cStage > -1) sheet.getRange(rowNum, cStage + 1).setValue('DONE');
Logger.log('Confirmed: ' + allData[i][cName]);
found++;
}
}
Logger.log(found > 0 ? 'Marked ' + found + ' row(s) as confirmed' : 'Student not found: ' + studentNameOrEmail);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
VIEW STATS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function viewLeadStatuses() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SIGNUPS_TAB);
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cName = findCol_(headers, 'name');
const cStatus = findCol_(headers, 'status');
const cStage = findCol_(headers, 'follow-up stage');
const counts = { 'Pending': 0, 'Followed Up': 0, '2nd Follow-Up': 0, 'All Complete': 0, 'Confirmed': 0, 'No Contact': 0, 'Failed': 0, 'Other': 0 };
Logger.log('โโโ LEAD STATUS REPORT โโโ');
for (let i = 1; i < allData.length; i++) {
const name = cName > -1 ? String(allData[i][cName] || '').trim() : '';
if (!name) continue;
const status = cStatus > -1 ? String(allData[i][cStatus] || 'Pending').trim() : 'Pending';
let bucket = 'Other';
if (status.includes('Confirmed')) bucket = 'Confirmed';
else if (status.includes('All Follow')) bucket = 'All Complete';
else if (status.includes('2nd')) bucket = '2nd Follow-Up';
else if (status.includes('Followed Up') || status.includes('Awaiting')) bucket = 'Followed Up';
else if (status.includes('No Contact')) bucket = 'No Contact';
else if (status.includes('Failed') || status.includes('Retry')) bucket = 'Failed';
else if (status === 'Pending' || status === '') bucket = 'Pending';
counts[bucket] = (counts[bucket] || 0) + 1;
Logger.log(name + ' โ ' + status);
}
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโ');
for (const [k, v] of Object.entries(counts)) {
if (v > 0) Logger.log(k + ': ' + v);
}
}
/** API for BI Dashboard */
function getLeadStats() {
const demo = isDemoMode_();
if (demo) {
return {
demo: true,
total: 15,
pending: 3,
followedUp: 4,
confirmed: 6,
noContact: 1,
complete: 1,
conversionRate: '40%'
};
}
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = ss.getSheetByName(CONFIG.SIGNUPS_TAB);
if (!sheet || sheet.getLastRow() < 2) return { total: 0 };
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cName = findCol_(headers, 'name');
const cStatus = findCol_(headers, 'status');
let total = 0, confirmed = 0, pending = 0, followedUp = 0, noContact = 0;
for (let i = 1; i < allData.length; i++) {
if (cName > -1 && !String(allData[i][cName] || '').trim()) continue;
total++;
const status = cStatus > -1 ? String(allData[i][cStatus] || '').trim() : '';
if (status.includes('Confirmed')) confirmed++;
else if (status.includes('Followed') || status.includes('Awaiting') || status.includes('2nd') || status.includes('All Follow')) followedUp++;
else if (status.includes('No Contact')) noContact++;
else pending++;
}
return {
demo: false,
total: total,
pending: pending,
followedUp: followedUp,
confirmed: confirmed,
noContact: noContact,
conversionRate: total > 0 ? Math.round(confirmed / total * 100) + '%' : '0%'
};
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ADMIN SUMMARY EMAIL
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function sendAdminSummary_(stats, demo) {
const total = stats.fu1Sent + stats.fu2Sent + stats.fu3Sent;
if (total === 0 && stats.autoConfirmed === 0 && stats.errors === 0) {
Logger.log('No actions taken โ skipping admin summary');
return;
}
const body =
'<div style="text-align:center;margin-bottom:20px;">' +
'<span style="font-size:48px;">๐</span>' +
'<h2 style="color:#ff2d2d;margin:12px 0 4px;">Lead Follow-Up Summary</h2>' +
'<p style="color:#999;margin:0;">' + Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'EEEE, MMM d, yyyy โ h:mm a') + '</p>' +
'</div>' +
'<table style="width:100%;border-collapse:collapse;margin:16px 0;">' +
buildRow_('Leads Processed', stats.processed) +
buildRow_('1st Follow-Up Sent', stats.fu1Sent > 0 ? '<span style="color:#4CAF50;">' + stats.fu1Sent + '</span>' : '0') +
buildRow_('2nd Follow-Up Sent', stats.fu2Sent > 0 ? '<span style="color:#ffaa00;">' + stats.fu2Sent + '</span>' : '0') +
buildRow_('3rd Follow-Up Sent', stats.fu3Sent > 0 ? '<span style="color:#ff2d2d;">' + stats.fu3Sent + '</span>' : '0') +
buildRow_('Auto-Confirmed', stats.autoConfirmed > 0 ? '<span style="color:#4CAF50;">โ
' + stats.autoConfirmed + '</span>' : '0') +
buildRow_('Skipped (cooldown/no info)', stats.skipped) +
buildRow_('Errors', stats.errors > 0 ? '<span style="color:#ff2d2d;">โ ๏ธ ' + stats.errors + '</span>' : '0') +
'</table>';
const subject = (demo ? '[DEMO] ' : '') + '๐ Lead Follow-Up: ' + total + ' sent, ' + stats.autoConfirmed + ' auto-confirmed';
if (!demo) {
try {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: subject,
htmlBody: buildEmailHtml_('Lead Follow-Up Summary', body)
});
} catch (e) {
Logger.log('Admin summary email failed: ' + e.message);
}
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DEMO DATA
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function generateDemoLeads() {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const logSheet = ss.getSheetByName('Follow-Up Log');
if (!logSheet) {
Logger.log('Run fullSetup() first');
return;
}
const now = new Date();
const demoData = [
{ name: 'Sarah Johnson', email: '[email protected]', phone: '9175551001', daysAgo: 1 }, // too early
{ name: 'Mike Rivera', email: '[email protected]', phone: '7185551002', daysAgo: 3 }, // ready for 1st
{ name: 'Emily Chen', email: '[email protected]', phone: '3475551003', daysAgo: 6 }, // ready for 2nd
{ name: 'James Wilson', email: '[email protected]', phone: '6465551004', daysAgo: 11 }, // ready for 3rd
{ name: 'Ana Martinez', email: '[email protected]', phone: '9295551005', daysAgo: 4 }, // ready for 1st
];
const demoRows = demoData.map(d => {
const ts = new Date(now.getTime() - d.daysAgo * 24 * 60 * 60 * 1000);
return [ts, d.name, d.phone, d.email, '', '', ts];
});
Logger.log('Generated ' + demoRows.length + ' demo leads');
Logger.log('Run sendLeadFollowUps() to see them process (in demo mode โ no real emails)');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
EMAIL TEMPLATE โ Mission Control Theme
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function buildEmailHtml_(title, bodyContent) {
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;"><tr><td align="center" style="padding:20px;">' +
'<table width="600" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border:1px solid rgba(255,45,45,0.2);border-radius:12px;overflow:hidden;">' +
// Header
'<tr><td style="background:linear-gradient(135deg,#1a0000,#0d0d0d);padding:24px 32px;border-bottom:1px solid rgba(255,45,45,0.15);">' +
'<table width="100%"><tr>' +
'<td style="color:#ff2d2d;font-size:20px;font-weight:bold;">๐ซ ' + escHtml_(CONFIG.SCHOOL_NAME) + '</td>' +
'</tr></table>' +
'</td></tr>' +
// Title bar
'<tr><td style="padding:20px 32px 0;">' +
'<h1 style="color:#fff;font-size:22px;margin:0 0 4px;font-weight:600;">' + title + '</h1>' +
'<div style="width:40px;height:3px;background:#ff2d2d;border-radius:2px;"></div>' +
'</td></tr>' +
// Body
'<tr><td style="padding:20px 32px 32px;">' + bodyContent + '</td></tr>' +
// Footer
'<tr><td style="padding:20px 32px;border-top:1px solid rgba(255,255,255,0.05);text-align:center;">' +
'<p style="color:#444;font-size:11px;margin:0;">' + CONFIG.SCHOOL_NAME + ' โ Lead Follow-Up System</p>' +
'<p style="color:#333;font-size:10px;margin:4px 0 0;">Automated by Mission Control</p>' +
'</td></tr>' +
'</table>' +
'</td></tr></table></body></html>';
}
function buildRow_(label, value) {
return '<tr>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#999;width:50%;">' + label + '</td>' +
'<td style="padding:10px 12px;border-bottom:1px solid rgba(255,255,255,0.05);color:#fff;font-weight:bold;">' + value + '</td>' +
'</tr>';
}
/**
* =========================================================
* MONTHLY REVENUE REPORT
* Flavors Driving School
* =========================================================
* Comprehensive monthly business report pulling from all
* data sources: Signups, Registration, Payments, Expenses.
*
* - Revenue from actual payments (Payment Tracker) + estimates
* - Expense breakdown by category with budget comparison
* - Net profit with month-over-month trends
* - Student activity summary
* - YTD running totals
* - 3-month trend indicators
* - Report History sheet for lookback
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
SIGNUPS_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
EXPENSE_SHEET_ID: '1QyC39fjuslDXk-a_u09XACyHSq782H7NUJajV3vBp8M',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
// โโ Sheet tabs โโ
SIGNUPS_TAB: 'Form Responses 1',
REGISTRATION_TAB: '', // first sheet
EXPENSE_LOG_TAB: 'Expense Log',
PAYMENTS_TAB: 'Payments',
// โโ Admin โโ
ADMIN_EMAIL: '[email protected]',
SCHOOL_NAME: 'Flavors Driving School',
// โโ Demo Mode โโ
DEMO_MODE: true,
// โโ Pricing โโ
FIVE_HOUR_CLASS_PRICE: 65,
LESSON_PACKAGES: {
'3 Lessons': 250,
'5 Lessons': 445,
'10 Lessons': 710,
'15 Lessons': 950,
'25 Lessons': 1500
},
TIMEZONE: 'America/New_York'
};
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
AUTH & SETUP
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function forceAuth() {
const ss1 = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
Logger.log('Signups sheet: ' + ss1.getName());
const ss2 = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
Logger.log('Registration sheet: ' + ss2.getName());
const ss3 = SpreadsheetApp.openById(CONFIG.EXPENSE_SHEET_ID);
Logger.log('Expense sheet: ' + ss3.getName());
const ss4 = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
Logger.log('Payment Tracker: ' + ss4.getName());
MailApp.getRemainingDailyQuota();
Logger.log('MailApp authorized. Remaining quota: ' + MailApp.getRemainingDailyQuota());
Logger.log('forceAuth complete โ
');
}
function fullSetup() {
// โโ Report History sheet (in Registration spreadsheet) โโ
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let histSheet = ss.getSheetByName('Report History');
if (!histSheet) {
histSheet = ss.insertSheet('Report History');
histSheet.getRange(1, 1, 1, 10).setValues([[
'Report Month', 'Generated Date', 'Total Revenue', 'Total Expenses',
'Net Profit', 'New Students', '5-Hour Signups', 'Registrations',
'Payments Received', 'YTD Revenue'
]]);
histSheet.getRange(1, 1, 1, 10).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
histSheet.setFrozenRows(1);
Logger.log('Created Report History sheet โ
');
} else {
Logger.log('Report History sheet already exists โ
');
}
// โโ Settings sheet โโ
let settingsSheet = ss.getSheetByName('Revenue Report Settings');
if (!settingsSheet) {
settingsSheet = ss.insertSheet('Revenue Report Settings');
settingsSheet.getRange(1, 1, 1, 3).setValues([['Setting', 'Value', 'Description']]);
settingsSheet.getRange(1, 1, 1, 3).setFontWeight('bold').setBackground('#0d0d0d').setFontColor('#ff2d2d');
const settings = [
['DEMO_MODE', 'true', 'Set to false for production'],
['ADMIN_EMAIL', CONFIG.ADMIN_EMAIL, 'Report recipient(s) โ comma-separated for multiple'],
['REPORT_DAY', '1', 'Day of month to send report (1-28)'],
['REPORT_HOUR', '8', 'Hour to send report (0-23)'],
['INCLUDE_STUDENT_LIST', 'true', 'Show individual student names in report'],
['INCLUDE_YTD', 'true', 'Include year-to-date totals']
];
settingsSheet.getRange(2, 1, settings.length, 3).setValues(settings);
settingsSheet.autoResizeColumns(1, 3);
Logger.log('Created Revenue Report Settings sheet โ
');
} else {
Logger.log('Revenue Report Settings already exists โ
');
}
// โโ Triggers (clean duplicates) โโ
const triggers = ScriptApp.getProjectTriggers();
for (const t of triggers) {
if (['generateMonthlyReport', 'setupMonthlyTrigger'].includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
Logger.log('Removed old trigger: ' + t.getHandlerFunction());
}
}
ScriptApp.newTrigger('generateMonthlyReport')
.timeBased()
.onMonthDay(1)
.atHour(8)
.create();
Logger.log('Created trigger: generateMonthlyReport (1st of month 8 AM) โ
');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
Logger.log('fullSetup complete โ
');
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
HELPERS
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function findCol_(headers, label) {
for (let i = 0; i < headers.length; i++) {
if (String(headers[i]).toLowerCase().includes(String(label).toLowerCase())) return i;
}
return -1;
}
function escHtml_(s) {
return String(s || '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatMoney_(n) {
const num = Number(n) || 0;
const abs = Math.abs(num);
const formatted = abs.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return (num < 0 ? '-$' : '$') + formatted;
}
function pctChange_(current, previous) {
if (previous === 0) return current > 0 ? '+100%' : 'โ';
const pct = Math.round(((current - previous) / Math.abs(previous)) * 100);
return (pct >= 0 ? '+' : '') + pct + '%';
}
function getSetting_(key, fallback) {
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = ss.getSheetByName('Revenue Report Settings');
if (!sheet) return fallback;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]).trim().toUpperCase() === key.toUpperCase()) {
return String(data[i][1]).trim();
}
}
} catch (e) { /* ignore */ }
return fallback;
}
function isDemoMode_() {
return getSetting_('DEMO_MODE', 'true').toLowerCase() === 'true';
}
function getAdminEmail_() {
return getSetting_('ADMIN_EMAIL', CONFIG.ADMIN_EMAIL);
}
function getSheetByTabOrFirst_(ss, tabName) {
if (tabName) {
const sheet = ss.getSheetByName(tabName);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DATA COLLECTION
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function get5HourClassData_(startDate, endDate) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sheet = getSheetByTabOrFirst_(ss, CONFIG.SIGNUPS_TAB);
if (sheet.getLastRow() < 2) return { count: 0, students: [] };
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cTs = findCol_(headers, 'timestamp');
const cName = findCol_(headers, 'name');
let count = 0;
const students = [];
for (let i = 1; i < allData.length; i++) {
const date = cTs > -1 ? new Date(allData[i][cTs]) : null;
if (!date || isNaN(date.getTime())) continue;
if (date >= startDate && date <= endDate) {
count++;
if (cName > -1) {
const name = String(allData[i][cName] || '').trim();
if (name) students.push(name);
}
}
}
return { count, students };
} catch (e) {
Logger.log('Error reading 5-Hour signups: ' + e.message);
return { count: 0, students: [], error: e.message };
}
}
function getRegistrationData_(startDate, endDate) {
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = getSheetByTabOrFirst_(ss, CONFIG.REGISTRATION_TAB);
if (sheet.getLastRow() < 2) return { count: 0, totalRevenue: 0, packages: {}, students: [] };
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cTs = findCol_(headers, 'timestamp');
const cName = findCol_(headers, 'name');
const cPackage = findCol_(headers, 'package');
let count = 0;
let totalRevenue = 0;
const packages = {};
const students = [];
for (let i = 1; i < allData.length; i++) {
const date = cTs > -1 ? new Date(allData[i][cTs]) : null;
if (!date || isNaN(date.getTime())) continue;
if (date < startDate || date > endDate) continue;
count++;
if (cName > -1) {
const name = String(allData[i][cName] || '').trim();
if (name) students.push(name);
}
if (cPackage > -1) {
const pkg = String(allData[i][cPackage] || '').trim();
let matched = false;
for (const [key, price] of Object.entries(CONFIG.LESSON_PACKAGES)) {
if (pkg.toLowerCase().includes(key.toLowerCase()) ||
(key.match(/\d+/) && pkg.includes(key.match(/\d+/)[0]))) {
packages[key] = (packages[key] || 0) + 1;
totalRevenue += price;
matched = true;
break;
}
}
if (!matched) {
const numMatch = pkg.match(/(\d+)/);
if (numMatch) {
const lessonCount = parseInt(numMatch[1], 10);
const matchKey = Object.keys(CONFIG.LESSON_PACKAGES).find(k => k.includes(String(lessonCount)));
if (matchKey) {
packages[matchKey] = (packages[matchKey] || 0) + 1;
totalRevenue += CONFIG.LESSON_PACKAGES[matchKey];
} else {
packages['Unknown: ' + pkg] = (packages['Unknown: ' + pkg] || 0) + 1;
}
} else {
packages['Unknown: ' + pkg] = (packages['Unknown: ' + pkg] || 0) + 1;
}
}
}
}
return { count, totalRevenue, packages, students };
} catch (e) {
Logger.log('Error reading registrations: ' + e.message);
return { count: 0, totalRevenue: 0, packages: {}, students: [], error: e.message };
}
}
function getExpenseData_(startDate, endDate) {
try {
const ss = SpreadsheetApp.openById(CONFIG.EXPENSE_SHEET_ID);
const sheet = getSheetByTabOrFirst_(ss, CONFIG.EXPENSE_LOG_TAB);
if (sheet.getLastRow() < 2) return { totalExpenses: 0, categories: {} };
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cDate = findCol_(headers, 'date of expense');
const cTs = cDate > -1 ? cDate : findCol_(headers, 'timestamp');
const cAmount = findCol_(headers, 'amount');
const cCategory = findCol_(headers, 'category');
const cStatus = findCol_(headers, 'status');
let totalExpenses = 0;
const categories = {};
for (let i = 1; i < allData.length; i++) {
// Skip voided/duplicate
if (cStatus > -1) {
const status = String(allData[i][cStatus] || '').toLowerCase();
if (['void', 'voided', 'duplicate', 'cancelled', 'canceled'].includes(status)) continue;
}
const date = cTs > -1 ? new Date(allData[i][cTs]) : null;
if (!date || isNaN(date.getTime())) continue;
if (date < startDate || date > endDate) continue;
const amount = cAmount > -1 ? (parseFloat(String(allData[i][cAmount]).replace(/[$,]/g, '')) || 0) : 0;
totalExpenses += amount;
const cat = cCategory > -1 ? String(allData[i][cCategory] || 'Uncategorized').trim() : 'Uncategorized';
categories[cat] = (categories[cat] || 0) + amount;
}
return { totalExpenses, categories };
} catch (e) {
Logger.log('Error reading expenses: ' + e.message);
return { totalExpenses: 0, categories: {}, error: e.message };
}
}
function getPaymentData_(startDate, endDate) {
try {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const sheet = getSheetByTabOrFirst_(ss, CONFIG.PAYMENTS_TAB);
if (sheet.getLastRow() < 2) return { totalReceived: 0, count: 0, methods: {} };
const allData = sheet.getDataRange().getValues();
const headers = allData[0];
const cDate = findCol_(headers, 'date');
const cTs = cDate > -1 ? cDate : findCol_(headers, 'timestamp');
const cAmount = findCol_(headers, 'amount');
const cMethod = findCol_(headers, 'method');
const cStatus = findCol_(headers, 'status');
const cType = findCol_(headers, 'type');
let totalReceived = 0;
let count = 0;
const methods = {};
for (let i = 1; i < allData.length; i++) {
// Skip refunds and voided
if (cStatus > -1) {
const status = String(allData[i][cStatus] || '').toLowerCase();
if (['voided', 'refunded', 'cancelled'].includes(status)) continue;
}
if (cType > -1) {
const type = String(allData[i][cType] || '').toLowerCase();
if (type === 'refund') continue;
}
const date = cTs > -1 ? new Date(allData[i][cTs]) : null;
if (!date || isNaN(date.getTime())) continue;
if (date < startDate || date > endDate) continue;
const amount = cAmount > -1 ? (parseFloat(String(allData[i][cAmount]).replace(/[$,]/g, '')) || 0) : 0;
totalReceived += amount;
count++;
if (cMethod > -1) {
const method = String(allData[i][cMethod] || 'Other').trim();
methods[method] = (methods[method] || 0) + amount;
}
}
return { totalReceived, count, methods };
} catch (e) {
Logger.log('Error reading payments: ' + e.message);
return { totalReceived: 0, count: 0, methods: {}, error: e.message };
}
}
/** Get YTD totals */
function getYTDData_(year) {
const ytdStart = new Date(year, 0, 1);
const ytdEnd = new Date(year, 11, 31, 23, 59, 59);
const signups = get5HourClassData_(ytdStart, ytdEnd);
const regs = getRegistrationData_(ytdStart, ytdEnd);
const expenses = getExpenseData_(ytdStart, ytdEnd);
const payments = getPaymentData_(ytdStart, ytdEnd);
const estimatedRevenue = (signups.count * CONFIG.FIVE_HOUR_CLASS_PRICE) + regs.totalRevenue;
return {
revenue: payments.totalReceived > 0 ? payments.totalReceived : estimatedRevenue,
expenses: expenses.totalExpenses,
profit: (payments.totalReceived > 0 ? payments.totalReceived : estimatedRevenue) - expenses.totalExpenses,
students: signups.count + regs.count,
payments: payments.count
};
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DEMO DATA
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getDemoData_() {
return {
monthName: 'January 2026',
signupData: { count: 8, students: ['Sarah Johnson', 'Mike Rivera', 'Emily Chen', 'James Wilson', 'Ana Martinez', 'David Park', 'Lisa Thompson', 'Kevin Brown'] },
registrationData: {
count: 12,
totalRevenue: 8440,
packages: { '3 Lessons': 2, '5 Lessons': 3, '10 Lessons': 4, '15 Lessons': 2, '25 Lessons': 1 },
students: ['Carlos Mendez', 'Jenny Liu', 'Omar Hassan', 'Priya Patel', 'Alex Kim', 'Rosa Garcia', 'Tyler Jones', 'Nina Popov', 'Derek Chang', 'Maria Santos', 'Ryan O\'Brien', 'Fatima Al-Said']
},
expenseData: {
totalExpenses: 5430.25,
categories: { 'Instructor Pay': 3000, 'Fuel / Gas': 645.50, 'Insurance': 600, 'Vehicle Maintenance': 425, 'Rent / Utilities': 500, 'Marketing / Advertising': 150, 'Office Supplies': 59.75, 'Miscellaneous': 50 }
},
paymentData: { totalReceived: 9285.00, count: 18, methods: { 'Cash': 4500, 'Zelle': 3785, 'Credit Card': 1000 } },
prevSignup: { count: 6 },
prevReg: { totalRevenue: 6500 },
prevExp: { totalExpenses: 4800 },
prevPayments: { totalReceived: 7100 },
ytd: { revenue: 9285, expenses: 5430.25, profit: 3854.75, students: 20, payments: 18 },
dataErrors: []
};
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
MAIN REPORT GENERATOR
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function generateMonthlyReport() {
const demo = isDemoMode_();
const now = new Date();
try {
let d;
if (demo) {
Logger.log('โโ DEMO MODE โ using sample data โโ');
d = getDemoData_();
} else {
// Report on previous month
const reportMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const monthStart = new Date(reportMonth.getFullYear(), reportMonth.getMonth(), 1);
const monthEnd = new Date(reportMonth.getFullYear(), reportMonth.getMonth() + 1, 0, 23, 59, 59);
const monthName = Utilities.formatDate(reportMonth, CONFIG.TIMEZONE, 'MMMM yyyy');
// Previous month for comparison
const prevStart = new Date(reportMonth.getFullYear(), reportMonth.getMonth() - 1, 1);
const prevEnd = new Date(reportMonth.getFullYear(), reportMonth.getMonth(), 0, 23, 59, 59);
// Collect data
const signupData = get5HourClassData_(monthStart, monthEnd);
const registrationData = getRegistrationData_(monthStart, monthEnd);
const expenseData = getExpenseData_(monthStart, monthEnd);
const paymentData = getPaymentData_(monthStart, monthEnd);
const prevSignup = get5HourClassData_(prevStart, prevEnd);
const prevReg = getRegistrationData_(prevStart, prevEnd);
const prevExp = getExpenseData_(prevStart, prevEnd);
const prevPayments = getPaymentData_(prevStart, prevEnd);
const ytd = getYTDData_(reportMonth.getFullYear());
const dataErrors = [];
if (signupData.error) dataErrors.push('5-Hour Signups: ' + signupData.error);
if (registrationData.error) dataErrors.push('Registrations: ' + registrationData.error);
if (expenseData.error) dataErrors.push('Expenses: ' + expenseData.error);
if (paymentData.error) dataErrors.push('Payments: ' + paymentData.error);
d = { monthName, signupData, registrationData, expenseData, paymentData, prevSignup, prevReg, prevExp, prevPayments, ytd, dataErrors };
}
// Calculate totals
const classRevenue = d.signupData.count * CONFIG.FIVE_HOUR_CLASS_PRICE;
const lessonRevenue = d.registrationData.totalRevenue;
const estimatedRevenue = classRevenue + lessonRevenue;
const actualRevenue = d.paymentData.totalReceived;
const totalRevenue = actualRevenue > 0 ? actualRevenue : estimatedRevenue;
const totalExpenses = d.expenseData.totalExpenses;
const netProfit = totalRevenue - totalExpenses;
const prevEstRevenue = (d.prevSignup.count * CONFIG.FIVE_HOUR_CLASS_PRICE) + (d.prevReg.totalRevenue || 0);
const prevRevenue = (d.prevPayments && d.prevPayments.totalReceived > 0) ? d.prevPayments.totalReceived : prevEstRevenue;
const prevExpenses = d.prevExp.totalExpenses || 0;
const prevProfit = prevRevenue - prevExpenses;
// Build trend (3 bars)
const trend = buildTrend_(prevRevenue, totalRevenue);
// Build email
const html = buildReportEmail_({
monthName: d.monthName,
signupData: d.signupData,
registrationData: d.registrationData,
expenseData: d.expenseData,
paymentData: d.paymentData,
classRevenue,
lessonRevenue,
estimatedRevenue,
actualRevenue,
totalRevenue,
totalExpenses,
netProfit,
prevRevenue,
prevExpenses,
prevProfit,
ytd: d.ytd,
trend,
dataErrors: d.dataErrors || [],
demo
});
// Send email
const recipients = getAdminEmail_();
const subject = (demo ? '[DEMO] ' : '') + '๐ Monthly Revenue Report โ ' + d.monthName;
if (!demo) {
const toList = recipients.split(',').map(e => e.trim()).filter(Boolean);
for (const to of toList) {
MailApp.sendEmail({ to, subject, htmlBody: html });
}
Logger.log('Report sent to ' + toList.length + ' recipient(s)');
} else {
Logger.log('DEMO โ email not sent. Subject: ' + subject);
}
// Save to Report History
saveReportHistory_(d.monthName, now, totalRevenue, totalExpenses, netProfit,
d.signupData.count + d.registrationData.count, d.signupData.count, d.registrationData.count,
d.paymentData ? d.paymentData.count : 0, d.ytd ? d.ytd.revenue : totalRevenue);
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
Logger.log('Revenue: ' + formatMoney_(totalRevenue) + ' | Expenses: ' + formatMoney_(totalExpenses) + ' | Net: ' + formatMoney_(netProfit));
Logger.log('โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ');
} catch (err) {
Logger.log('ERROR in generateMonthlyReport: ' + err.message);
if (!demo) {
try {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: 'โ ๏ธ Monthly Revenue Report Error',
htmlBody: buildEmailHtml_('Report Error',
'<p style="color:#ccc;">Failed to generate the monthly report:</p>' +
'<p style="color:#ff2d2d;font-family:monospace;">' + escHtml_(err.message) + '</p>' +
'<p style="color:#999;">Check the Apps Script execution log for details.</p>')
});
} catch (e2) { /* silent */ }
}
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
TREND BUILDER
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function buildTrend_(prev, current) {
// Simple 2-bar trend using block chars
const max = Math.max(prev, current, 1);
const bars = ['โ', 'โ', 'โ', 'โ', 'โ
', 'โ', 'โ', 'โ'];
const prevBar = bars[Math.min(Math.floor((prev / max) * 7), 7)];
const currBar = bars[Math.min(Math.floor((current / max) * 7), 7)];
const direction = current > prev ? '๐' : current < prev ? '๐' : 'โก๏ธ';
return prevBar + currBar + ' ' + direction;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
SAVE REPORT HISTORY
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function saveReportHistory_(monthName, date, revenue, expenses, profit, students, signups, regs, payments, ytdRevenue) {
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = ss.getSheetByName('Report History');
if (!sheet) return;
sheet.appendRow([monthName, date, revenue, expenses, profit, students, signups, regs, payments, ytdRevenue]);
Logger.log('Saved to Report History โ
');
} catch (e) {
Logger.log('Could not save to Report History: ' + e.message);
}
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
API โ For BI Dashboard
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function getRevenueStats(monthYear) {
const demo = isDemoMode_();
if (demo) {
return {
demo: true,
monthYear: 'January 2026',
totalRevenue: 9285,
totalExpenses: 5430.25,
netProfit: 3854.75,
newStudents: 20,
fiveHourSignups: 8,
registrations: 12,
paymentsReceived: 18,
topExpenseCategory: 'Instructor Pay',
ytdRevenue: 9285,
ytdProfit: 3854.75
};
}
const now = new Date();
const targetMonth = monthYear
? new Date(monthYear + ' 1')
: new Date(now.getFullYear(), now.getMonth() - 1, 1);
const monthStart = new Date(targetMonth.getFullYear(), targetMonth.getMonth(), 1);
const monthEnd = new Date(targetMonth.getFullYear(), targetMonth.getMonth() + 1, 0, 23, 59, 59);
const signups = get5HourClassData_(monthStart, monthEnd);
const regs = getRegistrationData_(monthStart, monthEnd);
const expenses = getExpenseData_(monthStart, monthEnd);
const payments = getPaymentData_(monthStart, monthEnd);
const ytd = getYTDData_(targetMonth.getFullYear());
const totalRevenue = payments.totalReceived > 0
? payments.totalReceived
: (signups.count * CONFIG.FIVE_HOUR_CLASS_PRICE) + regs.totalRevenue;
const sortedCats = Object.entries(expenses.categories).sort((a, b) => b[1] - a[1]);
return {
demo: false,
monthYear: Utilities.formatDate(targetMonth, CONFIG.TIMEZONE, 'MMMM yyyy'),
totalRevenue,
totalExpenses: expenses.totalExpenses,
netProfit: totalRevenue - expenses.totalExpenses,
newStudents: signups.count + regs.count,
fiveHourSignups: signups.count,
registrations: regs.count,
paymentsReceived: payments.count,
topExpenseCategory: sortedCats.length > 0 ? sortedCats[0][0] : 'N/A',
ytdRevenue: ytd.revenue,
ytdProfit: ytd.profit
};
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
TEST / MANUAL RUN
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
/** Send a test report for the CURRENT month (not previous). Prefixed [TEST]. */
function testReport() {
const demo = isDemoMode_();
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
const monthName = Utilities.formatDate(now, CONFIG.TIMEZONE, 'MMMM yyyy');
let signupData, registrationData, expenseData, paymentData;
if (demo) {
const d = getDemoData_();
signupData = d.signupData;
registrationData = d.registrationData;
expenseData = d.expenseData;
paymentData = d.paymentData;
} else {
signupData = get5HourClassData_(monthStart, monthEnd);
registrationData = getRegistrationData_(monthStart, monthEnd);
expenseData = getExpenseData_(monthStart, monthEnd);
paymentData = getPaymentData_(monthStart, monthEnd);
}
const classRevenue = signupData.count * CONFIG.FIVE_HOUR_CLASS_PRICE;
const totalRevenue = paymentData.totalReceived > 0 ? paymentData.totalReceived : classRevenue + registrationData.totalRevenue;
const netProfit = totalRevenue - expenseData.totalExpenses;
Logger.log('=== TEST REPORT ===');
Logger.log('5-Hour: ' + signupData.count + ' (' + formatMoney_(classRevenue) + ')');
Logger.log('Registrations: ' + registrationData.count + ' (' + formatMoney_(registrationData.totalRevenue) + ')');
Logger.log('Actual Payments: ' + formatMoney_(paymentData.totalReceived) + ' (' + paymentData.count + ' payments)');
Logger.log('Revenue: ' + formatMoney_(totalRevenue) + ' | Expenses: ' + formatMoney_(expenseData.totalExpenses) + ' | Net: ' + formatMoney_(netProfit));
const html = buildReportEmail_({
monthName, signupData, registrationData, expenseData, paymentData,
classRevenue, lessonRevenue: registrationData.totalRevenue,
estimatedRevenue: classRevenue + registrationData.totalRevenue,
actualRevenue: paymentData.totalReceived,
totalRevenue, totalExpenses: expenseData.totalExpenses, netProfit,
prevRevenue: 0, prevExpenses: 0, prevProfit: 0,
ytd: { revenue: totalRevenue, expenses: expenseData.totalExpenses, profit: netProfit, students: signupData.count + registrationData.count, payments: paymentData.count },
trend: 'โ
โ ๐', dataErrors: [], demo
});
if (!demo) {
MailApp.sendEmail({
to: getAdminEmail_(),
subject: '[TEST] ๐ Monthly Revenue Report โ ' + monthName,
htmlBody: html
});
Logger.log('Test email sent โ
');
} else {
Logger.log('DEMO โ test email not sent. HTML length: ' + html.length);
}
}
/** Dry run โ log only, no email */
function testReportDryRun() {
const now = new Date();
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1);
const monthEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59);
Logger.log('=== DRY RUN ===');
const signups = get5HourClassData_(monthStart, monthEnd);
const regs = getRegistrationData_(monthStart, monthEnd);
const expenses = getExpenseData_(monthStart, monthEnd);
const payments = getPaymentData_(monthStart, monthEnd);
Logger.log('5-Hour: ' + signups.count + ' | Regs: ' + regs.count);
Logger.log('Payments received: ' + formatMoney_(payments.totalReceived) + ' (' + payments.count + ')');
Logger.log('Estimated revenue: ' + formatMoney_((signups.count * CONFIG.FIVE_HOUR_CLASS_PRICE) + regs.totalRevenue));
Logger.log('Expenses: ' + formatMoney_(expenses.totalExpenses));
if (signups.error) Logger.log('โ ๏ธ Signup error: ' + signups.error);
if (regs.error) Logger.log('โ ๏ธ Registration error: ' + regs.error);
if (expenses.error) Logger.log('โ ๏ธ Expense error: ' + expenses.error);
if (payments.error) Logger.log('โ ๏ธ Payment error: ' + payments.error);
Logger.log('No email sent.');
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
EMAIL BUILDER โ Mission Control Theme
(Keeping Boss's sidebar design + upgrading)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
function buildReportEmail_(d) {
const revenueChange = pctChange_(d.totalRevenue, d.prevRevenue);
const expenseChange = pctChange_(d.totalExpenses, d.prevExpenses);
const profitChange = pctChange_(d.netProfit, d.prevProfit);
const profitPositive = d.netProfit >= 0;
const revColor = revenueChange.startsWith('+') ? '#22c55e' : '#ff4444';
const expColor = expenseChange.startsWith('+') ? '#ff4444' : '#22c55e';
const profitColor = profitChange.startsWith('+') ? '#22c55e' : '#ff4444';
// Revenue breakdown rows
let packageRows = '';
for (const [pkg, count] of Object.entries(d.registrationData.packages || {})) {
const price = CONFIG.LESSON_PACKAGES[pkg] || 0;
packageRows += '<tr><td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;font-size:14px;">' + escHtml_(pkg) + '</td>' +
'<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;text-align:center;">' + count + '</td>' +
'<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#fff;text-align:right;font-weight:600;">' + formatMoney_(price * count) + '</td></tr>';
}
if (d.signupData.count > 0) {
packageRows += '<tr><td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;">5-Hour Pre-Licensing</td>' +
'<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;text-align:center;">' + d.signupData.count + '</td>' +
'<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#fff;text-align:right;font-weight:600;">' + formatMoney_(d.classRevenue) + '</td></tr>';
}
if (!packageRows) {
packageRows = '<tr><td colspan="3" style="padding:16px;text-align:center;color:#666;font-style:italic;">No revenue recorded</td></tr>';
}
// Payment methods row (NEW)
let paymentMethodRows = '';
if (d.paymentData && d.paymentData.methods) {
for (const [method, amount] of Object.entries(d.paymentData.methods)) {
paymentMethodRows += '<tr><td style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.04);color:#ccc;font-size:13px;">' + escHtml_(method) + '</td>' +
'<td style="padding:8px 14px;border-bottom:1px solid rgba(255,255,255,0.04);color:#22c55e;text-align:right;font-weight:600;">' + formatMoney_(amount) + '</td></tr>';
}
}
// Expense breakdown rows
const sortedExpenses = Object.entries(d.expenseData.categories || {}).sort((a, b) => b[1] - a[1]);
let expenseRows = '';
for (const [cat, amount] of sortedExpenses) {
expenseRows += '<tr><td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;">' + escHtml_(cat) + '</td>' +
'<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ff4444;text-align:right;font-weight:600;">' + formatMoney_(amount) + '</td></tr>';
}
if (!expenseRows) {
expenseRows = '<tr><td colspan="2" style="padding:16px;text-align:center;color:#666;font-style:italic;">No expenses recorded</td></tr>';
}
// Student list
const allStudents = [...new Set([...(d.signupData.students || []), ...(d.registrationData.students || [])])];
let studentList = '';
if (allStudents.length > 0) {
for (const name of allStudents) {
studentList += '<tr><td style="padding:6px 14px;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<span style="display:inline-block;width:8px;height:8px;background:#ff2d2d;border-radius:50%;margin-right:10px;"></span>' +
'<span style="color:#ddd;font-size:13px;">' + escHtml_(name) + '</span></td></tr>';
}
} else {
studentList = '<tr><td style="padding:16px;text-align:center;color:#666;font-style:italic;">No new students</td></tr>';
}
// Data alerts
let alertHtml = '';
if (d.dataErrors && d.dataErrors.length > 0) {
alertHtml = '<div style="padding:12px 24px;background:rgba(255,200,0,0.1);border:1px solid rgba(255,200,0,0.3);border-radius:8px;margin:8px 24px;">' +
'<span style="color:#e6b800;font-size:12px;">โ ๏ธ ' + d.dataErrors.map(e => escHtml_(e)).join('<br/>') + '</span></div>';
}
// YTD section
let ytdHtml = '';
if (d.ytd) {
ytdHtml = '<tr><td style="padding:8px 24px 16px;">' +
'<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:16px;">' +
'<div style="color:#fff;font-size:14px;font-weight:600;margin-bottom:12px;">๐ Year-to-Date</div>' +
'<table width="100%" cellpadding="0" cellspacing="0"><tr>' +
'<td style="text-align:center;"><div style="color:#22c55e;font-size:18px;font-weight:700;">' + formatMoney_(d.ytd.revenue) + '</div><div style="color:#666;font-size:10px;">Revenue</div></td>' +
'<td style="text-align:center;"><div style="color:#ff4444;font-size:18px;font-weight:700;">' + formatMoney_(d.ytd.expenses) + '</div><div style="color:#666;font-size:10px;">Expenses</div></td>' +
'<td style="text-align:center;"><div style="color:' + (d.ytd.profit >= 0 ? '#ff2d2d' : '#ff4444') + ';font-size:18px;font-weight:700;">' + formatMoney_(d.ytd.profit) + '</div><div style="color:#666;font-size:10px;">Net Profit</div></td>' +
'<td style="text-align:center;"><div style="color:#fff;font-size:18px;font-weight:700;">' + d.ytd.students + '</div><div style="color:#666;font-size:10px;">Students</div></td>' +
'</tr></table></div></td></tr>';
}
// Sidebar
const sidebarItems = [
{ icon: '๐', label: 'Overview', active: true },
{ icon: '๐ฐ', label: 'Revenue' },
{ icon: '๐', label: 'Expenses' },
{ icon: '๐ฅ', label: 'Students' },
{ icon: '๐', label: 'Trends' },
{ icon: 'โ๏ธ', label: 'Settings' }
];
let sidebarHtml = '';
for (const item of sidebarItems) {
const bg = item.active ? 'background:rgba(255,45,45,0.15);border:1px solid rgba(255,45,45,0.25);' : 'background:transparent;border:1px solid transparent;';
const color = item.active ? '#ff2d2d' : '#555';
const weight = item.active ? '600' : '400';
sidebarHtml += '<tr><td style="padding:4px 0;"><div style="padding:10px 14px;border-radius:12px;' + bg + 'text-align:center;">' +
'<div style="font-size:20px;line-height:1;">' + item.icon + '</div>' +
'<div style="font-size:9px;color:' + color + ';margin-top:4px;font-weight:' + weight + ';">' + item.label + '</div></div></td></tr>';
}
// Payment source indicator
const revenueSourceNote = d.actualRevenue > 0
? '<div style="color:#22c55e;font-size:10px;margin-top:4px;">โ
Actual payments received</div>'
: '<div style="color:#ffaa00;font-size:10px;margin-top:4px;">โก Estimated from package prices</div>';
// Build full email
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,\'SF Pro Display\',\'Helvetica Neue\',Arial,sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:16px;"><tr><td align="center">' +
'<table width="700" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;overflow:hidden;border:1px solid rgba(255,255,255,0.06);">' +
// Sidebar + Content
'<tr>' +
// Sidebar
'<td width="72" valign="top" style="background:#0a0a0a;border-right:1px solid rgba(255,255,255,0.06);padding:16px 8px;">' +
'<table width="100%" cellpadding="0" cellspacing="0">' +
'<tr><td style="text-align:center;padding:8px 0 20px;"><div style="width:42px;height:42px;background:linear-gradient(135deg,#ff2d2d,#cc0000);border-radius:14px;line-height:42px;font-size:18px;margin:0 auto;font-weight:bold;color:#fff;">๐ซ</div></td></tr>' +
'<tr><td style="padding:0 10px 12px;"><div style="height:1px;background:rgba(255,255,255,0.06);"></div></td></tr>' +
sidebarHtml +
'<tr><td style="padding:16px 10px 8px;"><div style="height:1px;background:rgba(255,255,255,0.06);"></div></td></tr>' +
'<tr><td style="text-align:center;padding:4px 0;"><div style="width:8px;height:8px;background:#22c55e;border-radius:50%;margin:0 auto;"></div><div style="font-size:8px;color:#444;margin-top:4px;">LIVE</div></td></tr>' +
'</table></td>' +
// Main content
'<td valign="top" style="padding:0;"><table width="100%" cellpadding="0" cellspacing="0">' +
// Header bar
'<tr><td style="padding:18px 24px;border-bottom:1px solid rgba(255,255,255,0.06);"><table width="100%"><tr>' +
'<td><span style="color:#fff;font-size:18px;font-weight:700;">Mission Control</span> <span style="color:#333;">/</span> <span style="color:#888;font-size:15px;">Revenue Report</span></td>' +
'<td style="text-align:right;"><span style="display:inline-block;background:rgba(255,45,45,0.12);border:1px solid rgba(255,45,45,0.25);color:#ff2d2d;font-size:11px;font-weight:600;padding:4px 12px;border-radius:20px;">' + escHtml_(d.monthName) + (d.demo ? ' โข DEMO' : '') + '</span></td>' +
'</tr></table></td></tr>' +
// Data alerts
(alertHtml ? '<tr><td>' + alertHtml + '</td></tr>' : '') +
// Summary cards
'<tr><td style="padding:20px 24px 12px;"><table width="100%" cellpadding="0" cellspacing="0"><tr>' +
// Revenue card
'<td width="33%" style="padding-right:8px;"><div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:18px 16px;">' +
'<table width="100%"><tr><td><span style="font-size:11px;color:#666;text-transform:uppercase;">Revenue</span></td><td style="text-align:right;">๐ฐ</td></tr></table>' +
'<div style="font-size:24px;font-weight:700;color:#22c55e;margin:8px 0 4px;">' + formatMoney_(d.totalRevenue) + '</div>' +
'<span style="font-size:11px;color:' + revColor + ';">' + revenueChange + '</span> <span style="font-size:11px;color:#444;">vs prior</span>' +
revenueSourceNote +
'</div></td>' +
// Expenses card
'<td width="33%" style="padding:0 4px;"><div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;padding:18px 16px;">' +
'<table width="100%"><tr><td><span style="font-size:11px;color:#666;text-transform:uppercase;">Expenses</span></td><td style="text-align:right;">๐ฅ</td></tr></table>' +
'<div style="font-size:24px;font-weight:700;color:#ff4444;margin:8px 0 4px;">' + formatMoney_(d.totalExpenses) + '</div>' +
'<span style="font-size:11px;color:' + expColor + ';">' + expenseChange + '</span> <span style="font-size:11px;color:#444;">vs prior</span>' +
'</div></td>' +
// Net Profit card
'<td width="33%" style="padding-left:8px;"><div style="background:' + (profitPositive ? 'rgba(255,45,45,0.06)' : 'rgba(255,255,255,0.03)') + ';border:1px solid ' + (profitPositive ? 'rgba(255,45,45,0.18)' : 'rgba(255,255,255,0.07)') + ';border-radius:16px;padding:18px 16px;">' +
'<table width="100%"><tr><td><span style="font-size:11px;color:#666;text-transform:uppercase;">Net Profit</span></td><td style="text-align:right;">โก</td></tr></table>' +
'<div style="font-size:24px;font-weight:700;color:' + (profitPositive ? '#ff2d2d' : '#ff4444') + ';margin:8px 0 4px;">' + formatMoney_(d.netProfit) + '</div>' +
'<span style="font-size:11px;color:' + profitColor + ';">' + profitChange + '</span> <span style="font-size:11px;color:#444;">vs prior</span>' +
'<div style="color:#888;font-size:12px;margin-top:4px;">' + (d.trend || '') + '</div>' +
'</div></td>' +
'</tr></table></td></tr>' +
// Revenue breakdown + Students
'<tr><td style="padding:8px 24px 12px;"><table width="100%" cellpadding="0" cellspacing="0"><tr>' +
// Revenue table
'<td width="60%" valign="top" style="padding-right:8px;"><div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;overflow:hidden;">' +
'<div style="padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">๐ฐ <span style="color:#fff;font-size:14px;font-weight:600;">Revenue Breakdown</span></div>' +
'<table width="100%"><tr style="background:rgba(255,255,255,0.02);"><th style="padding:8px 12px;text-align:left;font-size:10px;color:#555;">Service</th><th style="padding:8px 12px;text-align:center;font-size:10px;color:#555;">Qty</th><th style="padding:8px 12px;text-align:right;font-size:10px;color:#555;">Amount</th></tr>' +
packageRows +
'<tr style="background:rgba(255,255,255,0.02);"><td style="padding:10px 12px;color:#fff;font-weight:700;" colspan="2">Total (Estimated)</td><td style="padding:10px 12px;text-align:right;color:#22c55e;font-weight:700;">' + formatMoney_(d.estimatedRevenue) + '</td></tr></table></div>' +
// Payment methods (if available)
(paymentMethodRows ? '<div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;overflow:hidden;margin-top:8px;">' +
'<div style="padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">๐ณ <span style="color:#fff;font-size:14px;font-weight:600;">Payments Received</span> <span style="display:inline-block;background:#22c55e;color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:8px;margin-left:6px;">' + (d.paymentData ? d.paymentData.count : 0) + '</span></div>' +
'<table width="100%">' + paymentMethodRows +
'<tr style="background:rgba(255,255,255,0.02);"><td style="padding:10px 14px;color:#fff;font-weight:700;">Total Received</td><td style="padding:10px 14px;text-align:right;color:#22c55e;font-weight:700;">' + formatMoney_(d.actualRevenue) + '</td></tr></table></div>' : '') +
'</td>' +
// Students list
'<td width="40%" valign="top" style="padding-left:8px;"><div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;overflow:hidden;">' +
'<div style="padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">๐ฅ <span style="color:#fff;font-size:14px;font-weight:600;">New Students</span> <span style="display:inline-block;background:#ff2d2d;color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:8px;margin-left:6px;">' + allStudents.length + '</span></div>' +
'<table width="100%">' + studentList + '</table></div></td>' +
'</tr></table></td></tr>' +
// Expense breakdown
'<tr><td style="padding:0 24px 12px;"><div style="background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.07);border-radius:16px;overflow:hidden;">' +
'<div style="padding:14px 16px 10px;border-bottom:1px solid rgba(255,255,255,0.05);">๐ <span style="color:#fff;font-size:14px;font-weight:600;">Expense Breakdown</span></div>' +
'<table width="100%"><tr style="background:rgba(255,255,255,0.02);"><th style="padding:8px 12px;text-align:left;font-size:10px;color:#555;">Category</th><th style="padding:8px 12px;text-align:right;font-size:10px;color:#555;">Amount</th></tr>' +
expenseRows +
'<tr style="background:rgba(255,255,255,0.02);"><td style="padding:10px 12px;color:#fff;font-weight:700;">Total Expenses</td><td style="padding:10px 12px;text-align:right;color:#ff4444;font-weight:700;">' + formatMoney_(d.totalExpenses) + '</td></tr></table></div></td></tr>' +
// YTD
ytdHtml +
// Footer
'<tr><td style="padding:12px 24px 18px;border-top:1px solid rgba(255,255,255,0.04);"><table width="100%"><tr>' +
'<td><span style="font-size:11px;color:#333;">Powered by </span><span style="font-size:11px;color:#ff2d2d;font-weight:600;">' + escHtml_(CONFIG.SCHOOL_NAME) + ' โ Mission Control</span></td>' +
'<td style="text-align:right;"><span style="font-size:11px;color:#444;">All systems operational</span></td>' +
'</tr></table></td></tr>' +
// Unsubscribe
'<tr><td style="padding:0 24px 16px;text-align:center;"><span style="font-size:10px;color:#333;">To stop receiving this report, reply "STOP" or update Settings.</span></td></tr>' +
'</table></td></tr></table></td></tr></table></body></html>';
}
/** Generic Mission Control email wrapper (for error emails) */
function buildEmailHtml_(title, bodyContent) {
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;"><tr><td align="center" style="padding:20px;">' +
'<table width="600" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border:1px solid rgba(255,45,45,0.2);border-radius:12px;overflow:hidden;">' +
'<tr><td style="background:linear-gradient(135deg,#1a0000,#0d0d0d);padding:24px 32px;border-bottom:1px solid rgba(255,45,45,0.15);">' +
'<span style="color:#ff2d2d;font-size:20px;font-weight:bold;">๐ซ ' + escHtml_(CONFIG.SCHOOL_NAME) + '</span></td></tr>' +
'<tr><td style="padding:20px 32px 0;"><h1 style="color:#fff;font-size:22px;margin:0 0 4px;">' + title + '</h1><div style="width:40px;height:3px;background:#ff2d2d;border-radius:2px;"></div></td></tr>' +
'<tr><td style="padding:20px 32px 32px;">' + bodyContent + '</td></tr>' +
'<tr><td style="padding:20px 32px;border-top:1px solid rgba(255,255,255,0.05);text-align:center;"><p style="color:#333;font-size:10px;margin:0;">Automated by Mission Control</p></td></tr>' +
'</table></td></tr></table></body></html>';
}
/**
* =========================================================
* PARENT DASHBOARD โ Automation #15
* Flavors Driving School
* =========================================================
* Web portal for parents of under-18 students
* - Demo mode with fake parent/student data
* - Phone verification (last 4 digits)
* - Email-first + fuzzy name matching
* - View child's lesson progress & attendance
* - See upcoming scheduled lessons
* - Track payment balance & payment history
* - Automated weekly progress emails (MailApp + unsubscribe)
* - Multi-child support (one parent, multiple students)
* - "Search Again" button
* - SMS notification preference flag (future)
* - Mission Control dark theme (consistent #0d0d0d)
* =========================================================
*/
// โโ CONFIG โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const CONFIG = {
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_EMAIL: '[email protected]',
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
};
// โโ DEMO DATA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const DEMO = {
parents: {
'[email protected]': {
name: 'Maria Rivera',
phone: '5551234',
children: ['Alex Rivera', 'Sofia Rivera'],
},
'[email protected]': {
name: 'James Johnson',
phone: '5559876',
children: ['Sarah Johnson'],
},
},
students: {
'alex rivera': {
name: 'Alex Rivera',
registration: { package: '10 Lessons', dob: '3/15/2008', phone: '555-4321', email: '[email protected]' },
lessons: [
{ date: '6/20/2025', time: '10:00 AM', instructor: 'Carlos', status: 'Completed' },
{ date: '6/18/2025', time: '2:00 PM', instructor: 'Carlos', status: 'Completed' },
{ date: '6/15/2025', time: '10:00 AM', instructor: 'Anisha', status: 'No Show' },
{ date: '6/12/2025', time: '11:00 AM', instructor: 'Carlos', status: 'Completed' },
],
upcoming: [
{ date: '7/1/2025', dayName: 'Tue', time: '10:00 AM', instructor: 'Carlos' },
{ date: '7/3/2025', dayName: 'Thu', time: '2:00 PM', instructor: 'Carlos' },
],
payments: { balance: 150, totalPaid: 350, totalDue: 500, payments: [
{ date: '6/1/2025', amount: 200, method: 'Zelle' },
{ date: '5/15/2025', amount: 150, method: 'Cash' },
]},
progress: { totalLessons: 10, completedLessons: 3, percentage: 30, remaining: 7, noShows: 1, onTime: 3, attendanceRate: 75 },
},
'sofia rivera': {
name: 'Sofia Rivera',
registration: { package: '5 Lessons', dob: '8/22/2009', phone: '555-4321', email: '[email protected]' },
lessons: [
{ date: '6/19/2025', time: '3:00 PM', instructor: 'Anisha', status: 'Completed' },
],
upcoming: [
{ date: '7/2/2025', dayName: 'Wed', time: '11:00 AM', instructor: 'Anisha' },
],
payments: { balance: 0, totalPaid: 250, totalDue: 250, payments: [
{ date: '5/20/2025', amount: 250, method: 'Card' },
]},
progress: { totalLessons: 5, completedLessons: 1, percentage: 20, remaining: 4, noShows: 0, onTime: 1, attendanceRate: 100 },
},
'sarah johnson': {
name: 'Sarah Johnson',
registration: { package: '10 Lessons', dob: '1/10/2008', phone: '555-8765', email: '[email protected]' },
lessons: [
{ date: '6/21/2025', time: '9:00 AM', instructor: 'Nick', status: 'Completed' },
{ date: '6/19/2025', time: '9:00 AM', instructor: 'Nick', status: 'Completed' },
{ date: '6/17/2025', time: '9:00 AM', instructor: 'Nick', status: 'Completed' },
{ date: '6/14/2025', time: '11:00 AM', instructor: 'Anisha', status: 'Completed' },
{ date: '6/12/2025', time: '9:00 AM', instructor: 'Nick', status: 'Completed' },
],
upcoming: [
{ date: '7/1/2025', dayName: 'Tue', time: '9:00 AM', instructor: 'Nick' },
{ date: '7/3/2025', dayName: 'Thu', time: '9:00 AM', instructor: 'Nick' },
{ date: '7/5/2025', dayName: 'Sat', time: '10:00 AM', instructor: 'Nick' },
],
payments: { balance: 100, totalPaid: 400, totalDue: 500, payments: [
{ date: '6/1/2025', amount: 200, method: 'Zelle' },
{ date: '5/10/2025', amount: 200, method: 'Cash' },
]},
progress: { totalLessons: 10, completedLessons: 5, percentage: 50, remaining: 5, noShows: 0, onTime: 5, attendanceRate: 100 },
},
},
};
// โโ WEB APP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function doGet(e) {
return HtmlService.createHtmlOutput(getPortalHTML_())
.setTitle('Parent Dashboard โ ' + CONFIG.SCHOOL_NAME)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1.0');
}
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized โ 3 sheets + email.');
}
function fullSetup() {
createParentSheet_();
setupProgressTrigger_();
Logger.log('โ
Parent Dashboard fully set up.');
}
function createParentSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let sheet = ss.getSheetByName('Parent Links');
if (!sheet) {
sheet = ss.insertSheet('Parent Links');
sheet.getRange('A1:G1').setValues([[
'Student Name', 'Parent Name', 'Parent Email', 'Parent Phone', 'Date Linked', 'SMS Opt-In', 'Unsubscribed'
]]);
sheet.getRange('A1:G1').setFontWeight('bold').setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontSize(10);
sheet.setFrozenRows(1);
Logger.log('Created Parent Links sheet');
}
}
function setupProgressTrigger_() {
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'sendWeeklyProgressToParents') {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('sendWeeklyProgressToParents')
.timeBased()
.onWeekDay(ScriptApp.WeekDay.FRIDAY)
.atHour(17)
.nearMinute(0)
.create();
Logger.log('โ
Weekly progress email trigger created (Friday 5 PM)');
}
// โโ PHONE VERIFICATION โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function verifyPhone(email, lastFour) {
email = sanitize_(email).toLowerCase();
lastFour = sanitize_(lastFour);
if (!email || !lastFour || lastFour.length !== 4 || !/^\d{4}$/.test(lastFour)) {
return { verified: false, error: 'Please enter the last 4 digits of your phone number.' };
}
if (CONFIG.DEMO_MODE) {
const parent = DEMO.parents[email];
if (!parent) return { verified: false, error: 'Email not found.' };
const parentLast4 = parent.phone.replace(/\D/g, '').slice(-4);
if (parentLast4 !== lastFour) return { verified: false, error: 'Phone number does not match our records.' };
return { verified: true };
}
// Check Parent Links sheet
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const parentSheet = ss.getSheetByName('Parent Links');
if (!parentSheet || parentSheet.getLastRow() <= 1) return { verified: false, error: 'No parent records found.' };
const data = parentSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const emailCol = findCol_(headers, ['parent email', 'email']);
const phoneCol = findCol_(headers, ['parent phone', 'phone']);
for (let i = 1; i < data.length; i++) {
const rowEmail = String(data[i][emailCol] || '').trim().toLowerCase();
if (rowEmail === email) {
const rowPhone = String(data[i][phoneCol] || '').replace(/\D/g, '');
if (rowPhone.slice(-4) === lastFour) return { verified: true };
}
}
// Also check registration sheet for student email matches
const regSheet = ss.getSheets()[0];
const regData = regSheet.getDataRange().getValues();
const regHeaders = regData[0].map(h => String(h).toLowerCase().trim());
const regEmailCol = findCol_(regHeaders, ['email', 'email address', 'student email']);
const regPhoneCol = findCol_(regHeaders, ['phone', 'phone number', 'cell', 'mobile']);
for (let i = 1; i < regData.length; i++) {
const rowEmail = String(regData[i][regEmailCol] || '').trim().toLowerCase();
if (rowEmail === email) {
const rowPhone = String(regData[i][regPhoneCol] || '').replace(/\D/g, '');
if (rowPhone.slice(-4) === lastFour) return { verified: true };
}
}
return { verified: false, error: 'Phone number does not match our records.' };
}
// โโ DATA FETCHING (called from client) โโโโโโโโโโโโโโโโโโ
function lookupParent(email) {
email = sanitize_(email).toLowerCase();
if (!email || !email.includes('@')) return { error: 'Please enter a valid email address.' };
if (CONFIG.DEMO_MODE) return lookupParentDemo_(email);
// Check Parent Links sheet
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const parentSheet = regSS.getSheetByName('Parent Links');
const children = [];
if (parentSheet && parentSheet.getLastRow() > 1) {
const data = parentSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student name', 'student', 'name']);
const emailCol = findCol_(headers, ['parent email', 'email']);
for (let i = 1; i < data.length; i++) {
const parentEmail = String(data[i][emailCol] || '').trim().toLowerCase();
if (parentEmail === email) {
const studentName = String(data[i][nameCol] || '').trim();
if (studentName && !children.includes(studentName)) children.push(studentName);
}
}
}
// Also check if email matches a student directly (parent using student's email)
const regSheet = regSS.getSheets()[0];
const regData = regSheet.getDataRange().getValues();
const regHeaders = regData[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(regHeaders, ['full name', 'name', 'student name']);
const emailCol = findCol_(regHeaders, ['email', 'email address', 'student email']);
for (let i = 1; i < regData.length; i++) {
const studentEmail = String(regData[i][emailCol] || '').trim().toLowerCase();
const studentName = String(regData[i][nameCol] || '').trim();
if (studentEmail === email && studentName && !children.includes(studentName)) {
// Also try fuzzy match to avoid near-dupes
if (!children.some(c => fuzzyNameMatch_(c, studentName))) {
children.push(studentName);
}
}
}
if (children.length === 0) {
return { error: 'No students found linked to this email. Please contact us to set up parent access.' };
}
const results = children.map(name => getStudentData_(name));
return { children: results };
}
function lookupParentDemo_(email) {
const parent = DEMO.parents[email];
if (!parent) {
return { error: 'No students found linked to this email. Try [email protected] or [email protected]' };
}
const results = parent.children.map(name => {
const key = name.toLowerCase();
return DEMO.students[key] || { name: name, registration: {}, lessons: [], upcoming: [], payments: { balance: 0, totalPaid: 0, totalDue: 0, payments: [] }, progress: { totalLessons: 0, completedLessons: 0, percentage: 0, remaining: 0, noShows: 0, onTime: 0, attendanceRate: 100 } };
});
return { children: results };
}
function getStudentData_(studentName) {
return {
name: studentName,
registration: getRegistrationInfo_(studentName),
lessons: getLessonHistory_(studentName),
upcoming: getUpcomingLessons_(studentName),
payments: getPaymentInfo_(studentName),
progress: calculateProgress_(studentName),
};
}
function getRegistrationInfo_(name) {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = ss.getSheets()[0];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['full name', 'name', 'student name']);
const emailCol = findCol_(headers, ['email', 'email address', 'student email']);
const packageCol = findCol_(headers, ['lesson package', 'package', 'selected package']);
const dobCol = findCol_(headers, ['date of birth', 'dob', 'birth date']);
const phoneCol = findCol_(headers, ['phone', 'phone number', 'cell', 'mobile']);
for (let i = 1; i < data.length; i++) {
const rowName = String(data[i][nameCol] || '').trim();
const rowEmail = emailCol !== -1 ? String(data[i][emailCol] || '').trim().toLowerCase() : '';
if (rowName.toLowerCase() === name.toLowerCase() || fuzzyNameMatch_(rowName, name)) {
return {
package: String(data[i][packageCol] || ''),
dob: data[i][dobCol] ? formatDate_(data[i][dobCol]) : '',
phone: phoneCol !== -1 ? String(data[i][phoneCol] || '') : '',
email: rowEmail,
};
}
}
return {};
}
function getLessonHistory_(name) {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = ss.getSheetByName('Bookings');
if (!sheet) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const emailCol = findCol_(headers, ['email', 'student email']);
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
// Skip these statuses โ they're not real completed lessons
const skipStatuses = ['cancelled', 'canceled', 'pending', 'scheduled', 'upcoming', 'rescheduled'];
const lessons = [];
const today = new Date();
for (let i = 1; i < data.length; i++) {
const rowName = String(data[i][nameCol] || '').trim();
if (!matchesStudent_(rowName, '', name)) continue;
const status = String(data[i][statusCol] || 'Completed').trim();
if (skipStatuses.includes(status.toLowerCase())) continue;
const rawDate = data[i][dateCol];
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (isNaN(date.getTime()) || date > today) continue;
lessons.push({
date: formatDate_(date),
time: String(data[i][timeCol] || ''),
instructor: String(data[i][instructorCol] || ''),
status: status,
});
}
lessons.sort((a, b) => new Date(b.date) - new Date(a.date));
return lessons;
}
function getUpcomingLessons_(name) {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = ss.getSheetByName('Bookings');
if (!sheet) return [];
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
// Skip all non-active statuses
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'rescheduled', 'completed'];
const upcoming = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = 1; i < data.length; i++) {
const rowName = String(data[i][nameCol] || '').trim();
if (!matchesStudent_(rowName, '', name)) continue;
const status = String(data[i][statusCol] || '').trim().toLowerCase();
if (skipStatuses.includes(status)) continue;
const rawDate = data[i][dateCol];
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (isNaN(date.getTime()) || date < today) continue;
upcoming.push({
date: formatDate_(date),
dayName: ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'][date.getDay()],
time: String(data[i][timeCol] || ''),
instructor: String(data[i][instructorCol] || ''),
});
}
upcoming.sort((a, b) => new Date(a.date) - new Date(b.date));
return upcoming;
}
function getPaymentInfo_(name) {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
let balance = 0, totalPaid = 0, totalDue = 0;
const balSheet = ss.getSheetByName('Student Balances');
if (balSheet) {
const data = balSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const emailCol = findCol_(headers, ['email', 'student email']);
const balCol = findCol_(headers, ['balance', 'remaining balance', 'amount due']);
const paidCol = findCol_(headers, ['total paid', 'paid']);
const dueCol = findCol_(headers, ['total due', 'package cost', 'total']);
for (let i = 1; i < data.length; i++) {
const rowName = String(data[i][nameCol] || '').trim();
const rowEmail = emailCol !== -1 ? String(data[i][emailCol] || '').trim() : '';
if (matchesStudent_(rowName, rowEmail, name)) {
balance = parseFloat(data[i][balCol]) || 0;
totalPaid = paidCol !== -1 ? (parseFloat(data[i][paidCol]) || 0) : 0;
totalDue = dueCol !== -1 ? (parseFloat(data[i][dueCol]) || 0) : 0;
break;
}
}
}
const payments = [];
const paySheet = ss.getSheetByName('Payments') || ss.getSheets()[0];
if (paySheet) {
const data = paySheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const emailCol = findCol_(headers, ['email', 'student email']);
const amountCol = findCol_(headers, ['amount', 'payment amount']);
const dateCol = findCol_(headers, ['date', 'payment date', 'timestamp']);
const methodCol = findCol_(headers, ['method', 'payment method']);
for (let i = 1; i < data.length; i++) {
const rowName = String(data[i][nameCol] || '').trim();
const rowEmail = emailCol !== -1 ? String(data[i][emailCol] || '').trim() : '';
if (matchesStudent_(rowName, rowEmail, name)) {
payments.push({
date: formatDate_(data[i][dateCol]),
amount: parseFloat(data[i][amountCol]) || 0,
method: methodCol !== -1 ? String(data[i][methodCol] || '') : '',
});
}
}
}
payments.sort((a, b) => new Date(b.date) - new Date(a.date));
return { balance, totalPaid, totalDue, payments };
}
function calculateProgress_(name) {
const reg = getRegistrationInfo_(name);
const lessons = getLessonHistory_(name);
const totalLessons = extractLessonCount_(reg.package);
// getLessonHistory_ already filters out cancelled/pending/scheduled/upcoming/rescheduled
// So count confirmed past lessons only
const completedLessons = lessons.filter(l =>
l.status.toLowerCase() !== 'no show' &&
l.status.toLowerCase() !== 'no-show' &&
l.status.toLowerCase() !== 'noshow'
).length;
const percentage = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
const noShows = lessons.filter(l =>
l.status.toLowerCase() === 'no show' ||
l.status.toLowerCase() === 'no-show' ||
l.status.toLowerCase() === 'noshow'
).length;
const onTime = lessons.filter(l =>
l.status.toLowerCase() === 'on time' ||
l.status.toLowerCase() === 'completed'
).length;
return {
totalLessons,
completedLessons,
percentage: Math.min(100, percentage),
remaining: Math.max(0, totalLessons - completedLessons),
noShows,
onTime,
attendanceRate: (completedLessons + noShows) > 0 ? Math.round((completedLessons / (completedLessons + noShows)) * 100) : 100,
};
}
// โโ STUDENT MATCHING (email-first + fuzzy name) โโโโโโโโโ
function matchesStudent_(rowName, rowEmail, targetName) {
if (!rowName && !rowEmail) return false;
// Exact name match
if (rowName && rowName.toLowerCase() === targetName.toLowerCase()) return true;
// Fuzzy name match
if (rowName && fuzzyNameMatch_(rowName, targetName)) return true;
return false;
}
// โโ WEEKLY PROGRESS EMAILS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function sendWeeklyProgressToParents() {
if (CONFIG.DEMO_MODE) {
Logger.log('โ ๏ธ DEMO MODE โ skipping weekly progress emails.');
return;
}
try {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const parentSheet = regSS.getSheetByName('Parent Links');
if (!parentSheet || parentSheet.getLastRow() <= 1) return;
const data = parentSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student name', 'student', 'name']);
const parentNameCol = findCol_(headers, ['parent name', 'parent']);
const emailCol = findCol_(headers, ['parent email', 'email']);
const unsubCol = findCol_(headers, ['unsubscribed', 'unsub']);
const parentMap = {};
for (let i = 1; i < data.length; i++) {
const studentName = String(data[i][nameCol] || '').trim();
const parentName = parentNameCol !== -1 ? String(data[i][parentNameCol] || '').trim() : '';
const parentEmail = String(data[i][emailCol] || '').trim().toLowerCase();
const unsubscribed = unsubCol !== -1 ? String(data[i][unsubCol] || '').trim().toLowerCase() : '';
if (!parentEmail || !studentName) continue;
if (unsubscribed === 'yes' || unsubscribed === 'true') continue;
if (!parentMap[parentEmail]) {
parentMap[parentEmail] = { name: parentName, children: [] };
}
parentMap[parentEmail].children.push(studentName);
}
let sent = 0;
for (const [email, parent] of Object.entries(parentMap)) {
const childrenData = parent.children.map(name => ({
name: escHtml_(name),
progress: calculateProgress_(name),
upcoming: getUpcomingLessons_(name).slice(0, 3),
payments: getPaymentInfo_(name),
}));
const html = buildProgressEmail_(parent.name, childrenData);
try {
MailApp.sendEmail({
to: email,
subject: '๐ Weekly Progress Report โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html,
name: CONFIG.SCHOOL_NAME,
});
sent++;
} catch (e) { Logger.log('Error emailing ' + email + ': ' + e.message); }
}
Logger.log('โ
Sent weekly progress emails to ' + sent + ' parents.');
} catch (e) {
Logger.log('โ sendWeeklyProgressToParents error: ' + e.message);
}
}
function buildProgressEmail_(parentName, children) {
let childBlocks = '';
children.forEach(function(child) {
const p = child.progress;
const barWidth = Math.max(5, p.percentage);
const barColor = p.percentage >= 75 ? '#22c55e' : p.percentage >= 40 ? '#f59e0b' : '#ff2d2d';
let upcomingRows = '';
child.upcoming.forEach(function(u) {
upcomingRows +=
'<tr><td style="color:#ccc;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;">' + escHtml_(u.dayName) + ' ' + escHtml_(u.date) + '</td>' +
'<td style="color:#ccc;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;">' + escHtml_(u.time) + '</td>' +
'<td style="color:#ccc;padding:6px 8px;border-bottom:1px solid rgba(255,255,255,0.04);font-size:13px;">' + escHtml_(u.instructor) + '</td></tr>';
});
childBlocks +=
'<table width="100%" style="background:rgba(255,255,255,0.03);border-radius:14px;margin:15px 0;border:1px solid rgba(255,255,255,0.06);" cellpadding="0" cellspacing="0">' +
'<tr><td style="padding:20px;">' +
'<p style="color:#fff;font-size:18px;font-weight:600;margin:0 0 15px;">๐ ' + child.name + '</p>' +
'<p style="color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin:0 0 8px;">Progress</p>' +
'<div style="background:#0a0a0a;border-radius:6px;overflow:hidden;height:24px;margin-bottom:8px;">' +
'<div style="background:' + barColor + ';height:100%;width:' + barWidth + '%;border-radius:6px;text-align:center;line-height:24px;color:#000;font-size:12px;font-weight:600;">' + p.percentage + '%</div></div>' +
'<p style="color:#aaa;font-size:13px;margin:0 0 15px;">' + p.completedLessons + ' of ' + p.totalLessons + ' lessons completed โข ' + p.remaining + ' remaining</p>' +
(p.noShows > 0 ? '<p style="color:#ff2d2d;font-size:13px;margin:0 0 15px;">โ ๏ธ ' + p.noShows + ' no-show(s) recorded</p>' : '') +
'<p style="color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin:0 0 8px;">Balance</p>' +
'<p style="color:' + (child.payments.balance > 0 ? '#f59e0b' : '#22c55e') + ';font-size:16px;font-weight:600;margin:0 0 15px;">' +
(child.payments.balance > 0 ? '$' + child.payments.balance.toFixed(2) + ' remaining' : 'โ
Paid in full') + '</p>' +
(child.upcoming.length > 0 ?
'<p style="color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin:0 0 8px;">Upcoming Lessons</p>' +
'<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:5px;">' +
'<tr><td style="color:#ff2d2d;padding:6px 8px;font-size:10px;text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.06);">Date</td>' +
'<td style="color:#ff2d2d;padding:6px 8px;font-size:10px;text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.06);">Time</td>' +
'<td style="color:#ff2d2d;padding:6px 8px;font-size:10px;text-transform:uppercase;border-bottom:1px solid rgba(255,255,255,0.06);">Instructor</td></tr>' +
upcomingRows + '</table>'
: '<p style="color:#444;font-size:13px;">No upcoming lessons scheduled</p>') +
'</td></tr></table>';
});
const unsubLink = '<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="mailto:' + escHtml_(CONFIG.SCHOOL_EMAIL) + '?subject=Unsubscribe%20Parent%20Progress%20Emails" style="color:#555;text-decoration:underline;">Unsubscribe from weekly updates</a></p>';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,\'SF Pro Display\',sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:20px;">' +
'<tr><td align="center">' +
'<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,0.5);">' +
'<tr><td style="padding:30px 40px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<table width="100%"><tr>' +
'<td style="color:#ff2d2d;font-size:22px;font-weight:700;">๐จโ๐ฉโ๐ง Parent Progress Report</td>' +
'<td align="right" style="color:#555;font-size:11px;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</td>' +
'</tr></table></td></tr>' +
'<tr><td style="padding:30px 40px;">' +
'<p style="color:#ccc;font-size:16px;line-height:1.6;">Hi <strong style="color:#fff;">' + escHtml_(parentName || 'there') + '</strong>,</p>' +
'<p style="color:#aaa;font-size:15px;line-height:1.6;">Here\'s this week\'s progress update for your child' + (children.length > 1 ? 'ren' : '') + ':</p>' +
childBlocks +
'<p style="color:#444;font-size:13px;margin-top:25px;line-height:1.5;">Questions? Reply to this email or call us anytime.<br>โ ' + escHtml_(CONFIG.SCHOOL_NAME) + ' Team</p>' +
'</td></tr>' +
'<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">' +
'<p style="color:#333;font-size:11px;margin:0;">Powered by Parent Dashboard โข <span style="color:#ff2d2d;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</span></p>' +
unsubLink +
'</td></tr></table></td></tr></table></body></html>';
}
// โโ PORTAL HTML โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function getPortalHTML_() {
const demoNote = CONFIG.DEMO_MODE
? '<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:10px 16px;margin-bottom:20px;text-align:center;"><span style="color:#ff6b6b;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;">โฆ Demo Mode โ Try [email protected] / 1234 or [email protected] / 9876</span></div>'
: '';
return '<!DOCTYPE html>\n' +
'<html lang="en">\n' +
'<head>\n' +
'<meta charset="utf-8">\n' +
'<meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
'<title>Parent Dashboard โ ' + escHtml_(CONFIG.SCHOOL_NAME) + '</title>\n' +
'<style>\n' +
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
' body {\n' +
' background: #000;\n' +
' color: #ccc;\n' +
' font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif;\n' +
' min-height: 100vh;\n' +
' }\n' +
' .container { max-width: 800px; margin: 0 auto; padding: 20px; }\n' +
' .header { text-align: center; padding: 40px 20px 30px; }\n' +
' .header h1 { color: #ff2d2d; font-size: 28px; font-weight: 700; letter-spacing: -0.5px; margin-bottom: 6px; }\n' +
' .header p { color: #555; font-size: 14px; }\n' +
' .school-badge {\n' +
' display: inline-block;\n' +
' background: rgba(255,255,255,0.03);\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' border-radius: 20px;\n' +
' padding: 6px 16px;\n' +
' color: #888;\n' +
' font-size: 12px;\n' +
' margin-bottom: 20px;\n' +
' }\n' +
' .search-card {\n' +
' background: #0d0d0d;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' border-radius: 20px;\n' +
' padding: 30px;\n' +
' margin-bottom: 25px;\n' +
' text-align: center;\n' +
' box-shadow: 0 8px 30px rgba(0,0,0,0.5);\n' +
' }\n' +
' .search-card .icon { font-size: 48px; margin-bottom: 15px; display: block; }\n' +
' .search-card p { color: #aaa; font-size: 15px; margin-bottom: 20px; line-height: 1.5; }\n' +
' .search-row {\n' +
' display: flex;\n' +
' gap: 10px;\n' +
' max-width: 500px;\n' +
' margin: 0 auto 10px;\n' +
' }\n' +
' .search-row input {\n' +
' flex: 1;\n' +
' background: #0a0a0a;\n' +
' border: 1px solid rgba(255,255,255,0.08);\n' +
' border-radius: 12px;\n' +
' padding: 14px 18px;\n' +
' color: #fff;\n' +
' font-size: 15px;\n' +
' outline: none;\n' +
' transition: border-color 0.3s;\n' +
' }\n' +
' .search-row input:focus { border-color: #ff2d2d; }\n' +
' .search-row input::placeholder { color: #444; }\n' +
' .search-row button, .btn-primary {\n' +
' background: linear-gradient(135deg, #ff2d2d, #cc0000);\n' +
' color: #fff;\n' +
' border: none;\n' +
' border-radius: 12px;\n' +
' padding: 14px 24px;\n' +
' font-size: 15px;\n' +
' font-weight: 600;\n' +
' cursor: pointer;\n' +
' transition: all 0.3s;\n' +
' white-space: nowrap;\n' +
' box-shadow: 0 4px 16px rgba(255,45,45,0.3);\n' +
' }\n' +
' .search-row button:hover, .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(255,45,45,0.4); }\n' +
' .search-row button:disabled { background: #333; cursor: not-allowed; transform: none; box-shadow: none; }\n' +
' .phone-row {\n' +
' display: flex;\n' +
' gap: 10px;\n' +
' max-width: 300px;\n' +
' margin: 0 auto;\n' +
' }\n' +
' .phone-row input {\n' +
' flex: 1;\n' +
' background: #0a0a0a;\n' +
' border: 1px solid rgba(255,255,255,0.08);\n' +
' border-radius: 12px;\n' +
' padding: 14px 18px;\n' +
' color: #fff;\n' +
' font-size: 15px;\n' +
' outline: none;\n' +
' text-align: center;\n' +
' letter-spacing: 4px;\n' +
' transition: border-color 0.3s;\n' +
' }\n' +
' .phone-row input:focus { border-color: #ff2d2d; }\n' +
' .phone-row input::placeholder { color: #444; letter-spacing: 1px; }\n' +
' .loading { text-align: center; padding: 40px; display: none; }\n' +
' .spinner {\n' +
' width: 40px; height: 40px;\n' +
' border: 3px solid rgba(255,255,255,0.06);\n' +
' border-top-color: #ff2d2d;\n' +
' border-radius: 50%;\n' +
' animation: spin 0.8s linear infinite;\n' +
' margin: 0 auto 15px;\n' +
' }\n' +
' @keyframes spin { to { transform: rotate(360deg); } }\n' +
' .error {\n' +
' background: rgba(255,45,45,0.06);\n' +
' border: 1px solid rgba(255,45,45,0.2);\n' +
' border-radius: 12px;\n' +
' padding: 16px 20px;\n' +
' color: #ff6b6b;\n' +
' text-align: center;\n' +
' margin: 15px 0;\n' +
' display: none;\n' +
' }\n' +
' .results { display: none; }\n' +
' .search-again {\n' +
' text-align: center;\n' +
' margin-bottom: 24px;\n' +
' }\n' +
' .btn-secondary {\n' +
' background: rgba(255,255,255,0.03);\n' +
' color: #ccc;\n' +
' border: 1px solid rgba(255,255,255,0.08);\n' +
' border-radius: 12px;\n' +
' padding: 12px 24px;\n' +
' font-size: 14px;\n' +
' font-weight: 500;\n' +
' cursor: pointer;\n' +
' transition: all 0.3s;\n' +
' }\n' +
' .btn-secondary:hover { background: rgba(255,255,255,0.06); border-color: #ff2d2d; color: #fff; }\n' +
' .child-card {\n' +
' background: #0d0d0d;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' border-radius: 20px;\n' +
' margin-bottom: 20px;\n' +
' overflow: hidden;\n' +
' transition: transform 0.2s;\n' +
' box-shadow: 0 8px 30px rgba(0,0,0,0.5);\n' +
' }\n' +
' .child-card:hover { transform: translateY(-2px); }\n' +
' .child-header {\n' +
' background: rgba(255,255,255,0.02);\n' +
' padding: 20px 24px;\n' +
' border-bottom: 1px solid rgba(255,255,255,0.06);\n' +
' display: flex;\n' +
' align-items: center;\n' +
' gap: 15px;\n' +
' }\n' +
' .child-avatar {\n' +
' width: 48px; height: 48px;\n' +
' background: linear-gradient(135deg, #ff2d2d, #cc0000);\n' +
' border-radius: 14px;\n' +
' display: flex;\n' +
' align-items: center;\n' +
' justify-content: center;\n' +
' font-size: 22px;\n' +
' color: #fff;\n' +
' font-weight: 700;\n' +
' box-shadow: 0 4px 15px rgba(255,45,45,0.3);\n' +
' }\n' +
' .child-name { color: #fff; font-size: 20px; font-weight: 600; }\n' +
' .child-package { color: #888; font-size: 13px; margin-top: 2px; }\n' +
' .child-body { padding: 24px; }\n' +
' .stats-grid {\n' +
' display: grid;\n' +
' grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));\n' +
' gap: 12px;\n' +
' margin-bottom: 24px;\n' +
' }\n' +
' .stat-bubble {\n' +
' background: #0a0a0a;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' border-radius: 14px;\n' +
' padding: 16px;\n' +
' text-align: center;\n' +
' transition: all 0.3s;\n' +
' }\n' +
' .stat-bubble:hover {\n' +
' transform: translateY(-3px) scale(1.02);\n' +
' border-color: #ff2d2d;\n' +
' box-shadow: 0 8px 25px rgba(255,45,45,0.15);\n' +
' }\n' +
' .stat-icon { font-size: 24px; margin-bottom: 8px; display: block; }\n' +
' .stat-value { color: #fff; font-size: 22px; font-weight: 700; display: block; }\n' +
' .stat-label { color: #555; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; margin-top: 4px; display: block; }\n' +
' .progress-section { margin-bottom: 24px; }\n' +
' .progress-label { display: flex; justify-content: space-between; margin-bottom: 8px; }\n' +
' .progress-label span { color: #888; font-size: 13px; }\n' +
' .progress-label strong { color: #fff; font-size: 13px; }\n' +
' .progress-track {\n' +
' background: #0a0a0a;\n' +
' border-radius: 8px;\n' +
' height: 20px;\n' +
' overflow: hidden;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' }\n' +
' .progress-fill {\n' +
' height: 100%;\n' +
' border-radius: 8px;\n' +
' transition: width 1s ease-out;\n' +
' text-align: center;\n' +
' line-height: 20px;\n' +
' font-size: 11px;\n' +
' font-weight: 600;\n' +
' color: #000;\n' +
' }\n' +
' .section-title {\n' +
' color: #ff2d2d;\n' +
' font-size: 12px;\n' +
' text-transform: uppercase;\n' +
' letter-spacing: 1px;\n' +
' margin: 24px 0 12px;\n' +
' padding-bottom: 8px;\n' +
' border-bottom: 1px solid rgba(255,255,255,0.06);\n' +
' }\n' +
' .lesson-item {\n' +
' display: flex;\n' +
' align-items: center;\n' +
' padding: 12px 14px;\n' +
' background: #0a0a0a;\n' +
' border-radius: 12px;\n' +
' margin-bottom: 8px;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' transition: all 0.2s;\n' +
' }\n' +
' .lesson-item:hover { border-color: rgba(255,255,255,0.1); transform: translateX(3px); }\n' +
' .lesson-dot {\n' +
' width: 8px; height: 8px;\n' +
' border-radius: 50%;\n' +
' margin-right: 12px;\n' +
' flex-shrink: 0;\n' +
' }\n' +
' .lesson-dot.upcoming { background: #3b82f6; box-shadow: 0 0 8px rgba(59,130,246,0.5); }\n' +
' .lesson-dot.completed { background: #22c55e; box-shadow: 0 0 8px rgba(34,197,94,0.5); }\n' +
' .lesson-dot.noshow { background: #ff2d2d; box-shadow: 0 0 8px rgba(255,45,45,0.5); }\n' +
' .lesson-info { flex: 1; }\n' +
' .lesson-date { color: #fff; font-size: 14px; font-weight: 500; }\n' +
' .lesson-detail { color: #555; font-size: 12px; margin-top: 2px; }\n' +
' .lesson-status {\n' +
' font-size: 11px;\n' +
' padding: 4px 10px;\n' +
' border-radius: 8px;\n' +
' font-weight: 600;\n' +
' text-transform: uppercase;\n' +
' letter-spacing: 0.5px;\n' +
' }\n' +
' .status-upcoming { background: rgba(59,130,246,0.1); color: #3b82f6; }\n' +
' .status-completed { background: rgba(34,197,94,0.1); color: #22c55e; }\n' +
' .status-noshow { background: rgba(255,45,45,0.1); color: #ff2d2d; }\n' +
' .balance-card {\n' +
' background: #0a0a0a;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' border-radius: 14px;\n' +
' padding: 20px;\n' +
' text-align: center;\n' +
' margin-bottom: 15px;\n' +
' }\n' +
' .balance-amount { font-size: 32px; font-weight: 700; margin: 8px 0; }\n' +
' .balance-paid { color: #22c55e; }\n' +
' .balance-due { color: #f59e0b; }\n' +
' .balance-subtitle { color: #555; font-size: 12px; }\n' +
' .payment-item {\n' +
' display: flex;\n' +
' justify-content: space-between;\n' +
' padding: 10px 14px;\n' +
' background: #0a0a0a;\n' +
' border-radius: 10px;\n' +
' margin-bottom: 6px;\n' +
' border: 1px solid rgba(255,255,255,0.06);\n' +
' }\n' +
' .payment-item .date { color: #aaa; font-size: 13px; }\n' +
' .payment-item .method { color: #555; font-size: 12px; }\n' +
' .payment-item .amount { color: #22c55e; font-size: 14px; font-weight: 600; }\n' +
' .empty-state { text-align: center; padding: 20px; color: #444; font-size: 14px; }\n' +
' .footer { text-align: center; padding: 30px 20px; color: #333; font-size: 12px; }\n' +
' .footer a { color: #ff2d2d; text-decoration: none; }\n' +
' @media (max-width: 600px) {\n' +
' .container { padding: 10px; }\n' +
' .search-row, .phone-row { flex-direction: column; }\n' +
' .stats-grid { grid-template-columns: repeat(2, 1fr); }\n' +
' .child-header { padding: 16px; }\n' +
' .child-body { padding: 16px; }\n' +
' }\n' +
'</style>\n' +
'</head>\n' +
'<body>\n' +
'<div class="container">\n' +
' <div class="header">\n' +
' <div class="school-badge">๐ ' + escHtml_(CONFIG.SCHOOL_NAME) + '</div>\n' +
' <h1>๐จโ๐ฉโ๐ง Parent Dashboard</h1>\n' +
' <p>Track your child\'s driving lesson progress</p>\n' +
' </div>\n' +
' ' + demoNote + '\n' +
' <div class="search-card" id="searchCard">\n' +
' <span class="icon">๐</span>\n' +
' <p>Enter your email address to view your child\'s progress, upcoming lessons, and payment status.</p>\n' +
' <div id="emailStep">\n' +
' <div class="search-row">\n' +
' <input type="email" id="emailInput" placeholder="Enter your email address..." onkeypress="if(event.key===\'Enter\')doEmailStep()">\n' +
' <button onclick="doEmailStep()" id="emailBtn">Continue</button>\n' +
' </div>\n' +
' </div>\n' +
' <div id="phoneStep" style="display:none;">\n' +
' <p style="color:#aaa;font-size:14px;margin-bottom:12px;">๐ For security, enter the <strong style="color:#fff;">last 4 digits</strong> of your phone number on file.</p>\n' +
' <div class="phone-row">\n' +
' <input type="text" id="phoneInput" placeholder="1234" maxlength="4" pattern="[0-9]{4}" inputmode="numeric" onkeypress="if(event.key===\'Enter\')doPhoneStep()">\n' +
' <button onclick="doPhoneStep()" id="phoneBtn" class="btn-primary">Verify</button>\n' +
' </div>\n' +
' </div>\n' +
' </div>\n' +
' <div class="error" id="errorMsg"></div>\n' +
' <div class="loading" id="loading">\n' +
' <div class="spinner"></div>\n' +
' <p style="color:#555;">Loading dashboard...</p>\n' +
' </div>\n' +
' <div class="results" id="results"></div>\n' +
' <div class="footer">\n' +
' <p>Questions? Contact us at <a href="mailto:' + escHtml_(CONFIG.SCHOOL_EMAIL) + '">' + escHtml_(CONFIG.SCHOOL_EMAIL) + '</a></p>\n' +
' <p style="margin-top:8px;">Powered by Parent Dashboard โข ' + escHtml_(CONFIG.SCHOOL_NAME) + '</p>\n' +
' </div>\n' +
'</div>\n' +
'<script>\n' +
'var currentEmail = "";\n' +
'\n' +
'function doEmailStep() {\n' +
' var email = document.getElementById("emailInput").value.trim();\n' +
' if (!email || !email.includes("@")) { showError("Please enter a valid email address."); return; }\n' +
' currentEmail = email;\n' +
' document.getElementById("errorMsg").style.display = "none";\n' +
' document.getElementById("emailStep").style.display = "none";\n' +
' document.getElementById("phoneStep").style.display = "block";\n' +
' document.getElementById("phoneInput").focus();\n' +
'}\n' +
'\n' +
'function doPhoneStep() {\n' +
' var code = document.getElementById("phoneInput").value.trim();\n' +
' if (!code || code.length !== 4 || !/^\\d{4}$/.test(code)) {\n' +
' showError("Please enter exactly 4 digits."); return;\n' +
' }\n' +
' document.getElementById("phoneBtn").disabled = true;\n' +
' document.getElementById("loading").style.display = "block";\n' +
' document.getElementById("errorMsg").style.display = "none";\n' +
'\n' +
' google.script.run\n' +
' .withSuccessHandler(function(res) {\n' +
' if (!res.verified) {\n' +
' document.getElementById("loading").style.display = "none";\n' +
' document.getElementById("phoneBtn").disabled = false;\n' +
' showError(res.error || "Verification failed.");\n' +
' return;\n' +
' }\n' +
' // Verified โ now fetch data\n' +
' google.script.run\n' +
' .withSuccessHandler(handleResult)\n' +
' .withFailureHandler(handleError)\n' +
' .lookupParent(currentEmail);\n' +
' })\n' +
' .withFailureHandler(handleError)\n' +
' .verifyPhone(currentEmail, code);\n' +
'}\n' +
'\n' +
'function handleResult(data) {\n' +
' document.getElementById("loading").style.display = "none";\n' +
' document.getElementById("phoneBtn").disabled = false;\n' +
' if (data.error) { showError(data.error); return; }\n' +
'\n' +
' var html = \'<div class="search-again"><button class="btn-secondary" onclick="searchAgain()">โ Search Again</button></div>\';\n' +
'\n' +
' data.children.forEach(function(child) {\n' +
' var p = child.progress;\n' +
' var barColor = p.percentage >= 75 ? "#22c55e" : p.percentage >= 40 ? "#f59e0b" : "#ff2d2d";\n' +
' var initial = child.name.charAt(0).toUpperCase();\n' +
'\n' +
' html += \'<div class="child-card">\';\n' +
' html += \'<div class="child-header">\';\n' +
' html += \'<div class="child-avatar">\' + esc(initial) + \'</div>\';\n' +
' html += \'<div><div class="child-name">\' + esc(child.name) + \'</div>\';\n' +
' html += \'<div class="child-package">\' + esc(child.registration.package || "Package not set") + \'</div></div>\';\n' +
' html += \'</div>\';\n' +
' html += \'<div class="child-body">\';\n' +
'\n' +
' html += \'<div class="stats-grid">\';\n' +
' html += statBubble("๐", p.completedLessons, "Completed");\n' +
' html += statBubble("๐
", p.remaining, "Remaining");\n' +
' html += statBubble("โ
", p.attendanceRate + "%", "Attendance");\n' +
' html += statBubble("๐ฐ", child.payments.balance > 0 ? "$" + child.payments.balance.toFixed(0) : "Paid", "Balance");\n' +
' html += \'</div>\';\n' +
'\n' +
' html += \'<div class="progress-section">\';\n' +
' html += \'<div class="progress-label"><span>Lesson Progress</span><strong>\' + p.completedLessons + " / " + p.totalLessons + \'</strong></div>\';\n' +
' html += \'<div class="progress-track"><div class="progress-fill" style="width:\' + Math.max(3, p.percentage) + "%;background:" + barColor + \'">\' + p.percentage + \'%</div></div>\';\n' +
' html += \'</div>\';\n' +
'\n' +
' if (p.noShows > 0) {\n' +
' html += \'<div style="background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.2);border-radius:10px;padding:10px 14px;margin-bottom:15px;">\';\n' +
' html += \'<span style="color:#ff2d2d;font-size:13px;">โ ๏ธ \' + p.noShows + \' no-show(s) recorded. Please ensure your child arrives on time.</span></div>\';\n' +
' }\n' +
'\n' +
' html += \'<div class="section-title">๐
Upcoming Lessons</div>\';\n' +
' if (child.upcoming.length > 0) {\n' +
' child.upcoming.forEach(function(u) {\n' +
' html += \'<div class="lesson-item">\';\n' +
' html += \'<div class="lesson-dot upcoming"></div>\';\n' +
' html += \'<div class="lesson-info"><div class="lesson-date">\' + esc(u.dayName) + ", " + esc(u.date) + \'</div>\';\n' +
' html += \'<div class="lesson-detail">\' + esc(u.time) + " โข " + esc(u.instructor) + \'</div></div>\';\n' +
' html += \'<span class="lesson-status status-upcoming">Upcoming</span>\';\n' +
' html += \'</div>\';\n' +
' });\n' +
' } else {\n' +
' html += \'<div class="empty-state">No upcoming lessons scheduled</div>\';\n' +
' }\n' +
'\n' +
' html += \'<div class="section-title">๐ Lesson History</div>\';\n' +
' if (child.lessons.length > 0) {\n' +
' child.lessons.slice(0, 10).forEach(function(l) {\n' +
' var isNoShow = l.status.toLowerCase() === "no show" || l.status.toLowerCase() === "no-show" || l.status.toLowerCase() === "noshow";\n' +
' var dotClass = isNoShow ? "noshow" : "completed";\n' +
' var statusClass = isNoShow ? "status-noshow" : "status-completed";\n' +
' html += \'<div class="lesson-item">\';\n' +
' html += \'<div class="lesson-dot \' + dotClass + \'"></div>\';\n' +
' html += \'<div class="lesson-info"><div class="lesson-date">\' + esc(l.date) + \'</div>\';\n' +
' html += \'<div class="lesson-detail">\' + esc(l.time) + " โข " + esc(l.instructor) + \'</div></div>\';\n' +
' html += \'<span class="lesson-status \' + statusClass + \'">\' + esc(l.status) + \'</span>\';\n' +
' html += \'</div>\';\n' +
' });\n' +
' if (child.lessons.length > 10) {\n' +
' html += \'<div class="empty-state">+ \' + (child.lessons.length - 10) + \' more lessons</div>\';\n' +
' }\n' +
' } else {\n' +
' html += \'<div class="empty-state">No lesson history yet</div>\';\n' +
' }\n' +
'\n' +
' html += \'<div class="section-title">๐ณ Payments</div>\';\n' +
' html += \'<div class="balance-card">\';\n' +
' html += \'<div class="balance-subtitle">CURRENT BALANCE</div>\';\n' +
' if (child.payments.balance > 0) {\n' +
' html += \'<div class="balance-amount balance-due">$\' + child.payments.balance.toFixed(2) + \'</div>\';\n' +
' html += \'<div class="balance-subtitle">$\' + child.payments.totalPaid.toFixed(2) + " paid of $" + child.payments.totalDue.toFixed(2) + \'</div>\';\n' +
' } else {\n' +
' html += \'<div class="balance-amount balance-paid">โ
Paid in Full</div>\';\n' +
' html += \'<div class="balance-subtitle">Total: $\' + child.payments.totalPaid.toFixed(2) + \'</div>\';\n' +
' }\n' +
' html += \'</div>\';\n' +
'\n' +
' if (child.payments.payments.length > 0) {\n' +
' child.payments.payments.forEach(function(pay) {\n' +
' html += \'<div class="payment-item">\';\n' +
' html += \'<div><div class="date">\' + esc(pay.date) + \'</div><div class="method">\' + esc(pay.method) + \'</div></div>\';\n' +
' html += \'<div class="amount">+$\' + pay.amount.toFixed(2) + \'</div>\';\n' +
' html += \'</div>\';\n' +
' });\n' +
' }\n' +
'\n' +
' html += \'</div></div>\';\n' +
' });\n' +
'\n' +
' document.getElementById("results").innerHTML = html;\n' +
' document.getElementById("results").style.display = "block";\n' +
' document.getElementById("searchCard").style.display = "none";\n' +
'}\n' +
'\n' +
'function searchAgain() {\n' +
' document.getElementById("results").style.display = "none";\n' +
' document.getElementById("results").innerHTML = "";\n' +
' document.getElementById("searchCard").style.display = "block";\n' +
' document.getElementById("emailStep").style.display = "block";\n' +
' document.getElementById("phoneStep").style.display = "none";\n' +
' document.getElementById("emailInput").value = "";\n' +
' document.getElementById("phoneInput").value = "";\n' +
' document.getElementById("errorMsg").style.display = "none";\n' +
' currentEmail = "";\n' +
'}\n' +
'\n' +
'function handleError(err) {\n' +
' document.getElementById("loading").style.display = "none";\n' +
' document.getElementById("phoneBtn").disabled = false;\n' +
' showError("Something went wrong. Please try again.");\n' +
'}\n' +
'\n' +
'function showError(msg) {\n' +
' var el = document.getElementById("errorMsg");\n' +
' el.textContent = msg;\n' +
' el.style.display = "block";\n' +
'}\n' +
'\n' +
'function esc(s) {\n' +
' if (!s) return "";\n' +
' var d = document.createElement("div");\n' +
' d.appendChild(document.createTextNode(String(s)));\n' +
' return d.innerHTML;\n' +
'}\n' +
'\n' +
'function statBubble(icon, value, label) {\n' +
' return \'<div class="stat-bubble">\'\n' +
' + \'<span class="stat-icon">\' + icon + \'</span>\'\n' +
' + \'<span class="stat-value">\' + value + \'</span>\'\n' +
' + \'<span class="stat-label">\' + label + \'</span>\'\n' +
' + \'</div>\';\n' +
'}\n' +
'</script>\n' +
'</body>\n' +
'</html>';
}
// ================================================================
// FUZZY NAME MATCHING
// ================================================================
function fuzzyNameMatch_(name1, name2) {
if (!name1 || !name2) return false;
const n1 = name1.toLowerCase().replace(/\s+/g, ' ').trim();
const n2 = name2.toLowerCase().replace(/\s+/g, ' ').trim();
if (n1 === n2) return true;
if (n1.includes(n2) || n2.includes(n1)) return true;
const p1 = n1.split(' ').filter(Boolean);
const p2 = n2.split(' ').filter(Boolean);
if (p1.length >= 2 && p2.length >= 2) {
if (p1[p1.length-1] === p2[p2.length-1] && p1[0].substring(0,3) === p2[0].substring(0,3)) return true;
if (p1[0] === p2[p2.length-1] && p1[p1.length-1] === p2[0]) return true;
}
if (levenshtein_(n1, n2) <= 2) return true;
return false;
}
function levenshtein_(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
m[i][j] = b.charAt(i-1) === a.charAt(j-1) ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
}
}
return m[b.length][a.length];
}
// ================================================================
// UTILITY FUNCTIONS
// ================================================================
function findCol_(headers, candidates) {
for (const c of candidates) {
const idx = headers.findIndex(h => h.includes(c.toLowerCase()));
if (idx !== -1) return idx;
}
return -1;
}
function escHtml_(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function sanitize_(input) {
if (!input) return '';
return String(input).trim().replace(/[<>{}()\[\]\\\/]/g, '').replace(/\s+/g, ' ').substring(0, 200);
}
function formatDate_(date) {
if (!date) return '';
if (!(date instanceof Date)) date = new Date(date);
if (isNaN(date.getTime())) return '';
return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear();
}
function extractLessonCount_(pkg) {
if (!pkg) return 0;
const match = pkg.toString().match(/(\d+)\s*lesson/i);
if (match) return parseInt(match[1]);
const match2 = pkg.toString().match(/^(\d+)$/);
if (match2) return parseInt(match2[1]);
const lower = pkg.toString().toLowerCase();
if (lower.includes('beginner')) return 10;
if (lower.includes('standard')) return 10;
if (lower.includes('premium')) return 15;
if (lower.includes('intensive')) return 20;
return 0;
}
/**
* =========================================================
* PAYMENT TRACKER
* Flavors Driving School
* =========================================================
* Tracks all student payments, balances, and payment plans.
*
* - Records payments with receipt emails
* - Auto-imports students from Registration + 5-Hour Signups
* - Per-student balance tracking with status colors
* - Payment plan management with installment tracking
* - Weekly overdue alerts (admin + student reminders)
* - 7-day friendly payment reminders (before overdue)
* - Refund processing
* - Student payment history API (for Student Portal)
* - Revenue trend tracking
* - Sync to Registration sheet
* - Dashboard with live stats
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
SIGNUPS_SHEET_ID: '1lE2_amcNj3-ao3TiP_U0VpaWHqmx4R-0rkF3n3xl7fM',
// โโ Sheet tabs โโ
REGISTRATION_SHEET_TAB: '',
SIGNUPS_SHEET_TAB: '',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
SCHOOL_PHONE: '(718) 555-0100',
TIMEZONE: 'America/New_York',
PACKAGES: {
'3 Lessons': 250,
'5 Lessons': 445,
'10 Lessons': 710,
'15 Lessons': 950,
'25 Lessons': 1500,
'5-Hour Class': 65,
'Beginner': 710,
'Standard': 710,
'Premium': 950,
'Intensive': 1500
},
PAYMENT_PLAN_THRESHOLD: 445,
PAYMENT_METHODS: ['Cash', 'Zelle', 'Credit Card', 'Check', 'Other'],
OVERDUE_DAYS: 14,
REMINDER_DAYS: 7,
// โโ Demo mode โโ
DEMO_MODE: true
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO = {
payment: {
id: 'PAY-0042',
student: 'Sarah Johnson',
email: '[email protected]',
amount: 250,
method: 'Zelle',
totalPaid: 500,
balance: 210,
pkg: '10 Lessons'
},
dashboard: {
totalStudents: 24,
totalOwed: 14580,
totalCollected: 9850,
outstanding: 4730,
collectionRate: '67.6%',
paidInFull: 12,
partial: 8,
unpaid: 4,
overdue: 3
},
overdueStudents: [
{ name: 'Mike Rivera', balance: 460, days: '21 days', phone: '718-555-0456' },
{ name: 'David Chen', balance: 710, days: '18 days', phone: '917-555-0789' },
{ name: 'Lisa Thompson', balance: 65, days: 'Never paid', phone: '646-555-0321' }
]
};
/* ================================================================
SETUP & AUTH
================================================================ */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID).getSheetByName('test_auth_ignore');
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
// โโ Payments sheet โโ
let paySheet = ss.getSheetByName('Payments');
if (!paySheet) {
paySheet = ss.insertSheet('Payments');
paySheet.appendRow([
'Payment ID', 'Timestamp', 'Student Name', 'Student Email', 'Student Phone',
'Package', 'Payment Amount', 'Payment Method', 'Notes',
'Recorded By', 'Plan Payment #', 'Type'
]);
styleHeader_(paySheet, 12);
Logger.log('โ
Created "Payments" sheet.');
}
// โโ Balances sheet โโ
let balSheet = ss.getSheetByName('Balances');
if (!balSheet) {
balSheet = ss.insertSheet('Balances');
balSheet.appendRow([
'Student Name', 'Email', 'Phone', 'Package', 'Total Owed',
'Total Paid', 'Balance Due', 'Status', 'On Payment Plan',
'Last Payment Date', 'Days Since Payment', 'Reminder Sent'
]);
styleHeader_(balSheet, 12);
Logger.log('โ
Created "Balances" sheet.');
}
// โโ Payment Plans sheet โโ
let planSheet = ss.getSheetByName('Payment Plans');
if (!planSheet) {
planSheet = ss.insertSheet('Payment Plans');
planSheet.appendRow([
'Student Name', 'Email', 'Package', 'Total Owed', 'Number of Installments',
'Installment Amount', 'Installments Paid', 'Remaining',
'Next Due Date', 'Status'
]);
styleHeader_(planSheet, 10);
Logger.log('โ
Created "Payment Plans" sheet.');
}
// โโ Dashboard sheet โโ
let dashSheet = ss.getSheetByName('Dashboard');
if (!dashSheet) {
ss.insertSheet('Dashboard');
Logger.log('โ
Created "Dashboard" sheet.');
}
// โโ Revenue Log sheet โโ
let revSheet = ss.getSheetByName('Revenue Log');
if (!revSheet) {
revSheet = ss.insertSheet('Revenue Log');
revSheet.appendRow(['Month', 'Year', 'Revenue Collected', 'New Students', 'Payments Count']);
styleHeader_(revSheet, 5);
Logger.log('โ
Created "Revenue Log" sheet.');
}
// โโ Import students โโ
importStudents_();
// โโ Triggers (clean old first) โโ
ScriptApp.getProjectTriggers().forEach(t => {
const fn = t.getHandlerFunction();
if (['checkOverdue', 'sendPaymentReminders', 'refreshDashboard'].includes(fn)) {
ScriptApp.deleteTrigger(t);
}
});
// Monday 9 AM โ overdue check
ScriptApp.newTrigger('checkOverdue')
.timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(9).nearMinute(0).create();
// Thursday 10 AM โ friendly reminders (before they go overdue)
ScriptApp.newTrigger('sendPaymentReminders')
.timeBased().onWeekDay(ScriptApp.WeekDay.THURSDAY).atHour(10).nearMinute(0).create();
refreshDashboard();
Logger.log('โ
Payment Tracker setup complete.');
Logger.log('๐ Triggers: Overdue check Monday 9 AM, Payment reminders Thursday 10 AM.');
}
function styleHeader_(sheet, numCols) {
sheet.getRange(1, 1, 1, numCols).setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontWeight('bold').setFontSize(10);
sheet.setFrozenRows(1);
}
/* ================================================================
IMPORT STUDENTS
================================================================ */
function importStudents_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const balSheet = ss.getSheetByName('Balances');
if (!balSheet) return 0;
// Build existing index by email + name
const existingData = balSheet.getLastRow() > 1 ? balSheet.getRange(2, 1, balSheet.getLastRow() - 1, balSheet.getLastColumn()).getValues() : [];
const existing = new Set();
for (const row of existingData) {
existing.add(String(row[0] || '').toLowerCase().trim()); // name
if (row[1]) existing.add(String(row[1]).toLowerCase().trim()); // email
}
let imported = 0;
// โโ Registration sheet โโ
try {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const regSheet = CONFIG.REGISTRATION_SHEET_TAB
? (regSS.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB) || regSS.getSheets()[0])
: regSS.getSheets()[0];
if (regSheet && regSheet.getLastRow() > 1) {
const data = regSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nCol = findCol_(h, ['student name', 'full name', 'name']);
const eCol = findCol_(h, ['email', 'student email', 'email address']);
const pCol = findCol_(h, ['phone', 'phone number', 'mobile']);
const pkgCol = findCol_(h, ['package', 'lesson package', 'program']);
for (let i = 1; i < data.length; i++) {
const name = sanitize_(String(data[i][nCol] || '').trim());
if (!name) continue;
const email = eCol >= 0 ? String(data[i][eCol] || '').trim().toLowerCase() : '';
// Dedup by email-first, then name
if (email && existing.has(email)) continue;
if (existing.has(name.toLowerCase())) continue;
const phone = pCol >= 0 ? String(data[i][pCol] || '').trim() : '';
const pkgRaw = pkgCol >= 0 ? String(data[i][pkgCol] || '') : '';
const { pkg, owed } = resolvePackage_(pkgRaw);
balSheet.appendRow([name, email, phone, pkg, owed, 0, owed, owed > 0 ? 'Unpaid' : 'N/A', 'No', '', '', '']);
existing.add(name.toLowerCase());
if (email) existing.add(email);
imported++;
}
}
} catch (e) {
Logger.log('Registration import error: ' + e.message);
}
// โโ 5-Hour Signups โโ
try {
const sigSS = SpreadsheetApp.openById(CONFIG.SIGNUPS_SHEET_ID);
const sigSheet = CONFIG.SIGNUPS_SHEET_TAB
? (sigSS.getSheetByName(CONFIG.SIGNUPS_SHEET_TAB) || sigSS.getSheets()[0])
: sigSS.getSheets()[0];
if (sigSheet && sigSheet.getLastRow() > 1) {
const data = sigSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nCol = findCol_(h, ['full name', 'student name', 'name']);
const eCol = findCol_(h, ['email', 'student email']);
const pCol = findCol_(h, ['phone', 'phone number', 'mobile']);
for (let i = 1; i < data.length; i++) {
const name = sanitize_(String(data[i][nCol] || '').trim());
if (!name) continue;
const email = eCol >= 0 ? String(data[i][eCol] || '').trim().toLowerCase() : '';
if (email && existing.has(email)) continue;
if (existing.has(name.toLowerCase())) continue;
const phone = pCol >= 0 ? String(data[i][pCol] || '').trim() : '';
balSheet.appendRow([name, email, phone, '5-Hour Class', 65, 0, 65, 'Unpaid', 'No', '', '', '']);
existing.add(name.toLowerCase());
if (email) existing.add(email);
imported++;
}
}
} catch (e) {
Logger.log('Signups import error: ' + e.message);
}
Logger.log('โ
Imported ' + imported + ' student(s).');
return imported;
}
function resolvePackage_(pkgRaw) {
const raw = String(pkgRaw || '').toLowerCase().trim();
if (!raw) return { pkg: 'Unknown', owed: 0 };
// Sort by longest key first to match "5-Hour Class" before "5 Lessons"
const keys = Object.keys(CONFIG.PACKAGES).sort((a, b) => b.length - a.length);
for (const key of keys) {
if (raw.includes(key.toLowerCase())) return { pkg: key, owed: CONFIG.PACKAGES[key] };
}
// Try numeric extraction
const numMatch = raw.match(/(\d+)/);
if (numMatch) {
const num = numMatch[1];
for (const key of keys) {
if (key.includes(num)) return { pkg: key, owed: CONFIG.PACKAGES[key] };
}
}
return { pkg: pkgRaw || 'Unknown', owed: 0 };
}
/* ================================================================
RECORD PAYMENT
================================================================ */
function recordPayment(studentName, amount, method, notes, planPaymentNum) {
if (CONFIG.DEMO_MODE) {
const d = DEMO.payment;
Logger.log('๐ญ DEMO MODE โ would record: $' + amount + ' from ' + studentName + ' via ' + method);
return { success: true, paymentId: d.id, message: '๐ญ Demo payment recorded.' };
}
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const paySheet = ss.getSheetByName('Payments');
const balSheet = ss.getSheetByName('Balances');
if (!paySheet || !balSheet) return { success: false, message: 'Payments or Balances sheet not found.' };
// Sanitize
studentName = sanitize_(studentName);
method = sanitize_(method || 'Cash');
notes = sanitize_(notes || '');
amount = parseFloat(String(amount).replace(/[$,]/g, '')) || 0;
if (amount <= 0) return { success: false, message: 'Invalid payment amount.' };
// โโ Find student (email-first + fuzzy name) โโ
const balData = balSheet.getDataRange().getValues();
const balHeaders = balData[0].map(h => String(h).toLowerCase().trim());
const bNameCol = findCol_(balHeaders, ['student name', 'name']);
const bEmailCol = findCol_(balHeaders, ['email']);
const bPhoneCol = findCol_(balHeaders, ['phone']);
const bPkgCol = findCol_(balHeaders, ['package']);
const bOwedCol = findCol_(balHeaders, ['total owed']);
const bPaidCol = findCol_(balHeaders, ['total paid']);
const bBalCol = findCol_(balHeaders, ['balance due']);
const bStatCol = findCol_(balHeaders, ['status']);
const bLastCol = findCol_(balHeaders, ['last payment date']);
const bDaysCol = findCol_(balHeaders, ['days since payment']);
let studentRow = -1;
const nameLower = studentName.toLowerCase().trim();
// Try exact name match first
for (let i = 1; i < balData.length; i++) {
if (String(balData[i][bNameCol] || '').toLowerCase().trim() === nameLower) {
studentRow = i + 1;
break;
}
}
// Fuzzy name fallback
if (studentRow === -1) {
for (let i = 1; i < balData.length; i++) {
const rowName = String(balData[i][bNameCol] || '').toLowerCase().trim();
if (levenshtein_(rowName, nameLower) <= 2) {
studentRow = i + 1;
break;
}
}
}
if (studentRow === -1) return { success: false, message: 'Student not found: ' + studentName + '. Import them first.' };
const row = balData[studentRow - 1];
const studentEmail = bEmailCol >= 0 ? String(row[bEmailCol] || '') : '';
const studentPhone = bPhoneCol >= 0 ? String(row[bPhoneCol] || '') : '';
const pkg = bPkgCol >= 0 ? String(row[bPkgCol] || '') : '';
const totalOwed = bOwedCol >= 0 ? (parseFloat(row[bOwedCol]) || 0) : 0;
const prevPaid = bPaidCol >= 0 ? (parseFloat(row[bPaidCol]) || 0) : 0;
const totalPaid = prevPaid + amount;
const balanceDue = Math.max(0, totalOwed - totalPaid);
// Overpayment warning
if (totalPaid > totalOwed && totalOwed > 0) {
Logger.log('โ ๏ธ Overpayment: ' + studentName + ' paid $' + totalPaid.toFixed(2) + ' but only owes $' + totalOwed.toFixed(2));
}
// Generate short payment ID
const payId = generatePaymentId_(paySheet);
try {
paySheet.appendRow([
payId, new Date(), studentName, studentEmail, studentPhone,
pkg, amount, method, notes, 'System', planPaymentNum || '', 'Payment'
]);
// Update Balances
if (bPaidCol >= 0) balSheet.getRange(studentRow, bPaidCol + 1).setValue(totalPaid);
if (bBalCol >= 0) balSheet.getRange(studentRow, bBalCol + 1).setValue(balanceDue);
const newStatus = balanceDue <= 0 ? 'Paid in Full' : 'Partial';
if (bStatCol >= 0) {
const statusCell = balSheet.getRange(studentRow, bStatCol + 1);
statusCell.setValue(newStatus);
statusCell.setBackground(balanceDue <= 0 ? '#0a2e0a' : '#2e1a0a');
statusCell.setFontColor(balanceDue <= 0 ? '#22c55e' : '#ff9900');
}
if (bLastCol >= 0) balSheet.getRange(studentRow, bLastCol + 1).setValue(new Date());
if (bDaysCol >= 0) balSheet.getRange(studentRow, bDaysCol + 1).setValue(0);
updatePaymentPlan_(ss, studentName, amount);
sendPaymentReceipt_(payId, studentName, studentEmail, amount, method, totalPaid, balanceDue, pkg);
syncToRegistration_(studentName, studentEmail, totalPaid, balanceDue, method);
updateRevenue_(ss, amount);
try { refreshDashboard(); } catch (_) {}
return {
success: true,
paymentId: payId,
message: 'โ
Payment recorded: ' + studentName + ' paid $' + amount.toFixed(2) + ' via ' + method + '. Balance: $' + balanceDue.toFixed(2)
};
} catch (err) {
Logger.log('recordPayment error: ' + (err.message || err));
notifyAdmin_('Payment Error', 'recordPayment failed for ' + studentName + ': ' + String(err.message || err).substring(0, 500));
return { success: false, message: 'Payment could not be saved: ' + (err.message || String(err)) };
}
}
/* ================================================================
REFUND
================================================================ */
function processRefund(paymentId, refundAmount, reason) {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would refund $' + refundAmount + ' for ' + paymentId);
return { success: true, message: '๐ญ Demo refund.' };
}
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const paySheet = ss.getSheetByName('Payments');
const balSheet = ss.getSheetByName('Balances');
if (!paySheet || !balSheet) return { success: false, message: 'Sheets not found.' };
refundAmount = parseFloat(String(refundAmount).replace(/[$,]/g, '')) || 0;
if (refundAmount <= 0) return { success: false, message: 'Invalid refund amount.' };
// Find original payment
const payData = paySheet.getDataRange().getValues();
const payHeaders = payData[0].map(h => String(h).toLowerCase().trim());
const pIdCol = findCol_(payHeaders, ['payment id', 'id']);
const pNameCol = findCol_(payHeaders, ['student name', 'student']);
const pEmailCol = findCol_(payHeaders, ['student email', 'email']);
const pPhoneCol = findCol_(payHeaders, ['student phone', 'phone']);
const pPkgCol = findCol_(payHeaders, ['package']);
let originalRow = null;
for (let i = 1; i < payData.length; i++) {
if (String(payData[i][pIdCol] || '').trim() === paymentId) {
originalRow = payData[i];
break;
}
}
if (!originalRow) return { success: false, message: 'Payment ' + paymentId + ' not found.' };
const studentName = String(originalRow[pNameCol] || '');
const studentEmail = pEmailCol >= 0 ? String(originalRow[pEmailCol] || '') : '';
const studentPhone = pPhoneCol >= 0 ? String(originalRow[pPhoneCol] || '') : '';
const pkg = pPkgCol >= 0 ? String(originalRow[pPkgCol] || '') : '';
// Record refund as negative payment
const refundId = generatePaymentId_(paySheet);
paySheet.appendRow([
refundId, new Date(), studentName, studentEmail, studentPhone,
pkg, -refundAmount, 'Refund', 'Refund for ' + paymentId + ': ' + sanitize_(reason || ''),
'System', '', 'Refund'
]);
// Update Balances
const balData = balSheet.getDataRange().getValues();
const balHeaders = balData[0].map(h => String(h).toLowerCase().trim());
const bNameCol = findCol_(balHeaders, ['student name', 'name']);
const bPaidCol = findCol_(balHeaders, ['total paid']);
const bBalCol = findCol_(balHeaders, ['balance due']);
const bOwedCol = findCol_(balHeaders, ['total owed']);
const bStatCol = findCol_(balHeaders, ['status']);
for (let i = 1; i < balData.length; i++) {
if (String(balData[i][bNameCol] || '').toLowerCase().trim() === studentName.toLowerCase().trim()) {
const row = i + 1;
const newPaid = Math.max(0, (parseFloat(balData[i][bPaidCol]) || 0) - refundAmount);
const owed = parseFloat(balData[i][bOwedCol]) || 0;
const newBal = Math.max(0, owed - newPaid);
balSheet.getRange(row, bPaidCol + 1).setValue(newPaid);
balSheet.getRange(row, bBalCol + 1).setValue(newBal);
if (bStatCol >= 0) {
const status = newBal <= 0 ? 'Paid in Full' : (newPaid > 0 ? 'Partial' : 'Unpaid');
balSheet.getRange(row, bStatCol + 1).setValue(status);
}
break;
}
}
// Notify
sendRefundEmail_(refundId, studentName, studentEmail, refundAmount, reason || '', paymentId);
Logger.log('โ
Refund processed: $' + refundAmount.toFixed(2) + ' for ' + studentName + ' (original: ' + paymentId + ')');
try { refreshDashboard(); } catch (_) {}
return { success: true, refundId: refundId, message: 'โ
Refund of $' + refundAmount.toFixed(2) + ' processed for ' + studentName };
}
/* ================================================================
PAYMENT HISTORY API (for Student Portal)
================================================================ */
function getPaymentHistory(email) {
if (CONFIG.DEMO_MODE) {
return [
{ id: 'PAY-0042', date: '02/19/2026', amount: 250, method: 'Zelle', balance: 210, type: 'Payment' },
{ id: 'PAY-0038', date: '02/10/2026', amount: 250, method: 'Cash', balance: 460, type: 'Payment' }
];
}
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const paySheet = ss.getSheetByName('Payments');
if (!paySheet || paySheet.getLastRow() < 2) return [];
const data = paySheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const idCol = findCol_(h, ['payment id', 'id']);
const dateCol = findCol_(h, ['timestamp', 'date']);
const emailCol = findCol_(h, ['student email', 'email']);
const amtCol = findCol_(h, ['payment amount', 'amount']);
const methCol = findCol_(h, ['payment method', 'method']);
const typeCol = findCol_(h, ['type']);
const emailLower = String(email || '').toLowerCase().trim();
const results = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
if (emailCol < 0 || String(row[emailCol] || '').toLowerCase().trim() !== emailLower) continue;
const dateVal = dateCol >= 0 ? row[dateCol] : '';
let dateStr = '';
if (dateVal instanceof Date && !isNaN(dateVal.getTime())) {
dateStr = Utilities.formatDate(dateVal, CONFIG.TIMEZONE, 'MM/dd/yyyy');
}
results.push({
id: idCol >= 0 ? String(row[idCol] || '') : '',
date: dateStr,
amount: amtCol >= 0 ? parseFloat(row[amtCol]) || 0 : 0,
method: methCol >= 0 ? String(row[methCol] || '') : '',
type: typeCol >= 0 ? String(row[typeCol] || 'Payment') : 'Payment'
});
}
results.sort((a, b) => {
const da = new Date(a.date), db = new Date(b.date);
return db - da;
});
return results;
}
/* ================================================================
PAYMENT PLANS
================================================================ */
function setupPaymentPlan(studentName, numInstallments) {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would create payment plan for ' + studentName);
return { success: true, message: '๐ญ Demo plan created.' };
}
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const balSheet = ss.getSheetByName('Balances');
const planSheet = ss.getSheetByName('Payment Plans');
if (!balSheet || !planSheet) return { success: false, message: 'Sheets not found.' };
const balData = balSheet.getDataRange().getValues();
const balHeaders = balData[0].map(h => String(h).toLowerCase().trim());
const bNameCol = findCol_(balHeaders, ['student name', 'name']);
const bEmailCol = findCol_(balHeaders, ['email']);
const bBalCol = findCol_(balHeaders, ['balance due']);
const bPkgCol = findCol_(balHeaders, ['package']);
const bPlanCol = findCol_(balHeaders, ['on payment plan']);
let studentRow = -1;
let balanceDue = 0, pkg = '', email = '';
const nameLower = String(studentName).toLowerCase().trim();
for (let i = 1; i < balData.length; i++) {
if (String(balData[i][bNameCol] || '').toLowerCase().trim() === nameLower ||
levenshtein_(String(balData[i][bNameCol] || '').toLowerCase().trim(), nameLower) <= 2) {
studentRow = i + 1;
balanceDue = parseFloat(balData[i][bBalCol]) || 0;
pkg = String(balData[i][bPkgCol] || '');
email = bEmailCol >= 0 ? String(balData[i][bEmailCol] || '') : '';
break;
}
}
if (studentRow === -1) return { success: false, message: 'Student not found.' };
if (balanceDue <= 0) return { success: false, message: 'No balance due.' };
// Prevent duplicate active plan
const planData = planSheet.getDataRange().getValues();
for (let p = 1; p < planData.length; p++) {
if (String(planData[p][0] || '').toLowerCase().trim() === nameLower &&
String(planData[p][planData[0].length - 1] || '').toLowerCase() === 'active') {
return { success: false, message: 'Student already has an active payment plan.' };
}
}
const installmentAmount = Math.ceil(balanceDue / numInstallments * 100) / 100;
const nextDue = new Date();
nextDue.setDate(nextDue.getDate() + 14);
planSheet.appendRow([
studentName, email, pkg, balanceDue, numInstallments,
installmentAmount, 0, balanceDue, nextDue, 'Active'
]);
if (bPlanCol >= 0) balSheet.getRange(studentRow, bPlanCol + 1).setValue('Yes');
Logger.log('โ
Payment plan: ' + studentName + ' โ ' + numInstallments + ' x $' + installmentAmount.toFixed(2));
return { success: true, message: 'Plan created: ' + numInstallments + ' payments of $' + installmentAmount.toFixed(2) + ' every 2 weeks.' };
}
function updatePaymentPlan_(ss, studentName, paymentAmount) {
const planSheet = ss.getSheetByName('Payment Plans');
if (!planSheet || planSheet.getLastRow() < 2) return;
const data = planSheet.getDataRange().getValues();
const nameLower = String(studentName).toLowerCase().trim();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0] || '').toLowerCase().trim() !== nameLower) continue;
const statusCol = data[0].length - 1;
if (String(data[i][statusCol] || '').toLowerCase() !== 'active') continue;
const row = i + 1;
const installmentAmount = parseFloat(data[i][5]) || 0;
const remainingBefore = parseFloat(data[i][7]) || 0;
const remaining = Math.max(0, remainingBefore - paymentAmount);
let installmentsPaid = parseInt(data[i][6], 10) || 0;
if (installmentAmount > 0) {
installmentsPaid += Math.min(
Math.floor(paymentAmount / installmentAmount),
Math.ceil(remainingBefore / installmentAmount)
);
} else {
installmentsPaid += 1;
}
planSheet.getRange(row, 7).setValue(installmentsPaid);
planSheet.getRange(row, 8).setValue(remaining);
if (remaining > 0) {
const nextDue = new Date();
nextDue.setDate(nextDue.getDate() + 14);
planSheet.getRange(row, 9).setValue(nextDue);
planSheet.getRange(row, 10).setValue('Active');
} else {
planSheet.getRange(row, 9).setValue('');
planSheet.getRange(row, 10).setValue('Completed');
}
break;
}
}
/* ================================================================
REVENUE TRACKING
================================================================ */
function updateRevenue_(ss, amount) {
try {
const revSheet = ss.getSheetByName('Revenue Log');
if (!revSheet) return;
const now = new Date();
const month = Utilities.formatDate(now, CONFIG.TIMEZONE, 'MMMM');
const year = Utilities.formatDate(now, CONFIG.TIMEZONE, 'yyyy');
const data = revSheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
if (String(data[i][0]) === month && String(data[i][1]) === year) {
const row = i + 1;
revSheet.getRange(row, 3).setValue((parseFloat(data[i][2]) || 0) + amount);
revSheet.getRange(row, 5).setValue((parseInt(data[i][4], 10) || 0) + 1);
return;
}
}
// New month
revSheet.appendRow([month, year, amount, 0, 1]);
} catch (e) {
Logger.log('Revenue tracking error: ' + e.message);
}
}
/* ================================================================
DASHBOARD
================================================================ */
function refreshDashboard() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
let dashSheet = ss.getSheetByName('Dashboard');
if (!dashSheet) dashSheet = ss.insertSheet('Dashboard');
dashSheet.clear();
const balSheet = ss.getSheetByName('Balances');
if (!balSheet || balSheet.getLastRow() < 2) return;
const balData = balSheet.getDataRange().getValues();
const h = balData[0].map(c => String(c || '').toLowerCase().trim());
const bNameCol = findCol_(h, ['student name', 'name']);
const bOwedCol = findCol_(h, ['total owed']);
const bPaidCol = findCol_(h, ['total paid']);
const bBalCol = findCol_(h, ['balance due']);
const bStatCol = findCol_(h, ['status']);
const bPlanCol = findCol_(h, ['on payment plan']);
const bLastCol = findCol_(h, ['last payment date']);
const bDaysCol = findCol_(h, ['days since payment']);
let totalStudents = 0, totalOwed = 0, totalPaid = 0, totalBalance = 0;
let paidInFull = 0, partial = 0, unpaid = 0, overdue = 0, onPlan = 0;
const overdueStudents = [];
const now = new Date();
for (let i = 1; i < balData.length; i++) {
const row = balData[i];
const name = bNameCol >= 0 ? String(row[bNameCol] || '').trim() : '';
if (!name) continue;
totalStudents++;
const owed = bOwedCol >= 0 ? (parseFloat(row[bOwedCol]) || 0) : 0;
const paid = bPaidCol >= 0 ? (parseFloat(row[bPaidCol]) || 0) : 0;
const balance = bBalCol >= 0 ? (parseFloat(row[bBalCol]) || 0) : 0;
const status = bStatCol >= 0 ? String(row[bStatCol] || '').toLowerCase() : '';
const plan = bPlanCol >= 0 ? String(row[bPlanCol] || '').toLowerCase() : '';
totalOwed += owed;
totalPaid += paid;
totalBalance += balance;
if (status === 'paid in full') paidInFull++;
else if (status === 'partial') partial++;
else unpaid++;
if (plan === 'yes') onPlan++;
if (balance > 0) {
const lastPay = bLastCol >= 0 ? row[bLastCol] : null;
let daysSince = 999;
if (lastPay instanceof Date && !isNaN(lastPay.getTime())) {
daysSince = Math.floor((now - lastPay) / 86400000);
if (bDaysCol >= 0) balSheet.getRange(i + 1, bDaysCol + 1).setValue(daysSince);
}
if (daysSince > CONFIG.OVERDUE_DAYS || (!(lastPay instanceof Date) && balance > 0 && owed > 0)) {
overdue++;
overdueStudents.push({
name: name,
balance: balance,
days: daysSince === 999 ? 'Never paid' : daysSince + ' days'
});
}
}
}
const tz = CONFIG.TIMEZONE;
const rows = [
['', ''],
[' ๐ PAYMENT DASHBOARD', ''],
['', ''],
[' Total Students', totalStudents],
[' Total Revenue Owed', '$' + totalOwed.toFixed(2)],
[' Total Collected', '$' + totalPaid.toFixed(2)],
[' Outstanding Balance', '$' + totalBalance.toFixed(2)],
[' Collection Rate', totalOwed > 0 ? (totalPaid / totalOwed * 100).toFixed(1) + '%' : '0%'],
['', ''],
[' ๐ STATUS BREAKDOWN', ''],
['', ''],
[' โ
Paid in Full', paidInFull],
[' ๐ก Partial Payment', partial],
[' ๐ด Unpaid', unpaid],
[' โ ๏ธ Overdue (>' + CONFIG.OVERDUE_DAYS + ' days)', overdue],
[' ๐
On Payment Plan', onPlan],
['', ''],
[' โ ๏ธ OVERDUE STUDENTS', ''],
['', '']
];
for (const o of overdueStudents) {
rows.push([' ' + o.name, '$' + o.balance.toFixed(2) + ' (' + o.days + ')']);
}
if (overdueStudents.length === 0) rows.push([' No overdue students ๐', '']);
rows.push(['', '']);
rows.push([' Last updated: ' + Utilities.formatDate(now, tz, 'M/d/yyyy h:mm a'), '']);
dashSheet.getRange(1, 1, rows.length, 2).setValues(rows);
// Styling
dashSheet.getRange(1, 1, rows.length, 2)
.setBackground('#0a0a0a').setFontColor('#cccccc').setFontFamily('Arial').setFontSize(12);
for (const r of [2, 10, 18]) {
dashSheet.getRange(r, 1, 1, 2).setFontColor('#ff2d2d').setFontWeight('bold').setFontSize(14);
}
dashSheet.getRange(4, 2, 5, 1).setFontColor('#ffffff').setFontWeight('bold').setFontSize(13);
dashSheet.getRange(12, 2).setFontColor('#22c55e');
dashSheet.getRange(13, 2).setFontColor('#ff9900');
dashSheet.getRange(14, 2).setFontColor('#ff4444');
dashSheet.getRange(15, 2).setFontColor('#ff2d2d').setFontWeight('bold');
dashSheet.setColumnWidth(1, 300);
dashSheet.setColumnWidth(2, 250);
Logger.log('โ
Dashboard refreshed.');
}
/* ================================================================
OVERDUE CHECK + STUDENT REMINDERS
================================================================ */
function checkOverdue() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would flag ' + DEMO.overdueStudents.length + ' overdue students.');
return;
}
runOverdueCheck_();
} catch (e) {
Logger.log('checkOverdue error: ' + (e.message || e));
notifyAdmin_('Payment Tracker โ Overdue Check Failed', String(e.message || e).substring(0, 500));
throw e;
}
}
function runOverdueCheck_() {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const balSheet = ss.getSheetByName('Balances');
if (!balSheet || balSheet.getLastRow() < 2) return;
const data = balSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const bNameCol = findCol_(h, ['student name', 'name']);
const bEmailCol = findCol_(h, ['email']);
const bPhoneCol = findCol_(h, ['phone']);
const bPkgCol = findCol_(h, ['package']);
const bBalCol = findCol_(h, ['balance due']);
const bStatCol = findCol_(h, ['status']);
const bLastCol = findCol_(h, ['last payment date']);
const bDaysCol = findCol_(h, ['days since payment']);
const now = new Date();
const overdueStudents = [];
for (let i = 1; i < data.length; i++) {
const row = data[i];
const name = String(row[bNameCol] || '').trim();
if (!name) continue;
const balance = parseFloat(row[bBalCol]) || 0;
if (balance <= 0) continue;
const lastPay = bLastCol >= 0 ? row[bLastCol] : null;
let daysSince = 999;
if (lastPay instanceof Date && !isNaN(lastPay.getTime())) {
daysSince = Math.floor((now - lastPay) / 86400000);
if (bDaysCol >= 0) balSheet.getRange(i + 1, bDaysCol + 1).setValue(daysSince);
}
if (daysSince > CONFIG.OVERDUE_DAYS || (!(lastPay instanceof Date) && balance > 0)) {
if (bStatCol >= 0) {
balSheet.getRange(i + 1, bStatCol + 1).setValue('OVERDUE')
.setBackground('#2e0a0a').setFontColor('#ff2d2d');
}
overdueStudents.push({
name: name,
email: bEmailCol >= 0 ? String(row[bEmailCol] || '') : '',
phone: bPhoneCol >= 0 ? String(row[bPhoneCol] || '') : '',
pkg: bPkgCol >= 0 ? String(row[bPkgCol] || '') : '',
balance: balance,
days: daysSince === 999 ? 'Never paid' : daysSince + ' days'
});
}
}
if (overdueStudents.length > 0) {
sendOverdueAdminAlert_(overdueStudents);
// Also email each overdue student
for (const s of overdueStudents) {
if (s.email && s.email.includes('@')) {
sendOverdueStudentEmail_(s.name, s.email, s.balance, s.pkg);
}
}
}
refreshDashboard();
Logger.log('Overdue check: ' + overdueStudents.length + ' student(s) overdue.');
}
/** Friendly payment reminders โ sent before overdue threshold */
function sendPaymentReminders() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would send payment reminders.');
return;
}
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
const balSheet = ss.getSheetByName('Balances');
if (!balSheet || balSheet.getLastRow() < 2) return;
const data = balSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const bNameCol = findCol_(h, ['student name', 'name']);
const bEmailCol = findCol_(h, ['email']);
const bBalCol = findCol_(h, ['balance due']);
const bPkgCol = findCol_(h, ['package']);
const bLastCol = findCol_(h, ['last payment date']);
const bDaysCol = findCol_(h, ['days since payment']);
const bRemCol = findCol_(h, ['reminder sent']);
const bOwedCol = findCol_(h, ['total owed']);
const now = new Date();
let sent = 0;
for (let i = 1; i < data.length; i++) {
const row = data[i];
const name = String(row[bNameCol] || '').trim();
const email = bEmailCol >= 0 ? String(row[bEmailCol] || '').trim() : '';
const balance = parseFloat(row[bBalCol]) || 0;
const owed = bOwedCol >= 0 ? (parseFloat(row[bOwedCol]) || 0) : 0;
if (!name || !email || !email.includes('@') || balance <= 0 || owed <= 0) continue;
// Already reminded this week?
if (bRemCol >= 0) {
const lastReminder = row[bRemCol];
if (lastReminder instanceof Date && (now - lastReminder) < 7 * 86400000) continue;
}
const lastPay = bLastCol >= 0 ? row[bLastCol] : null;
let daysSince = 999;
if (lastPay instanceof Date) daysSince = Math.floor((now - lastPay) / 86400000);
// Send reminder between REMINDER_DAYS and OVERDUE_DAYS (the "friendly zone")
if (daysSince >= CONFIG.REMINDER_DAYS && daysSince <= CONFIG.OVERDUE_DAYS) {
const pkg = bPkgCol >= 0 ? String(row[bPkgCol] || '') : '';
sendFriendlyReminder_(name, email, balance, pkg);
if (bRemCol >= 0) balSheet.getRange(i + 1, bRemCol + 1).setValue(now);
sent++;
}
}
Logger.log('Payment reminders sent: ' + sent);
} catch (e) {
Logger.log('sendPaymentReminders error: ' + (e.message || e));
notifyAdmin_('Payment Reminders Failed', String(e.message || e).substring(0, 500));
}
}
/* ================================================================
FORM HANDLER
================================================================ */
function onPaymentFormSubmit(e) {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ form submit ignored.');
return;
}
if (!e || !e.namedValues) {
Logger.log('onPaymentFormSubmit: no event data.');
return;
}
const val = (keys) => {
for (const k of keys) {
const v = e.namedValues[k];
if (v && v[0]) return v[0].trim();
}
return '';
};
const studentName = sanitize_(val(['Student Name', 'student name']));
const amount = parseFloat(String(val(['Payment Amount', 'Amount'])).replace(/[$,]/g, '')) || 0;
const method = sanitize_(val(['Payment Method', 'Method'])) || 'Cash';
const notes = sanitize_(val(['Notes', 'notes']));
const result = recordPayment(studentName, amount, method, notes);
if (!result.success) {
notifyAdmin_('โ ๏ธ Payment Recording Failed โ ' + studentName,
'Failed: ' + result.message + '\nStudent: ' + studentName + ', Amount: $' + amount + ', Method: ' + method);
}
Logger.log(result.message);
} catch (err) {
Logger.log('onPaymentFormSubmit error: ' + (err.message || err));
notifyAdmin_('Payment Form Error', String(err.message || err).substring(0, 500));
}
}
/* ================================================================
PAYMENT ID GENERATOR
================================================================ */
function generatePaymentId_(paySheet) {
let maxNum = 0;
if (paySheet && paySheet.getLastRow() > 1) {
const ids = paySheet.getRange(2, 1, paySheet.getLastRow() - 1, 1).getValues();
for (const row of ids) {
const match = String(row[0] || '').match(/PAY-(\d+)/);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
}
return 'PAY-' + String(maxNum + 1).padStart(4, '0');
}
/* ================================================================
REGISTRATION SYNC
================================================================ */
function syncToRegistration_(studentName, studentEmail, totalPaid, balanceDue, method) {
try {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const regSheet = CONFIG.REGISTRATION_SHEET_TAB
? (regSS.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB) || regSS.getSheets()[0])
: regSS.getSheets()[0];
if (!regSheet || regSheet.getLastRow() < 2) return;
const data = regSheet.getDataRange().getValues();
const h = data[0].map(c => String(c || '').toLowerCase().trim());
const nameCol = findCol_(h, ['full name', 'student name', 'name']);
const emailCol = findCol_(h, ['email', 'student email']);
const statusCol = findCol_(h, ['payment status']);
const amountCol = findCol_(h, ['amount paid']);
const notesCol = findCol_(h, ['notes']);
if (nameCol < 0 || statusCol < 0) return;
const nameLower = String(studentName).toLowerCase().trim();
const emailLower = String(studentEmail).toLowerCase().trim();
// Email-first, then name match
let matchedRow = -1;
for (let i = 1; i < data.length; i++) {
if (emailLower && emailCol >= 0 && String(data[i][emailCol] || '').toLowerCase().trim() === emailLower) {
matchedRow = i + 1;
break;
}
}
if (matchedRow === -1) {
for (let i = 1; i < data.length; i++) {
if (String(data[i][nameCol] || '').toLowerCase().trim() === nameLower) {
matchedRow = i + 1;
break;
}
}
}
if (matchedRow === -1) return;
const status = balanceDue <= 0 ? 'Paid in Full' : 'Partial โ $' + balanceDue.toFixed(0) + ' remaining';
regSheet.getRange(matchedRow, statusCol + 1).setValue(status);
if (amountCol >= 0) {
regSheet.getRange(matchedRow, amountCol + 1).setValue(totalPaid);
}
if (notesCol >= 0) {
const existing = String(data[matchedRow - 1][notesCol] || '').trim();
const prevPaid = amountCol >= 0 ? (parseFloat(data[matchedRow - 1][amountCol]) || 0) : 0;
const payNote = Utilities.formatDate(new Date(), CONFIG.TIMEZONE, 'M/d/yy') + ': $' + (totalPaid - prevPaid).toFixed(0) + ' via ' + method;
regSheet.getRange(matchedRow, notesCol + 1).setValue(existing ? existing + ' | ' + payNote : payNote);
}
Logger.log('Registration synced for ' + studentName);
} catch (e) {
Logger.log('Registration sync error: ' + e.message);
}
}
/* ================================================================
EMAIL TEMPLATES (Mission Control theme)
================================================================ */
function sendPaymentReceipt_(payId, studentName, studentEmail, amount, method, totalPaid, balanceDue, pkg) {
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(studentEmail) + '%20from%20payment%20emails.';
const html = emailWrap_(
iconHeader_('๐ต', '#22c55e', 'Payment Received'),
'<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Receipt #', esc_(payId))
+ detailRow_('Student', esc_(studentName))
+ detailRow_('Package', esc_(pkg))
+ detailRow_('Amount Paid', '$' + amount.toFixed(2), '#22c55e')
+ detailRow_('Method', esc_(method))
+ detailRow_('Total Paid', '$' + totalPaid.toFixed(2))
+ detailRow_('Balance Due', '$' + balanceDue.toFixed(2), balanceDue > 0 ? '#ff4444' : '#22c55e')
+ '</table>',
unsub
);
// Admin
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: '๐ต Payment: ' + studentName + ' โ $' + amount.toFixed(2) + ' | Balance: $' + balanceDue.toFixed(2),
body: 'Payment: ' + studentName + ' paid $' + amount.toFixed(2) + ' via ' + method + '. Balance: $' + balanceDue.toFixed(2),
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
// Student
if (studentEmail && studentEmail.includes('@')) {
MailApp.sendEmail({
to: studentEmail,
subject: 'Payment Receipt โ $' + amount.toFixed(2) + ' received โ ' + CONFIG.SCHOOL_NAME,
body: 'Hi ' + studentName + '! Payment of $' + amount.toFixed(2) + ' received. Balance: $' + balanceDue.toFixed(2),
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
}
}
function sendRefundEmail_(refundId, studentName, studentEmail, amount, reason, originalPayId) {
const html = emailWrap_(
iconHeader_('โฉ๏ธ', '#f59e0b', 'Refund Processed'),
'<table width="100%" cellpadding="0" cellspacing="0" border="0">'
+ detailRow_('Refund ID', esc_(refundId))
+ detailRow_('Original Payment', esc_(originalPayId))
+ detailRow_('Student', esc_(studentName))
+ detailRow_('Refund Amount', '$' + amount.toFixed(2), '#f59e0b')
+ (reason ? detailRow_('Reason', esc_(reason)) : '')
+ '</table>', ''
);
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: 'โฉ๏ธ Refund: $' + amount.toFixed(2) + ' โ ' + studentName,
body: 'Refund of $' + amount.toFixed(2) + ' for ' + studentName + ' (original: ' + originalPayId + ')',
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
if (studentEmail && studentEmail.includes('@')) {
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20me%20from%20payment%20emails.';
MailApp.sendEmail({
to: studentEmail,
subject: 'Refund Processed โ $' + amount.toFixed(2) + ' โ ' + CONFIG.SCHOOL_NAME,
body: 'A refund of $' + amount.toFixed(2) + ' has been processed. Reason: ' + (reason || 'N/A'),
htmlBody: emailWrap_(iconHeader_('โฉ๏ธ', '#f59e0b', 'Refund Processed'),
'<p style="font-size:14px;color:rgba(255,255,255,0.6);text-align:center;">A refund of <strong style="color:#f59e0b;">$' + amount.toFixed(2) + '</strong> has been processed to your account.</p>'
+ (reason ? '<p style="font-size:13px;color:rgba(255,255,255,0.4);text-align:center;">Reason: ' + esc_(reason) + '</p>' : ''), unsub),
name: CONFIG.SCHOOL_NAME
});
}
}
function sendOverdueAdminAlert_(students) {
let totalOverdue = 0;
let rows = '';
for (const s of students) {
totalOverdue += s.balance;
rows += '<tr><td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ccc;font-size:13px;">' + esc_(s.name) + '</td>'
+ '<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#ff4444;font-size:13px;font-weight:600;">$' + s.balance.toFixed(2) + '</td>'
+ '<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#888;">' + esc_(s.days) + '</td>'
+ '<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#666;">' + esc_(s.email || 'โ') + '</td>'
+ '<td style="padding:10px 14px;border-bottom:1px solid rgba(255,255,255,0.06);color:#666;">' + esc_(s.phone || 'โ') + '</td></tr>';
}
const html = emailWrap_(
iconHeader_('โ ๏ธ', '#ff2d2d', 'Overdue Payments Alert'),
'<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.15);border-radius:12px;padding:16px;text-align:center;margin-bottom:20px;">'
+ '<span style="font-size:12px;color:#888;">Total Overdue</span><br>'
+ '<span style="font-size:28px;font-weight:700;color:#ff2d2d;">$' + totalOverdue.toFixed(2) + '</span>'
+ '<span style="display:block;font-size:12px;color:#888;margin-top:4px;">' + students.length + ' student' + (students.length !== 1 ? 's' : '') + '</span></div>'
+ '<table width="100%" style="border:1px solid rgba(255,255,255,0.06);border-radius:10px;overflow:hidden;">'
+ '<tr style="background:rgba(255,255,255,0.03);"><th style="padding:10px;text-align:left;font-size:11px;color:#666;">Student</th><th style="padding:10px;text-align:left;font-size:11px;color:#666;">Balance</th><th style="padding:10px;text-align:left;font-size:11px;color:#666;">Overdue</th><th style="padding:10px;text-align:left;font-size:11px;color:#666;">Email</th><th style="padding:10px;text-align:left;font-size:11px;color:#666;">Phone</th></tr>'
+ rows + '</table>', ''
);
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: 'โ ๏ธ ' + students.length + ' Overdue โ $' + totalOverdue.toFixed(2) + ' outstanding',
body: students.length + ' students overdue. Total: $' + totalOverdue.toFixed(2),
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
}
function sendOverdueStudentEmail_(name, email, balance, pkg) {
const firstName = (name || '').split(' ')[0] || name;
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(email) + '%20from%20payment%20emails.';
const html = emailWrap_(
iconHeader_('๐', '#ff2d2d', 'Payment Overdue'),
'<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;text-align:center;">'
+ 'Hi <strong style="color:#fff;">' + esc_(firstName) + '</strong>, our records show an outstanding balance on your account.</p>'
+ '<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:16px;text-align:center;margin:16px 0;">'
+ '<div style="font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px;">Balance Due</div>'
+ '<div style="font-size:28px;font-weight:800;color:#ff2d2d;margin-top:4px;">$' + balance.toFixed(2) + '</div>'
+ '<div style="font-size:12px;color:rgba(255,255,255,0.4);margin-top:4px;">' + esc_(pkg) + '</div></div>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.5);text-align:center;">We accept Cash and Zelle. Contact us at ' + esc_(CONFIG.SCHOOL_PHONE) + ' if you need to set up a payment plan.</p>',
unsub
);
try {
MailApp.sendEmail({
to: email,
subject: 'Payment Reminder โ $' + balance.toFixed(2) + ' balance due โ ' + CONFIG.SCHOOL_NAME,
body: 'Hi ' + firstName + ', you have a balance of $' + balance.toFixed(2) + ' for ' + pkg + '. Please contact us at ' + CONFIG.SCHOOL_PHONE,
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
} catch (e) {
Logger.log('Overdue email failed for ' + name + ': ' + e.message);
}
}
function sendFriendlyReminder_(name, email, balance, pkg) {
const firstName = (name || '').split(' ')[0] || name;
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(email) + '%20from%20payment%20emails.';
const html = emailWrap_(
iconHeader_('๐ก', '#f59e0b', 'Friendly Reminder'),
'<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;text-align:center;">'
+ 'Hi <strong style="color:#fff;">' + esc_(firstName) + '</strong>! Just a quick reminder about your upcoming balance.</p>'
+ '<div style="background:rgba(245,158,11,0.08);border:1px solid rgba(245,158,11,0.2);border-radius:12px;padding:16px;text-align:center;margin:16px 0;">'
+ '<div style="font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px;">Balance Due</div>'
+ '<div style="font-size:28px;font-weight:800;color:#f59e0b;margin-top:4px;">$' + balance.toFixed(2) + '</div></div>'
+ '<p style="font-size:13px;color:rgba(255,255,255,0.5);text-align:center;">No rush โ just wanted to keep you in the loop! We accept Cash and Zelle. Questions? Call ' + esc_(CONFIG.SCHOOL_PHONE) + '</p>',
unsub
);
try {
MailApp.sendEmail({
to: email,
subject: 'Friendly Reminder โ $' + balance.toFixed(2) + ' balance โ ' + CONFIG.SCHOOL_NAME,
body: 'Hi ' + firstName + '! Quick reminder: you have a balance of $' + balance.toFixed(2) + '. No rush! Call ' + CONFIG.SCHOOL_PHONE + ' with questions.',
htmlBody: html, name: CONFIG.SCHOOL_NAME
});
} catch (e) {
Logger.log('Friendly reminder failed for ' + name + ': ' + e.message);
}
}
/* ================================================================
EMAIL BUILDING BLOCKS
================================================================ */
function emailWrap_(header, body, unsubLink) {
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
return '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center" style="padding:20px;">'
+ '<table width="560" cellpadding="0" cellspacing="0" border="0" style="background:#0d0d0d;border-radius:16px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">'
+ '<tr><td style="padding:24px 30px;border-bottom:1px solid rgba(255,255,255,0.06);">' + header + '</td></tr>'
+ '<tr><td style="padding:24px 30px;">' + body + '</td></tr>'
+ '<tr><td style="padding:16px 30px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.2);margin:0;">' + sch + ' โ ' + tag + '</p>'
+ (unsubLink ? '<p style="font-size:10px;margin:6px 0 0;"><a href="' + unsubLink + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe</a></p>' : '')
+ '</td></tr></table></td></tr></table></body></html>';
}
function iconHeader_(emoji, color, title) {
return '<div style="display:inline-block;width:40px;height:40px;background:' + color + ';border-radius:12px;line-height:40px;font-size:20px;text-align:center;">' + emoji + '</div>'
+ '<span style="color:#fff;font-size:18px;font-weight:700;margin-left:12px;">' + esc_(title) + '</span>';
}
function detailRow_(label, value, color) {
return '<tr><td style="padding:8px 0;color:#666;font-size:13px;width:130px;">' + label + '</td>'
+ '<td style="padding:8px 0;color:' + (color || '#fff') + ';font-size:13px;font-weight:600;">' + value + '</td></tr>';
}
/* ================================================================
SHARED HELPERS
================================================================ */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function esc_(str) {
if (str == null) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
function sanitize_(str) {
return String(str || '').replace(/[<>{}()\[\]\\\/]/g, '').substring(0, 200).trim();
}
function levenshtein_(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0]; dp[0] = j;
for (let i = 1; i <= m; i++) {
const temp = dp[i];
dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
prev = temp;
}
}
return dp[m];
}
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try { MailApp.sendEmail(CONFIG.ADMIN_EMAIL, subject, body, { name: CONFIG.SCHOOL_NAME }); } catch (_) {}
}
/* ================================================================
MANUAL TOOLS
================================================================ */
function testDemoPayment() {
const d = DEMO.payment;
Logger.log('๐ญ Demo payment:');
Logger.log(' ' + d.student + ' paid $' + d.amount + ' via ' + d.method);
Logger.log(' Total paid: $' + d.totalPaid + ' | Balance: $' + d.balance);
Logger.log(' Payment ID: ' + d.id);
}
function manualImport() {
if (CONFIG.DEMO_MODE) { Logger.log('๐ญ DEMO MODE โ import skipped.'); return; }
const count = importStudents_();
Logger.log('Imported ' + count + ' student(s).');
}
function testPayment() {
const result = recordPayment('Marcus Johnson', 100, 'Cash', 'Test payment');
Logger.log(JSON.stringify(result));
}
/**
* Referral Rewards System โ Flavors Driving School
*
* Turns every happy student into a salesperson. When a student completes
* their lesson package, they receive a unique referral code. When a new
* student signs up using that code, BOTH get a reward ($25 credit).
*
* FEATURES:
* - Demo mode for presentations (no real emails, fake data)
* - Email-first + fuzzy name matching for completion detection
* - Referral code format validation
* - Unsubscribe links on all student emails
* - Leaderboard โ top referrers summary
* - Self-referral prevention
* - Duplicate processing guard
* - Configurable via Settings sheet
*
* SHEETS (in this spreadsheet):
* 1. "Referral Codes" โ Every student's unique code, status, usage count
* 2. "Referral Log" โ Complete history of who referred who
* 3. "Settings" โ Reward amounts, limits, and customization
*
* CONNECTED SHEETS:
* - Student Registration: 1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY
* - Instructor Schedule Board: 1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE
* - Payment Tracker: 1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk
*
* TRIGGERS:
* - Daily at 10:30 AM: Check for newly completed students โ send referral codes
* - Every 2 hours: Check if new registration has a referral code โ process it
*/
// ============ CONFIGURATION ============
const REFERRAL_CONFIG = {
// Connected sheets
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
SCHEDULE_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
PAYMENT_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
ADMIN_EMAIL: '[email protected]',
SCHOOL_NAME: 'Flavors Driving School',
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
// Reward settings (also editable in Settings sheet)
REFERRER_REWARD: 25,
REFEREE_REWARD: 25,
MAX_REFERRALS: 10,
CODE_EXPIRY_DAYS: 365,
// Referral code pattern: FIRSTNAME-XXXX
CODE_PATTERN: /^[A-Z]+-[A-Z2-9]{4}$/,
PACKAGES: {
'3 Lessons': 3,
'5 Lessons': 5,
'10 Lessons': 10,
'15 Lessons': 15,
'25 Lessons': 25,
'5-Hour Class': 1
}
};
// ============ GET SPREADSHEET HELPER ============
function getSpreadsheet() {
let ss = SpreadsheetApp.getActiveSpreadsheet();
if (ss) return ss;
const id = PropertiesService.getScriptProperties().getProperty('SPREADSHEET_ID');
if (id) return SpreadsheetApp.openById(id);
ss = SpreadsheetApp.create('Referral Rewards โ Flavors Driving School');
PropertiesService.getScriptProperties().setProperty('SPREADSHEET_ID', ss.getId());
Logger.log('Created new spreadsheet: ' + ss.getUrl());
return ss;
}
// ============ FORCE AUTH โ hits ALL connected sheets ============
function forceAuth() {
const reg = SpreadsheetApp.openById(REFERRAL_CONFIG.REGISTRATION_ID);
Logger.log('Registration: ' + reg.getName());
const sched = SpreadsheetApp.openById(REFERRAL_CONFIG.SCHEDULE_ID);
Logger.log('Schedule: ' + sched.getName());
const pay = SpreadsheetApp.openById(REFERRAL_CONFIG.PAYMENT_ID);
Logger.log('Payments: ' + pay.getName());
MailApp.getRemainingDailyQuota();
Logger.log('โ
Auth complete โ all sheets + email accessible.');
}
// ================================================================
// SETUP
// ================================================================
function fullSetup() {
const ss = getSpreadsheet();
// --- Referral Codes Sheet ---
let codesSheet = ss.getSheetByName('Referral Codes');
if (!codesSheet) codesSheet = ss.insertSheet('Referral Codes');
codesSheet.clear();
const codesHeaders = [
'Student Name', 'Email', 'Referral Code', 'Date Generated',
'Expires', 'Times Used', 'Total Earned', 'Status'
];
codesSheet.appendRow(codesHeaders);
formatHeader(codesSheet, codesHeaders.length);
codesSheet.setColumnWidths(1, 1, 180);
codesSheet.setColumnWidth(2, 220);
codesSheet.setColumnWidth(3, 140);
codesSheet.setColumnWidth(4, 140);
codesSheet.setColumnWidth(5, 140);
codesSheet.setColumnWidth(6, 100);
codesSheet.setColumnWidth(7, 110);
codesSheet.setColumnWidth(8, 100);
// --- Referral Log Sheet ---
let logSheet = ss.getSheetByName('Referral Log');
if (!logSheet) logSheet = ss.insertSheet('Referral Log');
logSheet.clear();
const logHeaders = [
'Date', 'Referral Code Used', 'Referrer Name', 'Referrer Email',
'New Student Name', 'New Student Email', 'New Student Package',
'Referrer Reward', 'New Student Reward', 'Status', 'Processing ID'
];
logSheet.appendRow(logHeaders);
formatHeader(logSheet, logHeaders.length);
// --- Settings Sheet ---
let settingsSheet = ss.getSheetByName('Settings');
if (!settingsSheet) settingsSheet = ss.insertSheet('Settings');
settingsSheet.clear();
const settingsData = [
['Setting', 'Value', 'Description'],
['Demo Mode', REFERRAL_CONFIG.DEMO_MODE ? 'Yes' : 'No', 'Set to Yes for demos โ uses fake data, no real emails'],
['Referrer Reward ($)', REFERRAL_CONFIG.REFERRER_REWARD, 'Credit given to the person who referred'],
['New Student Reward ($)', REFERRAL_CONFIG.REFEREE_REWARD, 'Credit given to the new student who was referred'],
['Max Referrals Per Student', REFERRAL_CONFIG.MAX_REFERRALS, 'Maximum times one code can be used (0 = unlimited)'],
['Code Expiry (Days)', REFERRAL_CONFIG.CODE_EXPIRY_DAYS, 'Days until a code expires (0 = never)'],
['School Name', REFERRAL_CONFIG.SCHOOL_NAME, 'Used in email templates'],
['Admin Email', REFERRAL_CONFIG.ADMIN_EMAIL, 'Receives referral notifications'],
['Unsubscribe Email', REFERRAL_CONFIG.ADMIN_EMAIL, 'Students reply here to opt out of emails'],
['Include Code in Review Email', 'Yes', 'Also send referral code in the Google Review Request email'],
['Minimum Package for Code', 'Any', 'Minimum package to earn a referral code (Any, 5 Lessons, 10 Lessons, etc.)']
];
settingsSheet.getRange(1, 1, settingsData.length, 3).setValues(settingsData);
formatHeader(settingsSheet, 3);
settingsSheet.setColumnWidth(1, 240);
settingsSheet.setColumnWidth(2, 120);
settingsSheet.setColumnWidth(3, 380);
// Style
const sRange = settingsSheet.getRange(2, 1, settingsData.length - 1, 3);
sRange.setBackground('#0a0a0a');
settingsSheet.getRange(2, 1, settingsData.length - 1, 1).setFontColor('#888888');
settingsSheet.getRange(2, 2, settingsData.length - 1, 1).setFontColor('#ffffff').setFontWeight('bold');
settingsSheet.getRange(2, 3, settingsData.length - 1, 1).setFontColor('#555555').setFontSize(9);
settingsSheet.getRange(3, 2).setFontColor('#ff2d2d').setFontSize(12);
settingsSheet.getRange(4, 2).setFontColor('#ff2d2d').setFontSize(12);
// Demo mode row highlight
settingsSheet.getRange(2, 2).setFontColor('#f59e0b').setFontSize(12);
setupTriggers();
Logger.log('=== REFERRAL REWARDS SYSTEM SETUP COMPLETE ===');
}
// ================================================================
// DAILY CHECK โ Find completed students and send referral codes
// ================================================================
function dailyCompletionCheck() {
const ss = getSpreadsheet();
const settings = getSettings(ss);
// === DEMO MODE โ log but don't process ===
if (settings.demoMode) {
Logger.log('โ ๏ธ DEMO MODE โ dailyCompletionCheck skipped. Set Demo Mode to "No" in Settings for production.');
return;
}
const codesSheet = ss.getSheetByName('Referral Codes');
// Get students who already have codes (by email + name)
const existingCodes = getExistingCodes(codesSheet);
// Get all registered students
const students = getRegisteredStudents();
// Get attendance counts (only confirmed past lessons)
const attendance = getAttendanceCounts();
let newCodes = 0;
for (const student of students) {
// Skip if already has a code (check email first, then name)
if (student.email && existingCodes.emails.has(student.email.toLowerCase().trim())) continue;
const nameKey = student.name.toLowerCase().trim();
if (existingCodes.names.has(nameKey)) continue;
if (isFuzzyTracked(nameKey, existingCodes.names)) continue;
// Get completed lessons (email-first + fuzzy name matching)
const completed = getStudentAttendance(student, attendance);
const purchased = student.lessonCount;
if (purchased <= 0 || completed < purchased) continue;
// Check minimum package requirement
if (settings.minPackage !== 'any') {
const minLessons = REFERRAL_CONFIG.PACKAGES[settings.minPackage] || 0;
if (purchased < minLessons) continue;
}
// Generate unique referral code
const code = generateCode(student.name, codesSheet);
// Calculate expiry
let expiryDate = 'Never';
if (settings.codeExpiryDays > 0) {
const expiry = new Date();
expiry.setDate(expiry.getDate() + settings.codeExpiryDays);
expiryDate = expiry;
}
// Add to sheet
codesSheet.appendRow([
student.name, student.email, code, new Date(),
expiryDate, 0, 0, 'Active'
]);
// Style
const rowNum = codesSheet.getLastRow();
codesSheet.getRange(rowNum, 3).setFontWeight('bold').setFontColor('#ff2d2d');
codesSheet.getRange(rowNum, 8).setFontColor('#22c55e');
codesSheet.getRange(rowNum, 1, 1, 8).setBackground('#0a0a0a');
// Send email
sendReferralCodeEmail(student, code, settings);
newCodes++;
if (student.email) existingCodes.emails.add(student.email.toLowerCase().trim());
existingCodes.names.add(nameKey);
Logger.log('Generated referral code ' + code + ' for ' + student.name);
}
if (newCodes > 0) {
sendAdminNewCodesAlert(newCodes, settings);
}
Logger.log('Daily check complete: ' + newCodes + ' new referral codes generated');
}
// ================================================================
// REFERRAL PROCESSING
// ================================================================
function processReferral(newStudentName, newStudentEmail, newStudentPackage, referralCode) {
const ss = getSpreadsheet();
const settings = getSettings(ss);
// Sanitize
referralCode = String(referralCode).trim().toUpperCase();
newStudentName = String(newStudentName).trim();
newStudentEmail = String(newStudentEmail).trim();
newStudentPackage = String(newStudentPackage).trim();
if (!referralCode) return { success: false, message: 'No referral code provided' };
// === DEMO MODE ===
if (settings.demoMode) {
Logger.log('โ ๏ธ DEMO MODE โ processReferral simulated for code: ' + referralCode);
return { success: true, message: 'DEMO: Referral would be processed for code ' + referralCode };
}
// Validate code format
if (!REFERRAL_CONFIG.CODE_PATTERN.test(referralCode)) {
return { success: false, message: 'Invalid referral code format. Expected: NAME-XXXX (e.g., SARAH-7X3K)' };
}
const codesSheet = ss.getSheetByName('Referral Codes');
const logSheet = ss.getSheetByName('Referral Log');
// Generate processing ID for duplicate guard
const processingId = newStudentEmail.toLowerCase() + '|' + referralCode;
// Check if already processed (duplicate guard)
const logData = logSheet.getDataRange().getValues();
for (let i = 1; i < logData.length; i++) {
const existingId = String(logData[i][10] || '').trim();
if (existingId === processingId) {
return { success: false, message: 'This referral has already been processed' };
}
}
// Find the referral code
const codesData = codesSheet.getDataRange().getValues();
let codeRow = -1;
let referrer = null;
for (let i = 1; i < codesData.length; i++) {
if (String(codesData[i][2]).trim().toUpperCase() === referralCode) {
codeRow = i + 1;
referrer = {
name: String(codesData[i][0]).trim(),
email: String(codesData[i][1]).trim(),
code: String(codesData[i][2]).trim(),
timesUsed: parseInt(codesData[i][5]) || 0,
totalEarned: parseFloat(codesData[i][6]) || 0,
status: String(codesData[i][7]).trim()
};
break;
}
}
// Validate
if (!referrer) return { success: false, message: 'Invalid referral code: ' + referralCode };
if (referrer.status !== 'Active') return { success: false, message: 'Referral code is ' + referrer.status.toLowerCase() };
// Max referrals check
if (settings.maxReferrals > 0 && referrer.timesUsed >= settings.maxReferrals) {
codesSheet.getRange(codeRow, 8).setValue('Maxed Out').setFontColor('#f59e0b');
return { success: false, message: 'Referral code has reached maximum uses' };
}
// Expiry check
const expiryVal = codesData[codeRow - 1][4];
if (expiryVal instanceof Date && expiryVal < new Date()) {
codesSheet.getRange(codeRow, 8).setValue('Expired').setFontColor('#ef4444');
return { success: false, message: 'Referral code has expired' };
}
// Self-referral check
if (referrer.email.toLowerCase() === newStudentEmail.toLowerCase()) {
return { success: false, message: 'Cannot use your own referral code' };
}
// --- Process it ---
const referrerReward = settings.referrerReward;
const refereeReward = settings.refereeReward;
// Update codes sheet
const newTimesUsed = referrer.timesUsed + 1;
const newTotalEarned = referrer.totalEarned + referrerReward;
codesSheet.getRange(codeRow, 6).setValue(newTimesUsed);
codesSheet.getRange(codeRow, 7).setValue(newTotalEarned);
if (settings.maxReferrals > 0 && newTimesUsed >= settings.maxReferrals) {
codesSheet.getRange(codeRow, 8).setValue('Maxed Out').setFontColor('#f59e0b');
}
// Log with processing ID
logSheet.appendRow([
new Date(), referralCode, referrer.name, referrer.email,
newStudentName, newStudentEmail, newStudentPackage,
referrerReward, refereeReward, 'Credited', processingId
]);
const logRowNum = logSheet.getLastRow();
logSheet.getRange(logRowNum, 1, 1, 11).setBackground('#0a0a0a');
logSheet.getRange(logRowNum, 8).setFontColor('#22c55e').setFontWeight('bold');
logSheet.getRange(logRowNum, 9).setFontColor('#22c55e').setFontWeight('bold');
logSheet.getRange(logRowNum, 10).setFontColor('#22c55e');
// Emails
sendReferrerNotification(referrer, newStudentName, referrerReward, newTotalEarned, settings);
sendRefereeWelcome(newStudentName, newStudentEmail, refereeReward, referrer.name, settings);
sendAdminReferralAlert(referrer, newStudentName, newStudentPackage, referrerReward, refereeReward, settings);
Logger.log('Referral processed: ' + newStudentName + ' referred by ' + referrer.name);
return { success: true, message: 'Referral processed successfully!' };
}
// ================================================================
// CHECK NEW REGISTRATIONS
// ================================================================
function checkNewRegistrations() {
const ss = getSpreadsheet();
const settings = getSettings(ss);
if (settings.demoMode) {
Logger.log('โ ๏ธ DEMO MODE โ checkNewRegistrations skipped.');
return;
}
const logSheet = ss.getSheetByName('Referral Log');
// Get already-processed pairs via processing ID
const logData = logSheet.getDataRange().getValues();
const processedIds = new Set();
for (let i = 1; i < logData.length; i++) {
const pid = String(logData[i][10] || '').trim();
if (pid) processedIds.add(pid);
}
try {
const regSheet = SpreadsheetApp.openById(REFERRAL_CONFIG.REGISTRATION_ID);
const data = regSheet.getSheets()[0].getDataRange().getValues();
if (data.length < 2) return;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['name', 'full name', 'student name']);
const emailCol = findCol(headers, ['email', 'student email', 'email address']);
const pkgCol = findCol(headers, ['package', 'lesson package', 'selected package']);
const refCol = findCol(headers, ['referral', 'referral code', 'referred by', 'promo code', 'coupon']);
if (refCol === -1) {
Logger.log('No referral code column found in Registration sheet.');
return;
}
for (let i = 1; i < data.length; i++) {
const name = nameCol !== -1 ? String(data[i][nameCol]).trim() : '';
const email = emailCol !== -1 ? String(data[i][emailCol]).trim() : '';
const pkg = pkgCol !== -1 ? String(data[i][pkgCol]).trim() : '';
const refCode = String(data[i][refCol]).trim().toUpperCase();
if (!refCode || !name) continue;
// Check processing ID
const pid = email.toLowerCase() + '|' + refCode;
if (processedIds.has(pid)) continue;
const result = processReferral(name, email, pkg, refCode);
if (result.success) processedIds.add(pid);
Logger.log('Registration check โ ' + name + ': ' + result.message);
}
} catch (e) {
Logger.log('Error checking registrations: ' + e.message);
}
}
// ================================================================
// LEADERBOARD โ Top referrers summary
// ================================================================
function getLeaderboard(topN) {
topN = topN || 10;
const ss = getSpreadsheet();
const codesSheet = ss.getSheetByName('Referral Codes');
if (!codesSheet) return [];
const data = codesSheet.getDataRange().getValues();
const referrers = [];
for (let i = 1; i < data.length; i++) {
const name = String(data[i][0]).trim();
const timesUsed = parseInt(data[i][5]) || 0;
const totalEarned = parseFloat(data[i][6]) || 0;
const code = String(data[i][2]).trim();
const status = String(data[i][7]).trim();
if (timesUsed > 0) {
referrers.push({ name, code, timesUsed, totalEarned, status });
}
}
// Sort by times used (desc), then total earned (desc)
referrers.sort((a, b) => b.timesUsed - a.timesUsed || b.totalEarned - a.totalEarned);
return referrers.slice(0, topN);
}
function emailLeaderboard() {
const settings = getSettings(getSpreadsheet());
const leaders = getLeaderboard(10);
if (leaders.length === 0) {
Logger.log('No referrals yet โ no leaderboard to send.');
return;
}
let rows = '';
leaders.forEach((r, i) => {
const medal = i === 0 ? '๐ฅ' : i === 1 ? '๐ฅ' : i === 2 ? '๐ฅ' : 'โช๏ธ';
rows += `<tr>
<td style="padding:8px 12px;color:#888;font-size:14px;">${medal}</td>
<td style="padding:8px 12px;color:#fff;font-weight:600;font-size:14px;">${r.name}</td>
<td style="padding:8px 12px;color:#ff2d2d;font-weight:700;font-size:14px;text-align:center;">${r.timesUsed}</td>
<td style="padding:8px 12px;color:#22c55e;font-weight:700;font-size:14px;text-align:right;">$${r.totalEarned}</td>
</tr>`;
});
const html = `
<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display',sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">
<tr><td style="padding:32px 40px 16px;text-align:center;">
<div style="font-size:36px;">๐</div>
<h1 style="color:#fff;font-size:20px;margin:12px 0 0;">Referral Leaderboard</h1>
<p style="color:#888;font-size:13px;margin:4px 0 0;">${settings.schoolName} โ Top Referrers</p>
</td></tr>
<tr><td style="padding:8px 24px 24px;">
<table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
<tr style="border-bottom:1px solid rgba(255,255,255,0.06);">
<th style="padding:8px 12px;text-align:left;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;"></th>
<th style="padding:8px 12px;text-align:left;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;">Student</th>
<th style="padding:8px 12px;text-align:center;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;">Referrals</th>
<th style="padding:8px 12px;text-align:right;color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;">Earned</th>
</tr>
${rows}
</table>
</td></tr>
<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">
<p style="margin:0;color:#333;font-size:11px;"><span style="color:#ff2d2d;">${settings.schoolName}</span> โ Referral Rewards</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
try {
MailApp.sendEmail({
to: settings.adminEmail,
subject: '๐ Referral Leaderboard โ ' + settings.schoolName,
htmlBody: html
});
Logger.log('Leaderboard email sent.');
} catch (e) {
Logger.log('Error sending leaderboard: ' + e.message);
}
}
// ================================================================
// CODE GENERATION
// ================================================================
function generateCode(studentName, codesSheet) {
const firstName = studentName.split(' ')[0].toUpperCase().replace(/[^A-Z]/g, '') || 'STUDENT';
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // No I, O, 0, 1
let suffix = '';
for (let i = 0; i < 4; i++) {
suffix += chars.charAt(Math.floor(Math.random() * chars.length));
}
const code = firstName + '-' + suffix;
// Verify uniqueness
if (codesSheet) {
const existingData = codesSheet.getDataRange().getValues();
for (let i = 1; i < existingData.length; i++) {
if (String(existingData[i][2]).toUpperCase() === code) {
return generateCode(studentName, codesSheet);
}
}
}
return code;
}
// ================================================================
// EMAIL TEMPLATES
// ================================================================
function buildUnsubscribeLink(studentName, unsubEmail) {
return 'mailto:' + (unsubEmail || '') + '?subject=Unsubscribe%20Referral%20Emails&body=Please%20remove%20me%20from%20referral%20reward%20emails.%20Name:%20' + encodeURIComponent(studentName);
}
function sendReferralCodeEmail(student, code, settings) {
const firstName = student.name.split(' ')[0];
const unsub = buildUnsubscribeLink(student.name, settings.unsubscribeEmail);
const html = `
<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#000000;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000000;padding:20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,0.5);">
<tr><td style="padding:40px 40px 16px;text-align:center;">
<div style="font-size:40px;letter-spacing:4px;">๐๐๐จ</div>
<h1 style="margin:16px 0 0;color:#ffffff;font-size:22px;font-weight:700;">
Congrats ${firstName}! You've earned a reward!
</h1>
<p style="margin:8px 0 0;color:#888;font-size:14px;line-height:1.5;">
You completed your lessons at <span style="color:#ff2d2d;font-weight:600;">${settings.schoolName}</span> โ and now you can help your friends save too.
</p>
</td></tr>
<tr><td style="padding:16px 40px 8px;text-align:center;">
<div style="background:rgba(255,45,45,0.08);border:2px dashed rgba(255,45,45,0.3);border-radius:16px;padding:24px;margin:0 auto;max-width:300px;">
<p style="margin:0 0 8px;color:#888;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Referral Code</p>
<p style="margin:0;color:#ff2d2d;font-size:32px;font-weight:800;letter-spacing:2px;">${code}</p>
</div>
</td></tr>
<tr><td style="padding:20px 40px 24px;">
<h2 style="color:#fff;font-size:16px;font-weight:600;margin:0 0 12px;">Here's how it works:</h2>
<table cellpadding="0" cellspacing="0" style="width:100%;">
<tr>
<td style="padding:8px 12px 8px 0;vertical-align:top;">
<div style="width:28px;height:28px;background:rgba(255,45,45,0.15);border-radius:8px;text-align:center;line-height:28px;font-size:13px;color:#ff2d2d;font-weight:700;">1</div>
</td>
<td style="padding:8px 0;color:#bbb;font-size:14px;line-height:1.5;">
Share your code <strong style="color:#ff2d2d;">${code}</strong> with friends or family
</td>
</tr>
<tr>
<td style="padding:8px 12px 8px 0;vertical-align:top;">
<div style="width:28px;height:28px;background:rgba(255,45,45,0.15);border-radius:8px;text-align:center;line-height:28px;font-size:13px;color:#ff2d2d;font-weight:700;">2</div>
</td>
<td style="padding:8px 0;color:#bbb;font-size:14px;line-height:1.5;">
They enter your code when they sign up for lessons
</td>
</tr>
<tr>
<td style="padding:8px 12px 8px 0;vertical-align:top;">
<div style="width:28px;height:28px;background:rgba(255,45,45,0.15);border-radius:8px;text-align:center;line-height:28px;font-size:13px;color:#ff2d2d;font-weight:700;">3</div>
</td>
<td style="padding:8px 0;color:#bbb;font-size:14px;line-height:1.5;">
<strong style="color:#22c55e;">You get $${settings.referrerReward}</strong> credit and <strong style="color:#22c55e;">they get $${settings.refereeReward} off</strong> their package!
</td>
</tr>
</table>
<p style="color:#666;font-size:12px;margin:16px 0 0;text-align:center;">
${settings.maxReferrals > 0 ? 'You can refer up to ' + settings.maxReferrals + ' friends. ' : ''}No limit on savings! ๐ฐ
</p>
</td></tr>
<tr><td style="padding:0 40px 32px;text-align:center;">
<p style="margin:0;color:#888;font-size:13px;">
Just tell them to mention code <strong style="color:#ff2d2d;">${code}</strong> when they register!
</p>
</td></tr>
<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">
<p style="margin:0;color:#333;font-size:11px;">
<span style="color:#ff2d2d;">${settings.schoolName}</span><br>
<span style="color:#444;">Thank you for being an amazing student! ๐</span>
</p>
<p style="margin:8px 0 0;color:#333;font-size:10px;">
<a href="${unsub}" style="color:#555;text-decoration:underline;">Unsubscribe</a>
</p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
try {
MailApp.sendEmail({ to: student.email, subject: '๐ You earned a referral reward! Share code ' + code + ' โ ' + settings.schoolName, htmlBody: html });
} catch (e) { Logger.log('Error sending referral code email to ' + student.email + ': ' + e.message); }
}
function sendReferrerNotification(referrer, newStudentName, reward, totalEarned, settings) {
const firstName = referrer.name.split(' ')[0];
const newFirstName = newStudentName.split(' ')[0];
const unsub = buildUnsubscribeLink(referrer.name, settings.unsubscribeEmail);
const html = `
<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#000000;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000000;padding:20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">
<tr><td style="padding:36px 40px 16px;text-align:center;">
<div style="font-size:36px;">๐ฐ</div>
<h1 style="margin:12px 0 0;color:#ffffff;font-size:20px;font-weight:700;">
Ka-ching! ${newFirstName} just used your code!
</h1>
</td></tr>
<tr><td style="padding:12px 40px 24px;text-align:center;">
<div style="background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);border-radius:14px;padding:20px;display:inline-block;">
<p style="margin:0;color:#888;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Credit Earned</p>
<p style="margin:4px 0 0;color:#22c55e;font-size:36px;font-weight:800;">+$${reward}</p>
</div>
<p style="margin:12px 0 0;color:#666;font-size:13px;">
Total referral credits: <strong style="color:#22c55e;">$${totalEarned}</strong>
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<p style="color:#bbb;font-size:14px;line-height:1.6;margin:0;">
Your friend ${newFirstName} signed up for driving lessons and used your referral code.
Your <strong style="color:#22c55e;">$${reward} credit</strong> will be applied to your account!
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<p style="color:#888;font-size:13px;margin:0;">
Keep sharing your code <strong style="color:#ff2d2d;">${referrer.code}</strong> to earn more! ๐ฅ
</p>
</td></tr>
<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">
<p style="margin:0;color:#333;font-size:11px;"><span style="color:#ff2d2d;">${settings.schoolName}</span> โ Referral Rewards</p>
<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="${unsub}" style="color:#555;text-decoration:underline;">Unsubscribe</a></p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
try {
MailApp.sendEmail({ to: referrer.email, subject: '๐ฐ +$' + reward + '! Someone used your referral code โ ' + settings.schoolName, htmlBody: html });
} catch (e) { Logger.log('Error sending referrer notification: ' + e.message); }
}
function sendRefereeWelcome(name, email, reward, referrerName, settings) {
const firstName = name.split(' ')[0];
const referrerFirst = referrerName.split(' ')[0];
const unsub = buildUnsubscribeLink(name, settings.unsubscribeEmail);
const html = `
<!DOCTYPE html>
<html><head><meta charset="utf-8"></head>
<body style="margin:0;padding:0;background:#000000;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Display','Helvetica Neue',Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#000000;padding:20px;">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;">
<tr><td style="padding:36px 40px 16px;text-align:center;">
<div style="font-size:36px;">๐๐</div>
<h1 style="margin:12px 0 0;color:#ffffff;font-size:20px;font-weight:700;">
Welcome ${firstName}! You've got a reward!
</h1>
</td></tr>
<tr><td style="padding:12px 40px 24px;text-align:center;">
<p style="color:#bbb;font-size:14px;line-height:1.6;margin:0;">
Thanks to your friend ${referrerFirst}'s referral, you've earned a
<strong style="color:#22c55e;">$${reward} credit</strong> on your driving lessons!
</p>
<div style="background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2);border-radius:14px;padding:16px;margin:16px auto;max-width:200px;">
<p style="margin:0;color:#888;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Your Reward</p>
<p style="margin:4px 0 0;color:#22c55e;font-size:28px;font-weight:800;">$${reward} OFF</p>
</div>
<p style="color:#888;font-size:13px;margin:0;">
This credit will be applied to your package automatically.
</p>
</td></tr>
<tr><td style="padding:0 40px 24px;text-align:center;">
<p style="color:#666;font-size:13px;margin:0;">
Welcome to <span style="color:#ff2d2d;font-weight:600;">${settings.schoolName}</span>! We can't wait to help you get on the road. ๐
</p>
</td></tr>
<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">
<p style="margin:0;color:#333;font-size:11px;"><span style="color:#ff2d2d;">${settings.schoolName}</span> โ Referral Rewards</p>
<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="${unsub}" style="color:#555;text-decoration:underline;">Unsubscribe</a></p>
</td></tr>
</table>
</td></tr></table>
</body></html>`;
try {
MailApp.sendEmail({ to: email, subject: '๐ Welcome! You earned $' + reward + ' off your lessons โ ' + settings.schoolName, htmlBody: html });
} catch (e) { Logger.log('Error sending referee welcome: ' + e.message); }
}
function sendAdminNewCodesAlert(count, settings) {
try {
MailApp.sendEmail({
to: settings.adminEmail,
subject: '๐ฏ ' + count + ' New Referral Code(s) Generated โ ' + settings.schoolName,
htmlBody: '<div style="font-family:sans-serif;background:#0a0a0a;color:#ccc;padding:24px;border-radius:12px;"><h2 style="color:#ff2d2d;margin:0 0 8px;">New Referral Codes</h2><p>' + count + ' student(s) completed their package and received referral codes today.</p></div>'
});
} catch (e) { Logger.log('Error sending admin alert: ' + e.message); }
}
function sendAdminReferralAlert(referrer, newStudentName, pkg, referrerReward, refereeReward, settings) {
try {
MailApp.sendEmail({
to: settings.adminEmail,
subject: '๐ฐ Referral Used! ' + newStudentName + ' referred by ' + referrer.name + ' โ ' + settings.schoolName,
htmlBody: '<div style="font-family:sans-serif;background:#0a0a0a;color:#ccc;padding:24px;border-radius:12px;"><h2 style="color:#22c55e;margin:0 0 12px;">Referral Processed โ
</h2><p><strong style="color:#fff;">' + newStudentName + '</strong> signed up for <strong>' + pkg + '</strong></p><p>Referred by: <strong style="color:#fff;">' + referrer.name + '</strong> (code: <strong style="color:#ff2d2d;">' + referrer.code + '</strong>)</p><hr style="border:none;border-top:1px solid #222;margin:12px 0;"><p>Referrer credit: <strong style="color:#22c55e;">+$' + referrerReward + '</strong></p><p>New student discount: <strong style="color:#22c55e;">$' + refereeReward + ' off</strong></p></div>'
});
} catch (e) { Logger.log('Error sending admin referral alert: ' + e.message); }
}
// ================================================================
// DATA HELPERS
// ================================================================
function getExistingCodes(codesSheet) {
const data = codesSheet.getDataRange().getValues();
const emails = new Set();
const names = new Set();
for (let i = 1; i < data.length; i++) {
const email = String(data[i][1]).toLowerCase().trim();
const name = String(data[i][0]).toLowerCase().trim();
if (email && email.includes('@')) emails.add(email);
if (name) names.add(name);
}
return { emails, names };
}
function getRegisteredStudents() {
const students = [];
try {
const regSheet = SpreadsheetApp.openById(REFERRAL_CONFIG.REGISTRATION_ID);
const data = regSheet.getSheets()[0].getDataRange().getValues();
if (data.length < 2) return students;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['name', 'full name', 'student name']);
const emailCol = findCol(headers, ['email', 'student email', 'email address']);
const pkgCol = findCol(headers, ['package', 'lesson package', 'selected package', 'number of lessons']);
for (let i = 1; i < data.length; i++) {
const name = nameCol !== -1 ? String(data[i][nameCol]).trim() : '';
const email = emailCol !== -1 ? String(data[i][emailCol]).trim() : '';
const pkgRaw = pkgCol !== -1 ? String(data[i][pkgCol]).trim() : '';
if (!name) continue;
let pkg = 'Unknown', lessonCount = 0;
for (const [key, count] of Object.entries(REFERRAL_CONFIG.PACKAGES)) {
if (pkgRaw.toLowerCase().includes(key.toLowerCase())) { pkg = key; lessonCount = count; break; }
}
students.push({ name, email, package: pkg, lessonCount });
}
} catch (e) { Logger.log('Error reading registrations: ' + e.message); }
return students;
}
function getAttendanceCounts() {
// Returns { byEmail: {}, byName: {} }
const counts = { byEmail: {}, byName: {} };
try {
const schedSheet = SpreadsheetApp.openById(REFERRAL_CONFIG.SCHEDULE_ID);
const data = schedSheet.getSheets()[0].getDataRange().getValues();
if (data.length < 2) return counts;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['student name', 'student', 'name']);
const emailCol = findCol(headers, ['email', 'student email']);
const statusCol = findCol(headers, ['status', 'booking status']);
const dateCol = findCol(headers, ['date', 'lesson date', 'booking date']);
const now = new Date();
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'pending', 'scheduled', 'upcoming', 'rescheduled'];
for (let i = 1; i < data.length; i++) {
const name = nameCol !== -1 ? String(data[i][nameCol]).trim().toLowerCase() : '';
const email = emailCol !== -1 ? String(data[i][emailCol]).trim().toLowerCase() : '';
if (!name && !email) continue;
// Only count confirmed past lessons
if (dateCol !== -1) {
const d = data[i][dateCol];
if (d instanceof Date && d > now) continue;
if (!(d instanceof Date) && d) {
const parsed = new Date(d);
if (!isNaN(parsed) && parsed > now) continue;
}
}
if (statusCol !== -1) {
const status = String(data[i][statusCol]).toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
}
if (email && email.includes('@')) counts.byEmail[email] = (counts.byEmail[email] || 0) + 1;
if (name) counts.byName[name] = (counts.byName[name] || 0) + 1;
}
} catch (e) { Logger.log('Error reading schedule: ' + e.message); }
return counts;
}
function getStudentAttendance(student, attendance) {
// Email first
if (student.email) {
const ek = student.email.toLowerCase().trim();
if (attendance.byEmail[ek]) return attendance.byEmail[ek];
}
// Exact name
const nk = student.name.toLowerCase().trim();
if (attendance.byName[nk]) return attendance.byName[nk];
// Fuzzy name
for (const [aName, count] of Object.entries(attendance.byName)) {
if (fuzzyNameMatch(nk, aName)) return count;
}
return 0;
}
// ============ FUZZY NAME MATCHING ============
function fuzzyNameMatch(name1, name2) {
if (!name1 || !name2) return false;
const n1 = name1.toLowerCase().replace(/\s+/g, ' ').trim();
const n2 = name2.toLowerCase().replace(/\s+/g, ' ').trim();
if (n1 === n2) return true;
if (n1.includes(n2) || n2.includes(n1)) return true;
const p1 = n1.split(' ').filter(Boolean);
const p2 = n2.split(' ').filter(Boolean);
if (p1.length >= 2 && p2.length >= 2) {
if (p1[p1.length-1] === p2[p2.length-1] && p1[0].substring(0,3) === p2[0].substring(0,3)) return true;
if (p1[0] === p2[p2.length-1] && p1[p1.length-1] === p2[0]) return true;
}
if (levenshtein(n1, n2) <= 2) return true;
return false;
}
function isFuzzyTracked(nameKey, trackedNames) {
for (const tracked of trackedNames) {
if (fuzzyNameMatch(nameKey, tracked)) return true;
}
return false;
}
function levenshtein(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
m[i][j] = b.charAt(i-1) === a.charAt(j-1) ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
}
}
return m[b.length][a.length];
}
function getSettings(ss) {
const d = {
demoMode: REFERRAL_CONFIG.DEMO_MODE,
referrerReward: REFERRAL_CONFIG.REFERRER_REWARD,
refereeReward: REFERRAL_CONFIG.REFEREE_REWARD,
maxReferrals: REFERRAL_CONFIG.MAX_REFERRALS,
codeExpiryDays: REFERRAL_CONFIG.CODE_EXPIRY_DAYS,
schoolName: REFERRAL_CONFIG.SCHOOL_NAME,
adminEmail: REFERRAL_CONFIG.ADMIN_EMAIL,
unsubscribeEmail: REFERRAL_CONFIG.ADMIN_EMAIL,
includeInReviewEmail: true,
minPackage: 'any'
};
const sheet = ss.getSheetByName('Settings');
if (!sheet) return d;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
const key = String(data[i][0]).toLowerCase().trim();
const val = String(data[i][1]).trim();
if (key.includes('demo mode')) d.demoMode = val.toLowerCase() === 'yes';
else if (key.includes('referrer reward')) d.referrerReward = parseFloat(val) || 25;
else if (key.includes('new student reward')) d.refereeReward = parseFloat(val) || 25;
else if (key.includes('max referrals')) d.maxReferrals = parseInt(val) || 0;
else if (key.includes('expiry')) d.codeExpiryDays = parseInt(val) || 0;
else if (key.includes('school name')) d.schoolName = val;
else if (key.includes('admin email')) d.adminEmail = val;
else if (key.includes('unsubscribe')) d.unsubscribeEmail = val;
else if (key.includes('review email')) d.includeInReviewEmail = val.toLowerCase() === 'yes';
else if (key.includes('minimum package')) d.minPackage = val.toLowerCase();
}
return d;
}
function findCol(headers, keywords) {
for (const kw of keywords) {
const idx = headers.findIndex(h => h.includes(kw));
if (idx !== -1) return idx;
}
return -1;
}
function formatHeader(sheet, cols) {
const range = sheet.getRange(1, 1, 1, cols);
range.setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontWeight('bold').setFontSize(10).setHorizontalAlignment('center');
sheet.setFrozenRows(1);
}
// ================================================================
// TRIGGERS
// ================================================================
function setupTriggers() {
const clean = ['dailyCompletionCheck', 'checkNewRegistrations'];
ScriptApp.getProjectTriggers().forEach(t => {
if (clean.includes(t.getHandlerFunction())) ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('dailyCompletionCheck').timeBased().everyDays(1).atHour(10).nearMinute(30).create();
ScriptApp.newTrigger('checkNewRegistrations').timeBased().everyHours(2).create();
Logger.log('Triggers set: dailyCompletionCheck at 10:30 AM, checkNewRegistrations every 2h');
}
// ================================================================
// MANUAL TESTING
// ================================================================
function testReferralCodeEmail() {
const settings = getSettings(getSpreadsheet());
const code = generateCode('Test', null);
sendReferralCodeEmail({ name: 'Test Student', email: REFERRAL_CONFIG.ADMIN_EMAIL }, code, settings);
Logger.log('Test referral code email sent with code: ' + code);
}
function testReferrerNotification() {
const settings = getSettings(getSpreadsheet());
sendReferrerNotification({ name: 'Test Referrer', email: REFERRAL_CONFIG.ADMIN_EMAIL, code: 'TEST-X7K2' }, 'New Friend', 25, 75, settings);
Logger.log('Test referrer notification sent');
}
function testRefereeWelcome() {
const settings = getSettings(getSpreadsheet());
sendRefereeWelcome('New Student', REFERRAL_CONFIG.ADMIN_EMAIL, 25, 'Test Referrer', settings);
Logger.log('Test referee welcome sent');
}
function testLeaderboard() {
emailLeaderboard();
}
/**
* =========================================================
* SMART SCHEDULING OPTIMIZER โ Automation #14
* Flavors Driving School
* =========================================================
* Features:
* - Demo mode for presentations (fake data, no real emails)
* - Scan Schedule Board for open slots
* - Match unscheduled students to open slots (email-first + fuzzy name)
* - Travel time buffer (15-min gap flags)
* - Student preference matching (instructor + time + day)
* - Cancellation backfill (auto-email waitlist)
* - Instructor load balancing
* - Schedule Health Score (0-100)
* - Weekly optimization report
* - Priority fills (most lessons remaining first)
* - "We have an opening" auto-emails with unsubscribe
* - SMS-ready flag for future text notifications
* - Duplicate email guard (ScriptProperties cooldown)
* =========================================================
*/
// โโ CONFIG โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const CONFIG = {
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
PAYMENT_TRACKER_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_EMAIL: '[email protected]',
INSTRUCTORS: ['Anisha', 'Carlos', 'Nick'],
WORK_START: 9,
WORK_END: 18,
SLOT_DURATION: 60,
TRAVEL_BUFFER_MINUTES: 15,
LOAD_BALANCE_THRESHOLD: 0.3,
MAX_EMAILS_PER_RUN: 20,
EMAIL_COOLDOWN_HOURS: 48, // Don't re-email same student within this window
// DEMO MODE โ set to true for presentations
DEMO_MODE: true,
};
// โโ FORCE AUTH โ all connected sheets + email โโโโโโโโโโโ
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized โ 3 sheets + email.');
}
// โโ SETUP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function fullSetup() {
createOptimizerSheets_();
setupTriggers_();
Logger.log('โ
Smart Scheduling Optimizer fully set up.');
}
function createOptimizerSheets_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
// Student Preferences
let prefSheet = ss.getSheetByName('Student Preferences');
if (!prefSheet) {
prefSheet = ss.insertSheet('Student Preferences');
prefSheet.getRange('A1:F1').setValues([['Student Name', 'Email', 'Preferred Instructor', 'Preferred Time', 'Preferred Days', 'Notes']]);
formatHeader_(prefSheet, 6);
}
// Waitlist
let waitSheet = ss.getSheetByName('Waitlist');
if (!waitSheet) {
waitSheet = ss.insertSheet('Waitlist');
waitSheet.getRange('A1:H1').setValues([['Student Name', 'Email', 'Phone', 'Preferred Instructor', 'Preferred Day', 'Preferred Time', 'Date Added', 'Status']]);
formatHeader_(waitSheet, 8);
}
// Optimization Log
let logSheet = ss.getSheetByName('Optimization Log');
if (!logSheet) {
logSheet = ss.insertSheet('Optimization Log');
logSheet.getRange('A1:F1').setValues([['Date', 'Action', 'Student', 'Instructor', 'Slot', 'Details']]);
formatHeader_(logSheet, 6);
}
// Optimizer Settings
let settingsSheet = ss.getSheetByName('Optimizer Settings');
if (!settingsSheet) {
settingsSheet = ss.insertSheet('Optimizer Settings');
settingsSheet.getRange('A1:B1').setValues([['Setting', 'Value']]);
formatHeader_(settingsSheet, 2);
const defaults = [
['Demo Mode', CONFIG.DEMO_MODE ? 'Yes' : 'No'],
['Travel Buffer (minutes)', CONFIG.TRAVEL_BUFFER_MINUTES],
['Load Balance Threshold (%)', CONFIG.LOAD_BALANCE_THRESHOLD * 100],
['Max Emails Per Run', CONFIG.MAX_EMAILS_PER_RUN],
['Auto-Send Opening Emails', 'Yes'],
['Auto-Backfill Cancellations', 'Yes'],
['Weekly Report Day', 'Monday'],
['Weekly Report Hour', 8],
['Unsubscribe Email', CONFIG.SCHOOL_EMAIL],
['SMS Notifications (Future)', 'No'],
['Email Cooldown (hours)', CONFIG.EMAIL_COOLDOWN_HOURS],
];
settingsSheet.getRange(2, 1, defaults.length, 2).setValues(defaults);
settingsSheet.getRange(2, 2).setFontColor('#f59e0b').setFontWeight('bold').setFontSize(12);
settingsSheet.getRange(2, 1, defaults.length, 2).setBackground('#0a0a0a');
settingsSheet.getRange(2, 1, defaults.length, 1).setFontColor('#888');
settingsSheet.getRange(2, 2, defaults.length, 1).setFontColor('#fff').setFontWeight('bold');
settingsSheet.setColumnWidth(1, 260);
settingsSheet.setColumnWidth(2, 160);
}
}
function formatHeader_(sheet, cols) {
sheet.getRange(1, 1, 1, cols).setFontWeight('bold').setBackground('#1a1a1a').setFontColor('#ff2d2d').setFontSize(10);
sheet.setFrozenRows(1);
}
// โโ TRIGGERS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function setupTriggers_() {
const clean = ['dailyOptimize', 'weeklyReport'];
ScriptApp.getProjectTriggers().forEach(t => {
if (clean.includes(t.getHandlerFunction())) ScriptApp.deleteTrigger(t);
});
ScriptApp.newTrigger('dailyOptimize').timeBased().everyDays(1).atHour(7).nearMinute(0).create();
ScriptApp.newTrigger('weeklyReport').timeBased().onWeekDay(ScriptApp.WeekDay.MONDAY).atHour(8).nearMinute(0).create();
Logger.log('โ
Triggers: dailyOptimize 7 AM daily, weeklyReport Monday 8 AM');
}
// ================================================================
// DEMO DATA
// ================================================================
const DEMO = {
openSlots: [
{ dateStr: '7/1/2025', dayName: 'Tuesday', time: '10:00 AM', hour: 10, instructor: 'Carlos', key: 'demo1' },
{ dateStr: '7/1/2025', dayName: 'Tuesday', time: '2:00 PM', hour: 14, instructor: 'Anisha', key: 'demo2' },
{ dateStr: '7/2/2025', dayName: 'Wednesday', time: '11:00 AM', hour: 11, instructor: 'Nick', key: 'demo3' },
{ dateStr: '7/2/2025', dayName: 'Wednesday', time: '3:00 PM', hour: 15, instructor: 'Carlos', key: 'demo4' },
{ dateStr: '7/3/2025', dayName: 'Thursday', time: '9:00 AM', hour: 9, instructor: 'Anisha', key: 'demo5' },
{ dateStr: '7/3/2025', dayName: 'Thursday', time: '1:00 PM', hour: 13, instructor: 'Nick', key: 'demo6' },
{ dateStr: '7/4/2025', dayName: 'Friday', time: '10:00 AM', hour: 10, instructor: 'Carlos', key: 'demo7' },
{ dateStr: '7/4/2025', dayName: 'Friday', time: '4:00 PM', hour: 16, instructor: 'Anisha', key: 'demo8' },
],
students: [
{ name: 'Sarah Johnson', email: '[email protected]', phone: '555-1234', package: '10 Lessons', totalLessons: 10, lessonsRemaining: 7, balance: 150 },
{ name: 'Marcus Williams', email: '[email protected]', phone: '555-5678', package: '5 Lessons', totalLessons: 5, lessonsRemaining: 3, balance: 0 },
{ name: 'Priya Patel', email: '[email protected]', phone: '555-9012', package: '15 Lessons', totalLessons: 15, lessonsRemaining: 12, balance: 300 },
],
preferences: {
'sarah johnson': { preferredInstructor: 'Carlos', preferredTime: 'Morning', preferredDays: 'Tue, Thu' },
'marcus williams': { preferredInstructor: '', preferredTime: 'Afternoon', preferredDays: '' },
'priya patel': { preferredInstructor: 'Anisha', preferredTime: '10 AM', preferredDays: 'Mon, Wed, Fri' },
},
loadData: {
'Anisha': { booked: 22, total: 63, utilization: 0.35 },
'Carlos': { booked: 35, total: 63, utilization: 0.56 },
'Nick': { booked: 18, total: 63, utilization: 0.29 },
}
};
// ================================================================
// CORE: DAILY OPTIMIZATION
// ================================================================
function dailyOptimize() {
try {
const settings = getSettings_();
const isDemo = settings.demoMode;
if (isDemo) {
Logger.log('โ ๏ธ DEMO MODE โ running with simulated data, no real emails sent.');
}
const today = new Date();
const daysToScan = 7;
const openSlots = isDemo ? DEMO.openSlots : getOpenSlots_(today, daysToScan);
const unscheduledStudents = isDemo ? DEMO.students : getUnscheduledStudents_();
const preferences = isDemo ? DEMO.preferences : getStudentPreferences_();
const loadData = isDemo ? DEMO.loadData : getInstructorLoad_(today, daysToScan);
if (openSlots.length === 0 || unscheduledStudents.length === 0) {
logAction_('Daily Scan', '-', '-', '-',
'Open slots: ' + openSlots.length + ', Unscheduled: ' + unscheduledStudents.length + '. No matches needed.');
return;
}
const matches = scoreMatches_(openSlots, unscheduledStudents, preferences, loadData);
const maxEmails = settings.maxEmails || CONFIG.MAX_EMAILS_PER_RUN;
const autoSend = settings.autoSendOpenings;
const cooldownHours = settings.emailCooldownHours || CONFIG.EMAIL_COOLDOWN_HOURS;
if (autoSend && matches.length > 0) {
let sent = 0;
for (const match of matches) {
if (sent >= maxEmails) break;
// Duplicate email guard โ skip if emailed recently
if (!isDemo && hasRecentEmail_(match.student.email, cooldownHours)) {
Logger.log('โญ๏ธ Skipping ' + match.student.email + ' โ emailed within ' + cooldownHours + 'h');
continue;
}
if (!isDemo) {
sendOpeningEmail_(match, settings);
recordEmailSent_(match.student.email);
}
logAction_('Opening Email' + (isDemo ? ' [DEMO]' : ''), match.student.name, match.slot.instructor,
formatSlot_(match.slot), 'Score: ' + match.score);
sent++;
}
Logger.log((isDemo ? 'DEMO: Would send ' : 'Sent ') + sent + ' opening emails.');
}
// Calculate health score
const totalSlots = CONFIG.INSTRUCTORS.length * daysToScan * (CONFIG.WORK_END - CONFIG.WORK_START);
const filledSlots = totalSlots - openSlots.length;
const healthScore = calculateHealthScore_(filledSlots, totalSlots, loadData, unscheduledStudents.length);
logAction_('Daily Scan Complete', '-', '-', '-',
'Slots: ' + openSlots.length + ' open / ' + totalSlots + ' total, Matches: ' + matches.length + ', Health: ' + healthScore + '/100');
} catch (e) {
Logger.log('โ dailyOptimize error: ' + e.message + '\n' + e.stack);
logAction_('ERROR', '-', '-', '-', 'dailyOptimize: ' + e.message);
}
}
// ================================================================
// EMAIL COOLDOWN (duplicate guard)
// ================================================================
function hasRecentEmail_(email, cooldownHours) {
try {
const props = PropertiesService.getScriptProperties();
const key = 'email_sent_' + email.toLowerCase();
const lastSent = props.getProperty(key);
if (!lastSent) return false;
const hoursSince = (Date.now() - parseInt(lastSent)) / (1000 * 60 * 60);
return hoursSince < cooldownHours;
} catch (e) { return false; }
}
function recordEmailSent_(email) {
try {
const props = PropertiesService.getScriptProperties();
props.setProperty('email_sent_' + email.toLowerCase(), String(Date.now()));
} catch (e) { Logger.log('Could not record email timestamp: ' + e.message); }
}
// ================================================================
// SCHEDULE HEALTH SCORE (0-100)
// ================================================================
function calculateHealthScore_(filledSlots, totalSlots, loadData, unscheduledCount) {
// Utilization component (0-40 points): higher utilization = better
const utilPct = totalSlots > 0 ? filledSlots / totalSlots : 0;
const utilScore = Math.round(utilPct * 40);
// Balance component (0-30 points): even distribution = better
const utils = CONFIG.INSTRUCTORS.map(i => (loadData[i] ? loadData[i].utilization : 0));
const maxU = Math.max(...utils);
const minU = Math.min(...utils);
const spread = maxU - minU; // 0 = perfect balance, 1 = worst
const balanceScore = Math.round((1 - spread) * 30);
// Coverage component (0-30 points): fewer unscheduled students = better
const coverageScore = unscheduledCount === 0 ? 30 : Math.max(0, 30 - (unscheduledCount * 3));
return Math.min(100, utilScore + balanceScore + coverageScore);
}
function getHealthScore() {
const settings = getSettings_();
const isDemo = settings.demoMode;
const today = new Date();
const openSlots = isDemo ? DEMO.openSlots : getOpenSlots_(today, 7);
const unscheduled = isDemo ? DEMO.students : getUnscheduledStudents_();
const loadData = isDemo ? DEMO.loadData : getInstructorLoad_(today, 7);
const totalSlots = CONFIG.INSTRUCTORS.length * 7 * (CONFIG.WORK_END - CONFIG.WORK_START);
const filledSlots = totalSlots - openSlots.length;
const score = calculateHealthScore_(filledSlots, totalSlots, loadData, unscheduled.length);
Logger.log('Schedule Health Score: ' + score + '/100');
return score;
}
// ================================================================
// OPEN SLOT SCANNER
// ================================================================
function getOpenSlots_(startDate, days) {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookingsSheet = ss.getSheetByName('Bookings') || ss.getSheets()[0];
const bookingsData = bookingsSheet.getDataRange().getValues();
if (bookingsData.length < 2) return [];
const headers = bookingsData[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
// Only active bookings occupy slots โ skip cancelled/no-show
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow'];
const bookedSlots = new Set();
for (let i = 1; i < bookingsData.length; i++) {
const status = String(bookingsData[i][statusCol] || '').toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
const date = bookingsData[i][dateCol];
const time = String(bookingsData[i][timeCol] || '').trim();
const instructor = String(bookingsData[i][instructorCol] || '').trim();
if (date && instructor) {
const dateStr = formatDate_(date instanceof Date ? date : new Date(date));
// Normalize time for consistent matching
const normalizedTime = normalizeTime_(time);
bookedSlots.add(dateStr + '|' + normalizedTime + '|' + instructor);
}
}
const openSlots = [];
for (let d = 0; d < days; d++) {
const date = new Date(startDate);
date.setDate(date.getDate() + d);
const dateStr = formatDate_(date);
const dayName = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][date.getDay()];
for (const instructor of CONFIG.INSTRUCTORS) {
for (let hour = CONFIG.WORK_START; hour < CONFIG.WORK_END; hour++) {
const timeStr = formatHour_(hour);
const key = dateStr + '|' + timeStr + '|' + instructor;
if (!bookedSlots.has(key)) {
openSlots.push({ date: date, dateStr: dateStr, dayName: dayName, time: timeStr, hour: hour, instructor: instructor, key: key });
}
}
}
}
return openSlots;
}
// ================================================================
// UNSCHEDULED STUDENT FINDER
// ================================================================
function getUnscheduledStudents_() {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const regData = regSS.getSheets()[0].getDataRange().getValues();
if (regData.length < 2) return [];
const regHeaders = regData[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(regHeaders, ['full name', 'name', 'student name']);
const emailCol = findCol_(regHeaders, ['email', 'email address', 'student email']);
const packageCol = findCol_(regHeaders, ['lesson package', 'package', 'class type', 'selected package']);
const phoneCol = findCol_(regHeaders, ['phone', 'phone number', 'cell', 'mobile']);
// Get booked students (by email + name)
const bookedEmails = new Set();
const bookedNames = new Set();
const schedSS = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookingsSheet = schedSS.getSheetByName('Bookings') || schedSS.getSheets()[0];
if (bookingsSheet) {
const bookData = bookingsSheet.getDataRange().getValues();
const bookHeaders = bookData[0].map(h => String(h).toLowerCase().trim());
const bookNameCol = findCol_(bookHeaders, ['student', 'student name', 'name']);
const bookEmailCol = findCol_(bookHeaders, ['email', 'student email']);
const bookStatusCol = findCol_(bookHeaders, ['status', 'booking status']);
// Skip cancelled/no-show/rescheduled โ these don't count as "booked"
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'rescheduled'];
for (let i = 1; i < bookData.length; i++) {
const status = String(bookData[i][bookStatusCol] || '').toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
const name = String(bookData[i][bookNameCol] || '').trim().toLowerCase();
const email = String(bookData[i][bookEmailCol] || '').trim().toLowerCase();
if (name) bookedNames.add(name);
if (email && email.includes('@')) bookedEmails.add(email);
}
}
const paymentStatus = getPaymentStatus_();
const unscheduled = [];
for (let i = 1; i < regData.length; i++) {
const name = String(regData[i][nameCol] || '').trim();
const email = String(regData[i][emailCol] || '').trim();
const pkg = String(regData[i][packageCol] || '').trim();
const phone = phoneCol !== -1 ? String(regData[i][phoneCol] || '').trim() : '';
if (!name || !email) continue;
// Match: email first, then exact name, then fuzzy
let isBooked = false;
if (email && bookedEmails.has(email.toLowerCase())) isBooked = true;
else if (bookedNames.has(name.toLowerCase())) isBooked = true;
else if (isFuzzyTracked_(name.toLowerCase(), bookedNames)) isBooked = true;
if (!isBooked) {
const lessonsInPackage = extractLessonCount_(pkg);
const pStatus = paymentStatus[email.toLowerCase()] || paymentStatus[name.toLowerCase()] || {};
unscheduled.push({
name: name, email: email, phone: phone, package: pkg,
totalLessons: lessonsInPackage,
lessonsRemaining: lessonsInPackage - (pStatus.lessonsCompleted || 0),
balance: pStatus.balance || 0,
});
}
}
unscheduled.sort((a, b) => b.lessonsRemaining - a.lessonsRemaining);
return unscheduled;
}
// ================================================================
// STUDENT PREFERENCES
// ================================================================
function getStudentPreferences_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const prefSheet = ss.getSheetByName('Student Preferences');
if (!prefSheet) return {};
const data = prefSheet.getDataRange().getValues();
if (data.length < 2) return {};
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student name', 'name', 'student']);
const emailCol = findCol_(headers, ['email', 'student email']);
const instCol = findCol_(headers, ['preferred instructor', 'instructor']);
const timeCol = findCol_(headers, ['preferred time', 'time']);
const daysCol = findCol_(headers, ['preferred days', 'days']);
const prefs = {};
for (let i = 1; i < data.length; i++) {
const name = String(data[i][nameCol] || '').trim().toLowerCase();
const email = String(data[i][emailCol] || '').trim().toLowerCase();
if (!name && !email) continue;
const pref = {
preferredInstructor: String(data[i][instCol] || '').trim(),
preferredTime: String(data[i][timeCol] || '').trim(),
preferredDays: String(data[i][daysCol] || '').trim(),
};
// Index by both name and email for flexible lookup
if (name) prefs[name] = pref;
if (email) prefs[email] = pref;
}
return prefs;
}
// ================================================================
// INSTRUCTOR LOAD BALANCING
// ================================================================
function getInstructorLoad_(startDate, days) {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookingsSheet = ss.getSheetByName('Bookings') || ss.getSheets()[0];
const data = bookingsSheet.getDataRange().getValues();
if (data.length < 2) {
const load = {};
CONFIG.INSTRUCTORS.forEach(i => { load[i] = { booked: 0, total: days * (CONFIG.WORK_END - CONFIG.WORK_START), utilization: 0 }; });
return load;
}
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + days);
// For load balancing, only skip cancelled/no-show โ we WANT to count
// confirmed, pending, scheduled, upcoming as they occupy slots
const skipStatuses = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow'];
const load = {};
CONFIG.INSTRUCTORS.forEach(i => { load[i] = { booked: 0, total: days * (CONFIG.WORK_END - CONFIG.WORK_START) }; });
for (let i = 1; i < data.length; i++) {
const status = String(data[i][statusCol] || '').toLowerCase().trim();
if (skipStatuses.includes(status)) continue;
const rawDate = data[i][dateCol];
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (isNaN(date.getTime())) continue;
const instructor = String(data[i][instructorCol] || '').trim();
if (date >= startDate && date <= endDate && load[instructor]) {
load[instructor].booked++;
}
}
for (const inst of CONFIG.INSTRUCTORS) {
load[inst].utilization = load[inst].total > 0 ? load[inst].booked / load[inst].total : 0;
}
return load;
}
// ================================================================
// MATCH SCORING ENGINE
// ================================================================
function scoreMatches_(openSlots, students, preferences, loadData) {
const matches = [];
for (const student of students) {
// Look up preferences by name first, then email
const pref = preferences[student.name.toLowerCase()] || preferences[student.email.toLowerCase()] || {};
for (const slot of openSlots) {
let score = 50;
// Instructor preference
if (pref.preferredInstructor) {
score += slot.instructor.toLowerCase() === pref.preferredInstructor.toLowerCase() ? 30 : -10;
}
// Time preference
if (pref.preferredTime) {
const prefHour = parseTimePreference_(pref.preferredTime);
if (prefHour !== null) {
const diff = Math.abs(slot.hour - prefHour);
if (diff <= 1) score += 20;
else if (diff <= 2) score += 10;
else score -= 5;
}
}
// Day preference
if (pref.preferredDays) {
const prefDays = pref.preferredDays.toLowerCase().split(/[,\s]+/);
if (prefDays.some(d => slot.dayName.toLowerCase().startsWith(d.substring(0, 3)))) score += 15;
}
// Load balancing โ favor underutilized instructors
if (loadData[slot.instructor]) {
const util = loadData[slot.instructor].utilization;
if (util < 0.3) score += 15;
else if (util > 0.7) score -= 10;
}
// Lessons remaining priority โ more remaining = higher urgency
if (student.lessonsRemaining > 10) score += 10;
else if (student.lessonsRemaining > 5) score += 5;
// Travel buffer โ penalize if instructor has back-to-back (no adjacent open slot)
if (!hasAdjacentOpenSlot_(slot, openSlots)) score -= 5;
// Sooner = better
if (slot.date) {
const daysOut = Math.floor((slot.date - new Date()) / (1000 * 60 * 60 * 24));
if (daysOut <= 1) score += 10;
else if (daysOut <= 3) score += 5;
}
matches.push({ student: student, slot: slot, score: score });
}
}
matches.sort((a, b) => b.score - a.score);
// Deduplicate: best match per student, and also per slot
const seenStudents = new Set();
const seenSlots = new Set();
const deduped = [];
for (const m of matches) {
if (!seenStudents.has(m.student.email) && !seenSlots.has(m.slot.key)) {
seenStudents.add(m.student.email);
seenSlots.add(m.slot.key);
deduped.push(m);
}
}
return deduped;
}
/**
* Check if an instructor has an adjacent open slot (travel buffer).
* If adjacent hours are also open, there's breathing room. If not,
* it means back-to-back bookings with no buffer.
*/
function hasAdjacentOpenSlot_(slot, allOpenSlots) {
return allOpenSlots.some(s =>
s.instructor === slot.instructor &&
s.dateStr === slot.dateStr &&
s.key !== slot.key &&
(s.hour === slot.hour - 1 || s.hour === slot.hour + 1)
);
}
// ================================================================
// CANCELLATION BACKFILL
// ================================================================
function handleCancellation(instructor, date, time) {
const settings = getSettings_();
if (!settings.autoBackfill) return;
if (settings.demoMode) { Logger.log('โ ๏ธ DEMO: Would backfill ' + instructor + ' ' + date + ' ' + time); return; }
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const waitSheet = ss.getSheetByName('Waitlist');
if (!waitSheet) return;
const data = waitSheet.getDataRange().getValues();
const dayName = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][
(date instanceof Date ? date : new Date(date)).getDay()
];
let backfilled = 0;
for (let i = 1; i < data.length; i++) {
const status = String(data[i][7] || '').toLowerCase();
if (status === 'filled' || status === 'notified') continue;
const prefInstructor = String(data[i][3] || '').trim();
const prefDay = String(data[i][4] || '').trim();
const email = String(data[i][1] || '').trim();
const name = String(data[i][0] || '').trim();
const instructorMatch = !prefInstructor || prefInstructor.toLowerCase() === instructor.toLowerCase();
const dayMatch = !prefDay || prefDay.toLowerCase().startsWith(dayName.toLowerCase().substring(0, 3));
if (instructorMatch && dayMatch && email) {
sendBackfillEmail_(name, email, instructor, date, time, settings);
waitSheet.getRange(i + 1, 8).setValue('Notified');
logAction_('Backfill Email', name, instructor, formatDate_(date) + ' ' + time, 'Cancellation backfill');
backfilled++;
if (backfilled >= 3) break;
}
}
return backfilled;
}
function scanCancellations() {
const settings = getSettings_();
if (settings.demoMode) { Logger.log('โ ๏ธ DEMO: scanCancellations skipped.'); return; }
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const bookingsSheet = ss.getSheetByName('Bookings') || ss.getSheets()[0];
const data = bookingsSheet.getDataRange().getValues();
if (data.length < 2) return;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const dateCol = findCol_(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol_(headers, ['time', 'lesson time', 'start time', 'time slot']);
const instructorCol = findCol_(headers, ['instructor', 'instructor name']);
const statusCol = findCol_(headers, ['status', 'booking status']);
const today = new Date();
const processed = new Set(); // Dedup: don't process same slot twice
for (let i = 1; i < data.length; i++) {
const status = String(data[i][statusCol] || '').toLowerCase();
if (status !== 'cancelled' && status !== 'canceled') continue;
const rawDate = data[i][dateCol];
const date = rawDate instanceof Date ? rawDate : new Date(rawDate);
if (isNaN(date.getTime()) || date < today) continue;
const instructor = String(data[i][instructorCol] || '').trim();
const time = String(data[i][timeCol] || '').trim();
const slotKey = formatDate_(date) + '|' + instructor + '|' + time;
if (processed.has(slotKey)) continue;
processed.add(slotKey);
handleCancellation(instructor, date, time);
}
}
// ================================================================
// PAYMENT STATUS
// ================================================================
function getPaymentStatus_() {
try {
const ss = SpreadsheetApp.openById(CONFIG.PAYMENT_TRACKER_ID);
let balSheet = ss.getSheetByName('Student Balances');
if (!balSheet) balSheet = ss.getSheets()[0];
const data = balSheet.getDataRange().getValues();
if (data.length < 2) return {};
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(headers, ['student', 'student name', 'name']);
const emailCol = findCol_(headers, ['email', 'student email']);
const balCol = findCol_(headers, ['balance', 'remaining balance', 'amount due']);
const lessonsCol = findCol_(headers, ['lessons completed', 'completed']);
const status = {};
for (let i = 1; i < data.length; i++) {
const name = String(data[i][nameCol] || '').trim().toLowerCase();
const email = emailCol !== -1 ? String(data[i][emailCol] || '').trim().toLowerCase() : '';
if (!name && !email) continue;
const entry = {
balance: parseFloat(data[i][balCol]) || 0,
lessonsCompleted: parseInt(data[i][lessonsCol]) || 0,
};
if (name) status[name] = entry;
if (email) status[email] = entry;
}
return status;
} catch (e) { Logger.log('Payment status error: ' + e.message); return {}; }
}
// ================================================================
// WEEKLY OPTIMIZATION REPORT
// ================================================================
function weeklyReport() {
try {
const settings = getSettings_();
const isDemo = settings.demoMode;
const today = new Date();
const openSlots = isDemo ? DEMO.openSlots : getOpenSlots_(today, 7);
const unscheduled = isDemo ? DEMO.students : getUnscheduledStudents_();
const loadData = isDemo ? DEMO.loadData : getInstructorLoad_(today, 7);
const totalSlots = CONFIG.INSTRUCTORS.length * 7 * (CONFIG.WORK_END - CONFIG.WORK_START);
const filledSlots = totalSlots - openSlots.length;
const overallUtil = ((filledSlots / totalSlots) * 100).toFixed(1);
const healthScore = calculateHealthScore_(filledSlots, totalSlots, loadData, unscheduled.length);
const utils = CONFIG.INSTRUCTORS.map(i => ({
name: i,
util: ((loadData[i] ? loadData[i].utilization : 0) * 100).toFixed(1),
booked: loadData[i] ? loadData[i].booked : 0,
}));
const maxUtil = Math.max(...utils.map(u => parseFloat(u.util)));
const minUtil = Math.min(...utils.map(u => parseFloat(u.util)));
const imbalanced = (maxUtil - minUtil) > (CONFIG.LOAD_BALANCE_THRESHOLD * 100);
const gapsByDay = {};
openSlots.forEach(s => { gapsByDay[s.dayName] = (gapsByDay[s.dayName] || 0) + 1; });
const html = buildWeeklyReportEmail_({
overallUtil: overallUtil,
filledSlots: filledSlots,
totalSlots: totalSlots,
openSlotCount: openSlots.length,
instructors: utils,
imbalanced: imbalanced,
gapsByDay: gapsByDay,
unscheduledCount: unscheduled.length,
topUnscheduled: unscheduled.slice(0, 5),
healthScore: healthScore,
isDemo: isDemo,
});
try {
MailApp.sendEmail({
to: CONFIG.SCHOOL_EMAIL,
subject: (isDemo ? '[DEMO] ' : '') + '๐ Weekly Schedule Report โ Health: ' + healthScore + '/100 โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html
});
} catch (e) { Logger.log('Error sending weekly report: ' + e.message); }
logAction_('Weekly Report' + (isDemo ? ' [DEMO]' : ''), '-', '-', '-',
'Health: ' + healthScore + '/100, Util: ' + overallUtil + '%, Open: ' + openSlots.length);
} catch (e) {
Logger.log('โ weeklyReport error: ' + e.message + '\n' + e.stack);
logAction_('ERROR', '-', '-', '-', 'weeklyReport: ' + e.message);
}
}
// ================================================================
// EMAIL TEMPLATES โ Mission Control theme + Unsubscribe
// ================================================================
function missionControlWrapper_(title, content, unsubEmail) {
const unsubLink = unsubEmail ? '<p style="margin:8px 0 0;color:#333;font-size:10px;"><a href="mailto:' + escHtml_(unsubEmail) + '?subject=Unsubscribe%20Schedule%20Notifications" style="color:#555;text-decoration:underline;">Unsubscribe</a></p>' : '';
return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>' +
'<body style="margin:0;padding:0;background:#000;font-family:-apple-system,BlinkMacSystemFont,\'SF Pro Display\',sans-serif;">' +
'<table width="100%" cellpadding="0" cellspacing="0" style="background:#000;padding:20px;">' +
'<tr><td align="center">' +
'<table width="560" cellpadding="0" cellspacing="0" style="background:#0d0d0d;border-radius:20px;border:1px solid rgba(255,255,255,0.06);overflow:hidden;box-shadow:0 8px 30px rgba(0,0,0,0.5);">' +
'<tr><td style="padding:30px 40px;border-bottom:1px solid rgba(255,255,255,0.06);">' +
'<table width="100%"><tr>' +
'<td style="color:#ff2d2d;font-size:22px;font-weight:700;">โก ' + escHtml_(title) + '</td>' +
'<td align="right" style="color:#555;font-size:11px;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</td>' +
'</tr></table></td></tr>' +
'<tr><td style="padding:30px 40px;">' + content + '</td></tr>' +
'<tr><td style="padding:16px 40px;border-top:1px solid rgba(255,255,255,0.06);text-align:center;">' +
'<p style="color:#333;font-size:11px;margin:0;">Powered by Smart Scheduling Optimizer โข <span style="color:#ff2d2d;">' + escHtml_(CONFIG.SCHOOL_NAME) + '</span></p>' +
unsubLink +
'</td></tr></table></td></tr></table></body></html>';
}
function sendOpeningEmail_(match, settings) {
const s = match.student;
const sl = match.slot;
const unsub = settings.unsubscribeEmail || CONFIG.SCHOOL_EMAIL;
const firstName = escHtml_(s.name.split(' ')[0]);
const content =
'<p style="color:#ccc;font-size:16px;line-height:1.6;">Hi <strong style="color:#fff;">' + firstName + '</strong>,</p>' +
'<p style="color:#aaa;font-size:15px;line-height:1.6;">Great news! We have a lesson opening that might work for you:</p>' +
'<table width="100%" style="background:rgba(255,255,255,0.03);border-radius:14px;margin:20px 0;border:1px solid rgba(255,255,255,0.06);">' +
'<tr><td style="padding:20px;">' +
'<p style="color:#ff2d2d;font-size:12px;text-transform:uppercase;letter-spacing:1px;margin:0 0 8px;">Available Slot</p>' +
'<p style="color:#fff;font-size:18px;font-weight:700;margin:0;">' + escHtml_(sl.dayName) + ', ' + escHtml_(sl.dateStr) + '</p>' +
'<p style="color:#888;font-size:14px;margin:6px 0 0;">๐ ' + escHtml_(sl.time) + ' โข ๐จโ๐ซ ' + escHtml_(sl.instructor) + '</p>' +
'</td></tr></table>' +
'<p style="color:#aaa;font-size:14px;line-height:1.6;">You have <strong style="color:#ff2d2d;">' + s.lessonsRemaining + ' lessons remaining</strong>. Reply or call to book!</p>' +
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0;"><tr><td align="center">' +
'<a href="mailto:' + escHtml_(CONFIG.SCHOOL_EMAIL) + '?subject=Book%20Lesson%20' + encodeURIComponent(sl.dateStr + ' ' + sl.time) + '" ' +
'style="display:inline-block;background:linear-gradient(135deg,#ff2d2d,#cc0000);color:#fff;padding:14px 32px;border-radius:12px;text-decoration:none;font-weight:700;font-size:15px;box-shadow:0 4px 16px rgba(255,45,45,0.3);">' +
'๐
Book This Slot</a></td></tr></table>';
const html = missionControlWrapper_('Lesson Opening Available', content, unsub);
try {
MailApp.sendEmail({
to: s.email,
subject: '๐
Lesson Opening: ' + sl.dayName + ' ' + sl.dateStr + ' at ' + sl.time + ' โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html
});
} catch (e) { Logger.log('Error sending opening email to ' + s.email + ': ' + e.message); }
}
function sendBackfillEmail_(name, email, instructor, date, time, settings) {
const dateStr = formatDate_(date instanceof Date ? date : new Date(date));
const dayName = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][
(date instanceof Date ? date : new Date(date)).getDay()
];
const unsub = (settings ? settings.unsubscribeEmail : '') || CONFIG.SCHOOL_EMAIL;
const firstName = escHtml_(name.split(' ')[0]);
const content =
'<p style="color:#ccc;font-size:16px;line-height:1.6;">Hi <strong style="color:#fff;">' + firstName + '</strong>,</p>' +
'<p style="color:#aaa;font-size:15px;line-height:1.6;">A lesson slot just opened up that matches your preferences!</p>' +
'<table width="100%" style="background:rgba(255,255,255,0.03);border-radius:14px;margin:20px 0;border:1px solid rgba(255,255,255,0.06);">' +
'<tr><td style="padding:20px;">' +
'<p style="color:#ff2d2d;font-size:12px;text-transform:uppercase;letter-spacing:1px;margin:0 0 8px;">Just Opened</p>' +
'<p style="color:#fff;font-size:18px;font-weight:700;margin:0;">' + escHtml_(dayName) + ', ' + escHtml_(dateStr) + '</p>' +
'<p style="color:#888;font-size:14px;margin:6px 0 0;">๐ ' + escHtml_(time) + ' โข ๐จโ๐ซ ' + escHtml_(instructor) + '</p>' +
'</td></tr></table>' +
'<p style="color:#aaa;font-size:14px;line-height:1.6;">This just cancelled โ you\'re first on the waitlist. Reply quickly to grab it!</p>' +
'<table width="100%" cellpadding="0" cellspacing="0" style="margin:20px 0;"><tr><td align="center">' +
'<a href="mailto:' + escHtml_(CONFIG.SCHOOL_EMAIL) + '?subject=Book%20Cancelled%20Slot%20' + encodeURIComponent(dateStr) + '" ' +
'style="display:inline-block;background:linear-gradient(135deg,#ff2d2d,#cc0000);color:#fff;padding:14px 32px;border-radius:12px;text-decoration:none;font-weight:700;font-size:15px;box-shadow:0 4px 16px rgba(255,45,45,0.3);">' +
'โก Grab This Slot</a></td></tr></table>';
const html = missionControlWrapper_('Slot Just Opened Up!', content, unsub);
try {
MailApp.sendEmail({
to: email,
subject: 'โก Slot Just Opened: ' + dayName + ' ' + dateStr + ' at ' + time + ' โ ' + CONFIG.SCHOOL_NAME,
htmlBody: html
});
} catch (e) { Logger.log('Error sending backfill email to ' + email + ': ' + e.message); }
}
function buildWeeklyReportEmail_(data) {
let instructorRows = '';
data.instructors.forEach(function(inst) {
const barWidth = Math.max(5, parseInt(inst.util));
const barColor = parseFloat(inst.util) > 70 ? '#22c55e' : parseFloat(inst.util) > 40 ? '#f59e0b' : '#ff2d2d';
instructorRows +=
'<tr><td style="color:#fff;padding:10px 0;font-size:14px;border-bottom:1px solid rgba(255,255,255,0.04);">' + escHtml_(inst.name) + '</td>' +
'<td style="padding:10px 8px;border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<div style="background:rgba(255,255,255,0.06);border-radius:4px;overflow:hidden;height:18px;width:100%;">' +
'<div style="background:' + barColor + ';height:100%;width:' + barWidth + '%;border-radius:4px;"></div></div></td>' +
'<td style="color:#888;padding:10px 0 10px 12px;font-size:13px;border-bottom:1px solid rgba(255,255,255,0.04);white-space:nowrap;">' + inst.util + '% (' + inst.booked + ')</td></tr>';
});
let gapsList = '';
Object.entries(data.gapsByDay).sort(function(a,b){ return b[1]-a[1]; }).forEach(function(entry) {
gapsList += '<li style="color:#888;padding:3px 0;">' + escHtml_(entry[0]) + ': <strong style="color:#ff2d2d;">' + entry[1] + ' open</strong></li>';
});
let unscheduledList = '';
data.topUnscheduled.forEach(function(s) {
unscheduledList += '<li style="color:#888;padding:3px 0;">' + escHtml_(s.name) + ' โ ' + s.lessonsRemaining + ' lessons remaining</li>';
});
// Health score color
const hsColor = data.healthScore >= 75 ? '#22c55e' : data.healthScore >= 50 ? '#f59e0b' : '#ff2d2d';
const content =
(data.isDemo ? '<div style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:10px 16px;margin-bottom:20px;text-align:center;"><span style="color:#ff6b6b;font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;">โฆ Demo Mode</span></div>' : '') +
// Health Score
'<div style="text-align:center;margin-bottom:24px;">' +
'<p style="color:#555;font-size:11px;text-transform:uppercase;letter-spacing:1px;margin:0 0 4px;">Schedule Health Score</p>' +
'<p style="color:' + hsColor + ';font-size:48px;font-weight:800;margin:0;letter-spacing:-2px;">' + data.healthScore + '</p>' +
'<p style="color:#555;font-size:12px;margin:0;">out of 100</p></div>' +
// Stats row
'<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;"><tr>' +
'<td width="33%" style="padding:14px;background:rgba(255,255,255,0.03);border-radius:12px 0 0 12px;text-align:center;border:1px solid rgba(255,255,255,0.04);">' +
'<p style="color:#ff2d2d;font-size:26px;font-weight:800;margin:0;">' + data.overallUtil + '%</p>' +
'<p style="color:#555;font-size:10px;margin:4px 0 0;text-transform:uppercase;letter-spacing:1px;">Utilization</p></td>' +
'<td width="33%" style="padding:14px;background:rgba(255,255,255,0.03);text-align:center;border-top:1px solid rgba(255,255,255,0.04);border-bottom:1px solid rgba(255,255,255,0.04);">' +
'<p style="color:#22c55e;font-size:26px;font-weight:800;margin:0;">' + data.filledSlots + '</p>' +
'<p style="color:#555;font-size:10px;margin:4px 0 0;text-transform:uppercase;letter-spacing:1px;">Booked</p></td>' +
'<td width="33%" style="padding:14px;background:rgba(255,255,255,0.03);border-radius:0 12px 12px 0;text-align:center;border:1px solid rgba(255,255,255,0.04);">' +
'<p style="color:#f59e0b;font-size:26px;font-weight:800;margin:0;">' + data.openSlotCount + '</p>' +
'<p style="color:#555;font-size:10px;margin:4px 0 0;text-transform:uppercase;letter-spacing:1px;">Open Slots</p></td>' +
'</tr></table>' +
(data.imbalanced ? '<div style="background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.2);border-radius:10px;padding:12px 16px;margin-bottom:20px;"><p style="color:#ff6b6b;margin:0;font-size:13px;">โ ๏ธ <strong>Load Imbalance</strong> โ Consider redistributing bookings across instructors.</p></div>' : '') +
'<p style="color:#fff;font-size:15px;font-weight:600;margin:24px 0 12px;">๐จโ๐ซ Instructor Utilization</p>' +
'<table width="100%" cellpadding="0" cellspacing="0">' + instructorRows + '</table>' +
'<p style="color:#fff;font-size:15px;font-weight:600;margin:24px 0 12px;">๐
Open Slots by Day</p>' +
'<ul style="padding-left:20px;margin:0;">' + gapsList + '</ul>' +
(data.unscheduledCount > 0 ?
'<p style="color:#fff;font-size:15px;font-weight:600;margin:24px 0 12px;">๐ฏ Unscheduled Students (' + data.unscheduledCount + ')</p>' +
'<ul style="padding-left:20px;margin:0;">' + unscheduledList + '</ul>' +
(data.unscheduledCount > 5 ? '<p style="color:#555;font-size:12px;margin-top:6px;">...and ' + (data.unscheduledCount - 5) + ' more</p>' : '')
: '<p style="color:#22c55e;font-size:14px;margin:24px 0;">โ
All students are scheduled!</p>');
return missionControlWrapper_('Weekly Schedule Report', content, null);
}
// ================================================================
// FUZZY NAME MATCHING
// ================================================================
function fuzzyNameMatch_(name1, name2) {
if (!name1 || !name2) return false;
const n1 = name1.toLowerCase().replace(/\s+/g, ' ').trim();
const n2 = name2.toLowerCase().replace(/\s+/g, ' ').trim();
if (n1 === n2) return true;
if (n1.includes(n2) || n2.includes(n1)) return true;
const p1 = n1.split(' ').filter(Boolean);
const p2 = n2.split(' ').filter(Boolean);
if (p1.length >= 2 && p2.length >= 2) {
// Same last name + first 3 chars of first name match
if (p1[p1.length-1] === p2[p2.length-1] && p1[0].substring(0,3) === p2[0].substring(0,3)) return true;
// Swapped order detection
if (p1[0] === p2[p2.length-1] && p1[p1.length-1] === p2[0]) return true;
}
if (levenshtein_(n1, n2) <= 2) return true;
return false;
}
function isFuzzyTracked_(nameKey, trackedNames) {
for (const tracked of trackedNames) {
if (fuzzyNameMatch_(nameKey, tracked)) return true;
}
return false;
}
function levenshtein_(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const m = [];
for (let i = 0; i <= b.length; i++) m[i] = [i];
for (let j = 0; j <= a.length; j++) m[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
m[i][j] = b.charAt(i-1) === a.charAt(j-1) ? m[i-1][j-1] : Math.min(m[i-1][j-1]+1, m[i][j-1]+1, m[i-1][j]+1);
}
}
return m[b.length][a.length];
}
// ================================================================
// UTILITY FUNCTIONS
// ================================================================
/**
* Find a column index by matching header candidates.
* Returns -1 if no match found (callers should handle gracefully).
*/
function findCol_(headers, candidates) {
for (const c of candidates) {
const idx = headers.findIndex(h => h.includes(c.toLowerCase()));
if (idx !== -1) return idx;
}
return -1;
}
/** HTML-escape user input for safe email insertion */
function escHtml_(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
}
/** Normalize time strings for consistent slot key matching */
function normalizeTime_(timeStr) {
if (!timeStr) return '';
const s = String(timeStr).trim().toUpperCase();
// Try to parse "2:00 PM", "14:00", "2 PM" etc. into our standard format
const match12 = s.match(/^(\d{1,2}):?(\d{2})?\s*(AM|PM)$/i);
if (match12) {
let h = parseInt(match12[1]);
const ampm = match12[3].toUpperCase();
if (ampm === 'PM' && h < 12) h += 12;
if (ampm === 'AM' && h === 12) h = 0;
return formatHour_(h);
}
const match24 = s.match(/^(\d{1,2}):(\d{2})$/);
if (match24) {
return formatHour_(parseInt(match24[1]));
}
return s;
}
function sanitize_(input) {
if (!input) return '';
return String(input).trim().replace(/[<>{}()\[\]\\\/]/g, '').replace(/\s+/g, ' ').substring(0, 200);
}
function formatDate_(date) {
if (!(date instanceof Date)) date = new Date(date);
return (date.getMonth() + 1) + '/' + date.getDate() + '/' + date.getFullYear();
}
function formatHour_(hour) {
const suffix = hour >= 12 ? 'PM' : 'AM';
const h = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
return h + ':00 ' + suffix;
}
function formatSlot_(slot) {
return slot.dateStr + ' ' + slot.time;
}
function extractLessonCount_(pkg) {
if (!pkg) return 0;
const match = pkg.toString().match(/(\d+)\s*lesson/i);
if (match) return parseInt(match[1]);
// Try standalone number
const match2 = pkg.toString().match(/^(\d+)$/);
if (match2) return parseInt(match2[1]);
// Common package names
const lower = pkg.toString().toLowerCase();
if (lower.includes('beginner')) return 10;
if (lower.includes('standard')) return 10;
if (lower.includes('premium')) return 15;
if (lower.includes('intensive')) return 20;
return 0;
}
function parseTimePreference_(timePref) {
if (!timePref) return null;
const t = timePref.toLowerCase();
if (t.includes('morning') || t.includes('early')) return 9;
if (t.includes('midday') || t.includes('noon') || t.includes('lunch')) return 12;
if (t.includes('afternoon')) return 14;
if (t.includes('evening') || t.includes('late')) return 16;
const match = t.match(/(\d{1,2})/);
if (match) {
let h = parseInt(match[1]);
if (t.includes('pm') && h < 12) h += 12;
return h;
}
return null;
}
function getSettings_() {
const d = {
demoMode: CONFIG.DEMO_MODE,
autoSendOpenings: true,
autoBackfill: true,
maxEmails: CONFIG.MAX_EMAILS_PER_RUN,
unsubscribeEmail: CONFIG.SCHOOL_EMAIL,
smsEnabled: false,
emailCooldownHours: CONFIG.EMAIL_COOLDOWN_HOURS,
};
try {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = ss.getSheetByName('Optimizer Settings');
if (!sheet) return d;
const data = sheet.getDataRange().getValues();
for (let i = 1; i < data.length; i++) {
const key = String(data[i][0]).toLowerCase().trim();
const val = String(data[i][1]).trim();
if (key.includes('demo mode')) d.demoMode = val.toLowerCase() === 'yes';
else if (key.includes('max emails')) d.maxEmails = parseInt(val) || 20;
else if (key.includes('auto-send') || key.includes('opening email')) d.autoSendOpenings = val.toLowerCase() === 'yes';
else if (key.includes('auto-backfill') || key.includes('backfill cancel')) d.autoBackfill = val.toLowerCase() === 'yes';
else if (key.includes('unsubscribe')) d.unsubscribeEmail = val;
else if (key.includes('sms')) d.smsEnabled = val.toLowerCase() === 'yes';
else if (key.includes('cooldown')) d.emailCooldownHours = parseInt(val) || 48;
}
} catch (e) { Logger.log('Settings error: ' + e.message); }
return d;
}
function logAction_(action, student, instructor, slot, details) {
try {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const logSheet = ss.getSheetByName('Optimization Log');
if (logSheet) logSheet.appendRow([new Date(), action, student, instructor, slot, details]);
} catch (e) { Logger.log('Log error: ' + e.message); }
}
// ================================================================
// MANUAL FUNCTIONS
// ================================================================
function addToWaitlist(name, email, phone, preferredInstructor, preferredDay, preferredTime) {
name = sanitize_(name);
email = sanitize_(email);
phone = sanitize_(phone);
preferredInstructor = sanitize_(preferredInstructor);
preferredDay = sanitize_(preferredDay);
preferredTime = sanitize_(preferredTime);
if (!name || !email) { Logger.log('Name and email required.'); return; }
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
const sheet = ss.getSheetByName('Waitlist');
if (!sheet) { Logger.log('Waitlist sheet not found'); return; }
sheet.appendRow([name, email, phone, preferredInstructor, preferredDay, preferredTime, new Date(), 'Waiting']);
Logger.log('โ
Added ' + name + ' to waitlist');
}
function runOptimizationNow() {
dailyOptimize();
scanCancellations();
Logger.log('โ
Manual optimization complete');
}
function previewMatches() {
const settings = getSettings_();
const isDemo = settings.demoMode;
const today = new Date();
const openSlots = isDemo ? DEMO.openSlots : getOpenSlots_(today, 7);
const unscheduled = isDemo ? DEMO.students : getUnscheduledStudents_();
const preferences = isDemo ? DEMO.preferences : getStudentPreferences_();
const loadData = isDemo ? DEMO.loadData : getInstructorLoad_(today, 7);
Logger.log('Open slots: ' + openSlots.length);
Logger.log('Unscheduled students: ' + unscheduled.length);
const matches = scoreMatches_(openSlots, unscheduled, preferences, loadData);
matches.slice(0, 10).forEach(function(m, i) {
Logger.log((i+1) + '. ' + m.student.name + ' โ ' + m.slot.instructor + ' on ' + m.slot.dateStr + ' at ' + m.slot.time + ' (score: ' + m.score + ')');
});
Logger.log('\nInstructor Load:');
Object.entries(loadData).forEach(function(entry) {
Logger.log(' ' + entry[0] + ': ' + (entry[1].utilization * 100).toFixed(1) + '% (' + entry[1].booked + ' booked)');
});
const totalSlots = CONFIG.INSTRUCTORS.length * 7 * (CONFIG.WORK_END - CONFIG.WORK_START);
const healthScore = calculateHealthScore_(totalSlots - openSlots.length, totalSlots, loadData, unscheduled.length);
Logger.log('\n๐ฅ Schedule Health Score: ' + healthScore + '/100');
}
/**
* =========================================================
* STUDENT CERTIFICATE GENERATOR โ Cherry on Top #3
* Flavors Driving School
* =========================================================
* Auto-generates premium branded PDF certificates when
* students complete their lesson package.
*
* - Detects completion from Schedule Board attendance
* - Generates a stunning certificate PDF (light theme โ Google PDF compatible)
* - Emails it to the student with a congrats message
* - Logs every certificate issued
* - Daily check at 6 PM (after lessons wrap up)
* - Certificate verification web page (doGet)
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
CERT_FOLDER_NAME: 'Flavors โ Student Certificates',
// โโ Sheet tabs โโ
REGISTRATION_SHEET_TAB: '',
BOOKINGS_SHEET_TAB: 'Bookings',
// โโ Demo mode (true = no real emails/PDFs, fake data for presentations) โโ
DEMO_MODE: true,
// โโ Statuses that count as confirmed attendance โโ
CONFIRMED_STATUSES: ['completed', 'on time', 'late', 'present', 'attended'],
SKIP_STATUSES: ['cancelled', 'canceled', 'no-show', 'no show', 'pending', 'scheduled', 'upcoming', 'rescheduled'],
// โโ Named packages โ lesson counts โโ
NAMED_PACKAGES: {
'beginner': 10,
'standard': 10,
'premium': 15,
'intensive': 20
}
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO_DATA = {
completedStudents: [
{ name: 'Sarah Johnson', email: '[email protected]', pkg: 'Premium Package', lessons: 15, instructor: 'Anisha' },
{ name: 'Marcus Williams', email: '[email protected]', pkg: '10 Lessons', lessons: 10, instructor: 'Carlos' }
],
recentCerts: [
{ certNum: 'FDS-260219-A7K2', name: 'Sarah Johnson', email: '[email protected]', pkg: 'Premium Package', lessons: 15, instructor: 'Anisha', date: 'February 19, 2026', url: '#', sent: 'Yes' },
{ certNum: 'FDS-260218-B3M9', name: 'Marcus Williams', email: '[email protected]', pkg: '10 Lessons', lessons: 10, instructor: 'Carlos', date: 'February 18, 2026', url: '#', sent: 'Yes' },
{ certNum: 'FDS-260215-C5P4', name: 'Emily Chen', email: '[email protected]', pkg: 'Beginner Package', lessons: 10, instructor: 'Nick', date: 'February 15, 2026', url: '#', sent: 'Yes' }
]
};
/* ================================================================
SETUP & AUTH
================================================================ */
/** Run once to trigger OAuth for all connected services. */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
DriveApp.getRootFolder();
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
// โโ Create "Certificates Issued" sheet โโ
let certSheet = ss.getSheetByName('Certificates Issued');
if (!certSheet) {
certSheet = ss.insertSheet('Certificates Issued');
certSheet.appendRow([
'Certificate #', 'Student Name', 'Email', 'Phone', 'Package', 'Lessons Completed',
'Primary Instructor', 'Issue Date', 'PDF Link', 'Email Sent'
]);
certSheet.getRange('1:1').setFontWeight('bold');
certSheet.setFrozenRows(1);
Logger.log('โ
Created "Certificates Issued" sheet.');
}
// โโ Create Drive folder โโ
const folders = DriveApp.getFoldersByName(CONFIG.CERT_FOLDER_NAME);
if (!folders.hasNext()) {
DriveApp.createFolder(CONFIG.CERT_FOLDER_NAME);
Logger.log('โ
Created certificate folder in Drive.');
}
// โโ Daily trigger (delete old first to avoid duplicates) โโ
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'dailyCertificateCheck') {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('dailyCertificateCheck')
.timeBased()
.everyDays(1)
.atHour(18)
.create();
Logger.log('โ
Daily trigger set for 6 PM.');
Logger.log('โ
Certificate Generator setup complete.');
}
/* ================================================================
WEB APP โ CERTIFICATE VERIFICATION PAGE
================================================================ */
function doGet(e) {
const certId = String(e && e.parameter && e.parameter.id ? e.parameter.id : '').trim();
const html = getVerificationHTML_(certId);
return HtmlService.createHtmlOutput(html)
.setTitle('Verify Certificate โ ' + CONFIG.SCHOOL_NAME)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function verifyCertificate(certId) {
const id = String(certId || '').trim().toUpperCase();
if (!id) return { found: false, error: 'Please enter a certificate number.' };
if (CONFIG.DEMO_MODE) {
const demo = DEMO_DATA.recentCerts.find(c => c.certNum.toUpperCase() === id);
if (demo) {
return {
found: true,
name: demo.name,
pkg: demo.pkg,
lessons: demo.lessons,
instructor: demo.instructor,
date: demo.date,
certNum: demo.certNum
};
}
return { found: false };
}
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const certSheet = ss.getSheetByName('Certificates Issued');
if (!certSheet || certSheet.getLastRow() <= 1) return { found: false };
const data = certSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const cNumCol = findCol_(headers, ['certificate #', 'cert #', 'certificate']);
const cNameCol = findCol_(headers, ['student name', 'name']);
const cPkgCol = findCol_(headers, ['package']);
const cLessCol = findCol_(headers, ['lessons completed', 'lessons']);
const cInstCol = findCol_(headers, ['primary instructor', 'instructor']);
const cDateCol = findCol_(headers, ['issue date', 'date']);
for (let r = 1; r < data.length; r++) {
const row = data[r];
if (String(row[cNumCol] || '').trim().toUpperCase() === id) {
return {
found: true,
name: String(row[cNameCol] || ''),
pkg: String(row[cPkgCol] || ''),
lessons: row[cLessCol] || 0,
instructor: String(row[cInstCol] || ''),
date: cDateCol >= 0 && row[cDateCol] ? Utilities.formatDate(new Date(row[cDateCol]), 'America/New_York', 'MMMM d, yyyy') : '',
certNum: String(row[cNumCol] || '')
};
}
}
return { found: false };
} catch (err) {
Logger.log('verifyCertificate error: ' + err.message);
return { found: false, error: 'Lookup failed. Please try again.' };
}
}
/* ================================================================
DAILY CHECK
================================================================ */
function dailyCertificateCheck() {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ skipping real certificate check.');
Logger.log('Demo would generate certificates for: ' + DEMO_DATA.completedStudents.map(s => s.name).join(', '));
return;
}
runDailyCertificateCheck_();
} catch (e) {
Logger.log('dailyCertificateCheck error: ' + (e.message || e.toString()));
try {
if (CONFIG.ADMIN_EMAIL) {
MailApp.sendEmail(CONFIG.ADMIN_EMAIL,
'Certificate Generator โ Daily check failed',
'Error: ' + String(e.message || e.toString()).substring(0, 500)
);
}
} catch (mailErr) {
Logger.log('Could not send error email: ' + mailErr.message);
}
throw e;
}
}
function runDailyCertificateCheck_() {
const regSheet = getRegistrationSheet_();
if (!regSheet || regSheet.getLastRow() <= 1) return;
const regData = regSheet.getDataRange().getValues();
const regHeaders = regData[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol_(regHeaders, ['student name', 'full name', 'name']);
const emailCol = findCol_(regHeaders, ['email', 'student email', 'email address']);
const pkgCol = findCol_(regHeaders, ['package', 'lesson package', 'lessons']);
const phoneCol = findCol_(regHeaders, ['phone', 'phone number', 'mobile', 'cell']);
if (nameCol < 0 || emailCol < 0) return;
const schedSheet = getScheduleSheet_();
if (!schedSheet || schedSheet.getLastRow() <= 1) return;
const schedData = schedSheet.getDataRange().getValues();
const schedHeaders = schedData[0].map(h => String(h).toLowerCase().trim());
const sNameCol = findCol_(schedHeaders, ['student', 'student name', 'name']);
const sEmailCol = findCol_(schedHeaders, ['email', 'student email']);
const sInstCol = findCol_(schedHeaders, ['instructor', 'instructor name']);
const sStatusCol = findCol_(schedHeaders, ['status', 'attendance', 'attended']);
const sDateCol = findCol_(schedHeaders, ['date', 'lesson date', 'scheduled date']);
if (sNameCol < 0) return;
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let certSheet = regSS.getSheetByName('Certificates Issued');
if (!certSheet) {
fullSetup();
certSheet = regSS.getSheetByName('Certificates Issued');
}
if (!certSheet) return;
// โโ Already-issued set (by email + name) โโ
const certData = certSheet.getDataRange().getValues();
const issuedSet = new Set();
for (let r = 1; r < certData.length; r++) {
issuedSet.add(String(certData[r][1] || '').toLowerCase().trim());
if (certData[r][2]) issuedSet.add(String(certData[r][2]).toLowerCase().trim());
}
// โโ Count confirmed lessons per student (email-first + name fallback) โโ
const now = new Date();
const lessonsByEmail = {};
const lessonsByName = {};
const instructorByEmail = {};
const instructorByName = {};
for (let i = 1; i < schedData.length; i++) {
const row = schedData[i];
const status = String(row[sStatusCol] != null ? row[sStatusCol] : '').toLowerCase().trim();
if (!isConfirmedStatus_(status)) continue;
// Only count past lessons
if (sDateCol >= 0 && row[sDateCol]) {
const lessonDate = new Date(row[sDateCol]);
if (!isNaN(lessonDate.getTime()) && lessonDate > now) continue;
}
const student = String(row[sNameCol] || '').trim();
const sEmail = sEmailCol >= 0 ? String(row[sEmailCol] || '').trim().toLowerCase() : '';
const instructor = String(row[sInstCol] != null ? row[sInstCol] : '').trim() || 'Flavors Team';
if (sEmail) {
lessonsByEmail[sEmail] = (lessonsByEmail[sEmail] || 0) + 1;
if (!instructorByEmail[sEmail]) instructorByEmail[sEmail] = {};
instructorByEmail[sEmail][instructor] = (instructorByEmail[sEmail][instructor] || 0) + 1;
}
if (student) {
const nk = student.toLowerCase();
lessonsByName[nk] = (lessonsByName[nk] || 0) + 1;
if (!instructorByName[nk]) instructorByName[nk] = {};
instructorByName[nk][instructor] = (instructorByName[nk][instructor] || 0) + 1;
}
}
// โโ Check each registered student โโ
let generated = 0;
for (let i = 1; i < regData.length; i++) {
const row = regData[i];
const name = String(row[nameCol] != null ? row[nameCol] : '').trim();
const email = String(row[emailCol] != null ? row[emailCol] : '').trim();
const pkg = String(row[pkgCol] != null ? row[pkgCol] : '');
const phone = phoneCol >= 0 ? String(row[phoneCol] || '') : '';
if (!name || !email) continue;
// Already issued? Check both email and name
if (issuedSet.has(email.toLowerCase()) || issuedSet.has(name.toLowerCase())) continue;
const required = extractLessonCount_(pkg);
if (required == null) continue;
// Email-first matching, then fuzzy name fallback
const emailKey = email.toLowerCase();
let completed = lessonsByEmail[emailKey] || 0;
let instMap = instructorByEmail[emailKey] || {};
if (completed === 0) {
// Fuzzy name match
const match = fuzzyFindStudent_(name, Object.keys(lessonsByName));
if (match) {
completed = lessonsByName[match] || 0;
instMap = instructorByName[match] || {};
}
}
if (completed >= required) {
const instructor = getPrimaryInstructor_(instMap);
const certNum = generateCertNumber_(certSheet);
const pdf = generateCertificatePDF_(name, pkg, completed, instructor, certNum);
const sent = sendCertificateEmail_(name, email, pkg, instructor, certNum, pdf);
certSheet.appendRow([
certNum, name, email, phone, pkg, completed,
instructor, new Date(), pdf.getUrl(), sent ? 'Yes' : 'Failed'
]);
generated++;
issuedSet.add(emailKey);
issuedSet.add(name.toLowerCase());
Logger.log('โ
Certificate issued: ' + name + ' (#' + certNum + ')');
}
}
Logger.log(generated === 0
? 'No new completions found today.'
: generated + ' certificate(s) generated and sent.');
}
/* ================================================================
RE-SEND CERTIFICATE
================================================================ */
/** Re-send a certificate by cert number. Looks up the student and re-emails the PDF. */
function resendCertificate(certNumber) {
const id = String(certNumber || '').trim().toUpperCase();
if (!id) { Logger.log('No cert number provided.'); return; }
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const certSheet = ss.getSheetByName('Certificates Issued');
if (!certSheet || certSheet.getLastRow() <= 1) { Logger.log('No certificates found.'); return; }
const data = certSheet.getDataRange().getValues();
const headers = data[0].map(h => String(h).toLowerCase().trim());
const cNumCol = findCol_(headers, ['certificate #', 'cert #']);
const cNameCol = findCol_(headers, ['student name', 'name']);
const cEmailCol = findCol_(headers, ['email']);
const cPkgCol = findCol_(headers, ['package']);
const cInstCol = findCol_(headers, ['primary instructor', 'instructor']);
const cUrlCol = findCol_(headers, ['pdf link', 'pdf url', 'link']);
for (let r = 1; r < data.length; r++) {
if (String(data[r][cNumCol] || '').trim().toUpperCase() !== id) continue;
const name = String(data[r][cNameCol] || '');
const email = String(data[r][cEmailCol] || '');
const pkg = String(data[r][cPkgCol] || '');
const inst = String(data[r][cInstCol] || 'Flavors Team');
const pdfUrl = String(data[r][cUrlCol] || '');
if (!email) { Logger.log('No email on file for ' + name); return; }
// Try to get existing PDF from Drive
let pdfFile = null;
if (pdfUrl) {
try {
const match = pdfUrl.match(/[-\w]{25,}/);
if (match) pdfFile = DriveApp.getFileById(match[0]);
} catch (_) { /* regenerate */ }
}
// Regenerate if we can't find the original
if (!pdfFile) {
Logger.log('Original PDF not found, regenerating...');
pdfFile = generateCertificatePDF_(name, pkg, 0, inst, id);
}
const sent = sendCertificateEmail_(name, email, pkg, inst, id, pdfFile);
Logger.log('Re-send to ' + name + ' (' + email + '): ' + (sent ? 'Success' : 'Failed'));
return;
}
Logger.log('Certificate ' + id + ' not found.');
}
/* ================================================================
SHEET HELPERS
================================================================ */
function getRegistrationSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
if (CONFIG.REGISTRATION_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
function getScheduleSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID);
if (CONFIG.BOOKINGS_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.BOOKINGS_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
/* ================================================================
STUDENT MATCHING (email-first + fuzzy name)
================================================================ */
function isConfirmedStatus_(status) {
const s = String(status || '').toLowerCase().trim();
if (CONFIG.SKIP_STATUSES.some(skip => s === skip)) return false;
return CONFIG.CONFIRMED_STATUSES.some(ok => s === ok);
}
function fuzzyFindStudent_(targetName, nameKeys) {
const tName = String(targetName || '').toLowerCase().trim();
if (!tName) return null;
// Exact match first
if (nameKeys.includes(tName)) return tName;
// Fuzzy: Levenshtein โค 2, first 3 chars of first name + same last name, swapped order
const tParts = tName.split(/\s+/);
const tFirst = tParts[0] || '';
const tLast = tParts[tParts.length - 1] || '';
for (const key of nameKeys) {
const kParts = key.split(/\s+/);
const kFirst = kParts[0] || '';
const kLast = kParts[kParts.length - 1] || '';
// Levenshtein distance โค 2
if (levenshtein_(tName, key) <= 2) return key;
// First 3 chars of first name + same last name
if (tFirst.length >= 3 && kFirst.length >= 3 &&
tFirst.substring(0, 3) === kFirst.substring(0, 3) &&
tLast === kLast) return key;
// Swapped first/last
if (tFirst === kLast && tLast === kFirst) return key;
}
return null;
}
function levenshtein_(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0];
dp[0] = j;
for (let i = 1; i <= m; i++) {
const temp = dp[i];
dp[i] = a[i - 1] === b[j - 1]
? prev
: 1 + Math.min(prev, dp[i], dp[i - 1]);
prev = temp;
}
}
return dp[m];
}
function getPrimaryInstructor_(instMap) {
if (!instMap) return 'Flavors Team';
const entries = Object.entries(instMap).filter(e => e[0] && e[0].trim());
if (entries.length === 0) return 'Flavors Team';
entries.sort((a, b) => b[1] - a[1]);
return entries[0][0] || 'Flavors Team';
}
/* ================================================================
LESSON COUNT PARSING
================================================================ */
function extractLessonCount_(pkg) {
const s = String(pkg || '').toLowerCase().trim();
if (!s) return null;
// 5-Hour Class = 1 completion (not driving lessons)
if (s.includes('5-hour') || s.includes('5 hour')) return 1;
// Named packages
for (const [name, count] of Object.entries(CONFIG.NAMED_PACKAGES)) {
if (s.includes(name)) return count;
}
// Numeric extraction
const numMatch = s.match(/(\d+)/);
if (numMatch) {
const n = parseInt(numMatch[1], 10);
if (n > 0 && n <= 100) return n;
}
return null;
}
/* ================================================================
CERTIFICATE NUMBER
================================================================ */
function generateCertNumber_(certSheet) {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const datePart = Utilities.formatDate(new Date(), 'America/New_York', 'yyMMdd');
const existing = new Set();
if (certSheet && certSheet.getLastRow() >= 1) {
const data = certSheet.getRange(1, 1, certSheet.getLastRow(), 1).getValues();
for (let r = 0; r < data.length; r++) existing.add(String(data[r][0]));
}
for (let tries = 0; tries < 50; tries++) {
let code = 'FDS-' + datePart + '-';
for (let i = 0; i < 4; i++) code += chars[Math.floor(Math.random() * chars.length)];
if (!existing.has(code)) return code;
}
// Fallback with timestamp
return 'FDS-' + datePart + '-' + Date.now().toString(36).toUpperCase().slice(-4);
}
/* ================================================================
PDF GENERATION (light theme โ Google PDF compatible)
================================================================ */
function generateCertificatePDF_(studentName, pkg, lessonsCompleted, instructor, certNum) {
const issueDate = Utilities.formatDate(new Date(), 'America/New_York', 'MMMM d, yyyy');
const schoolName = esc_(CONFIG.SCHOOL_NAME);
const tagline = esc_(CONFIG.SCHOOL_TAGLINE);
const name = esc_(studentName);
const pkgSafe = esc_(pkg);
const inst = esc_(instructor || 'Flavors Team');
const num = esc_(certNum);
const issue = esc_(issueDate);
const lessonsTxt = esc_(String(lessonsCompleted));
const html = '<!DOCTYPE html><html><head><meta charset="utf-8">'
+ '<style>@page{size:11in 8.5in;margin:0}*{margin:0;padding:0}'
+ 'html,body{margin:0;padding:0;width:11in;height:8.5in;overflow:hidden;font-family:Georgia,"Times New Roman",serif}</style>'
+ '</head><body>'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#faf8f5;height:8.5in;">'
+ '<tr><td align="center" valign="middle" style="padding:14px;">'
+ '<table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0" style="border:3px solid #8B0000;">'
+ '<tr><td style="padding:4px;">'
+ '<table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #C8A96E;">'
+ '<tr><td style="padding:3px;">'
+ '<table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #8B0000;">'
+ '<tr><td align="center" valign="middle" style="padding:16px 40px;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0">'
// โโ Top ornament โโ
+ '<tr><td align="center" style="padding-bottom:4px;"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:100px;height:2px;background-color:#C8A96E;"></td>'
+ '<td style="padding:0 12px;color:#8B0000;font-size:18px;">✶</td>'
+ '<td style="width:60px;height:2px;background-color:#8B0000;"></td>'
+ '<td style="padding:0 8px;color:#C8A96E;font-size:22px;">★</td>'
+ '<td style="width:60px;height:2px;background-color:#8B0000;"></td>'
+ '<td style="padding:0 12px;color:#8B0000;font-size:18px;">✶</td>'
+ '<td style="width:100px;height:2px;background-color:#C8A96E;"></td>'
+ '</tr></table></td></tr>'
// โโ Title โโ
+ '<tr><td align="center" style="padding:2px 0;"><span style="font-size:34px;font-weight:bold;color:#8B0000;letter-spacing:6px;">CERTIFICATE</span></td></tr>'
+ '<tr><td align="center" style="padding-bottom:2px;"><span style="font-size:14px;color:#666;letter-spacing:4px;font-style:italic;">of Completion</span></td></tr>'
// โโ Diamond divider โโ
+ '<tr><td align="center" style="padding:4px 0;"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:80px;height:1px;background-color:#C8A96E;"></td>'
+ '<td style="padding:0 10px;color:#C8A96E;font-size:10px;">♦ ♦ ♦</td>'
+ '<td style="width:80px;height:1px;background-color:#C8A96E;"></td>'
+ '</tr></table></td></tr>'
// โโ School name & tagline โโ
+ '<tr><td align="center" style="padding:4px 0 1px;"><span style="font-size:20px;color:#333;font-weight:bold;letter-spacing:2px;">' + schoolName + '</span></td></tr>'
+ '<tr><td align="center" style="padding-bottom:6px;"><span style="font-size:10px;color:#999;font-style:italic;letter-spacing:1px;">' + tagline + '</span></td></tr>'
// โโ Awarded to โโ
+ '<tr><td align="center" style="padding-bottom:2px;"><span style="font-family:Arial,Helvetica,sans-serif;font-size:10px;color:#999;letter-spacing:4px;text-transform:uppercase;">THIS CERTIFICATE IS PROUDLY AWARDED TO</span></td></tr>'
+ '<tr><td align="center" style="padding:4px 0 2px;"><span style="font-size:42px;color:#1a1a1a;font-weight:bold;font-style:italic;">' + name + '</span></td></tr>'
// โโ Name underline โโ
+ '<tr><td align="center" style="padding-bottom:6px;"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:40px;height:1px;background-color:#C8A96E;"></td>'
+ '<td style="padding:0 6px;"><table cellpadding="0" cellspacing="0" border="0"><tr><td style="width:6px;height:6px;background-color:#8B0000;"></td></tr></table></td>'
+ '<td style="width:240px;height:2px;background-color:#8B0000;"></td>'
+ '<td style="padding:0 6px;"><table cellpadding="0" cellspacing="0" border="0"><tr><td style="width:6px;height:6px;background-color:#8B0000;"></td></tr></table></td>'
+ '<td style="width:40px;height:1px;background-color:#C8A96E;"></td>'
+ '</tr></table></td></tr>'
// โโ Body text โโ
+ '<tr><td align="center" style="padding:0 70px 10px;"><span style="font-size:12px;color:#555;line-height:19px;">'
+ 'For successfully completing <span style="color:#8B0000;font-weight:bold;">' + lessonsTxt + ' driving lessons</span> in the '
+ '<span style="color:#8B0000;font-weight:bold;">' + pkgSafe + '</span> program. '
+ 'Your dedication to becoming a safe and confident driver is commendable. The open road awaits — drive with pride.</span></td></tr>'
// โโ Star divider โโ
+ '<tr><td align="center" style="padding-bottom:10px;"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:180px;height:1px;background-color:#e0d5c5;"></td>'
+ '<td style="padding:0 10px;color:#C8A96E;font-size:12px;">★</td>'
+ '<td style="width:180px;height:1px;background-color:#e0d5c5;"></td>'
+ '</tr></table></td></tr>'
// โโ Signature row โโ
+ '<tr><td align="center"><table width="92%" cellpadding="0" cellspacing="0" border="0"><tr>'
+ signatureCell_(inst, 'PRIMARY INSTRUCTOR')
+ signatureCell_(issue, 'DATE ISSUED')
+ signatureCell_(schoolName, 'AUTHORIZED BY')
+ '</tr></table></td></tr>'
// โโ Footer row โโ
+ '<tr><td align="center" style="padding-top:10px;"><table width="100%" cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td width="30%" align="left" style="font-family:Arial,sans-serif;font-size:7px;color:#ccc;letter-spacing:1px;">' + num + '</td>'
+ '<td width="40%" align="center"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:30px;height:1px;background-color:#C8A96E;"></td>'
+ '<td style="padding:0 8px;color:#8B0000;font-size:14px;">✶</td>'
+ '<td style="width:20px;height:1px;background-color:#8B0000;"></td>'
+ '<td style="padding:0 6px;color:#C8A96E;font-size:16px;">★</td>'
+ '<td style="width:20px;height:1px;background-color:#8B0000;"></td>'
+ '<td style="padding:0 8px;color:#8B0000;font-size:14px;">✶</td>'
+ '<td style="width:30px;height:1px;background-color:#C8A96E;"></td>'
+ '</tr></table></td>'
+ '<td width="30%" align="right" style="font-family:Arial,sans-serif;font-size:7px;color:#ccc;letter-spacing:1px;">EST. 2024</td>'
+ '</tr></table></td></tr>'
+ '</table>'
+ '</td></tr></table></td></tr></table></td></tr></table></td></tr></table>'
+ '</body></html>';
const blob = Utilities.newBlob(html, 'text/html', 'cert.html');
const tempFile = DriveApp.createFile(blob);
const pdfBlob = tempFile.getAs('application/pdf');
tempFile.setTrashed(true);
const safeName = (CONFIG.SCHOOL_NAME + ' โ Certificate โ ' + studentName + '.pdf').replace(/[<>:"/\\|?*]/g, '');
pdfBlob.setName(safeName);
let folder = null;
const folders = DriveApp.getFoldersByName(CONFIG.CERT_FOLDER_NAME);
if (folders.hasNext()) folder = folders.next();
else folder = DriveApp.createFolder(CONFIG.CERT_FOLDER_NAME);
const pdfFile = folder.createFile(pdfBlob);
pdfFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
return pdfFile;
}
function signatureCell_(value, label) {
return '<td width="33%" align="center" valign="top" style="padding:0 8px;">'
+ '<table cellpadding="0" cellspacing="0" border="0" width="100%">'
+ '<tr><td align="center" style="font-size:16px;color:#333;font-style:italic;padding-bottom:4px;">' + value + '</td></tr>'
+ '<tr><td align="center" style="padding-bottom:4px;"><table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:140px;height:2px;background-color:#8B0000;"></td></tr></table></td></tr>'
+ '<tr><td align="center" style="font-family:Arial,sans-serif;font-size:8px;color:#888;letter-spacing:2px;text-transform:uppercase;">' + label + '</td></tr>'
+ '</table></td>';
}
/* ================================================================
EMAIL (MailApp + unsubscribe + Mission Control theme)
================================================================ */
function sendCertificateEmail_(studentName, email, pkg, instructor, certNum, pdfFile) {
try {
const firstName = (studentName || '').split(' ')[0] || studentName;
const f = esc_(firstName);
const p = esc_(pkg);
const ins = esc_(instructor || 'Flavors Team');
const num = esc_(certNum);
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
const url = pdfFile.getUrl();
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(email) + '%20from%20certificate%20emails.';
const subject = 'Congratulations, ' + firstName + '! Your Certificate from ' + CONFIG.SCHOOL_NAME;
const htmlBody = '<!DOCTYPE html><html><head><meta charset="utf-8"></head><body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center"><table width="600" cellpadding="0" cellspacing="0" border="0">'
// Header
+ '<tr><td style="background:#0d0d0d;border-bottom:2px solid #ff2d2d;padding:30px 24px;text-align:center;">'
+ '<h1 style="color:#fff;font-size:20px;margin:0 0 4px;">' + sch + '</h1>'
+ '<p style="color:rgba(255,255,255,0.5);font-size:12px;margin:0;">' + tag + '</p>'
+ '</td></tr>'
// Body
+ '<tr><td style="background:#0d0d0d;padding:32px 24px;text-align:center;">'
+ '<div style="font-size:48px;margin-bottom:16px;">๐</div>'
+ '<div style="font-size:28px;font-weight:800;color:#fff;margin-bottom:8px;">Congratulations, ' + f + '!</div>'
+ '<p style="font-size:14px;color:rgba(255,255,255,0.6);line-height:1.7;max-width:440px;margin:0 auto 24px;">'
+ 'You did it! You\'ve successfully completed your <strong style="color:#ff2d2d;">' + p + '</strong> program with ' + sch + '. '
+ 'Your dedication and hard work have paid off โ you\'re officially road-ready.</p>'
// Stat boxes
+ '<table cellpadding="0" cellspacing="8" border="0" align="center"><tr>'
+ statBox_(p, 'Program') + statBox_(ins, 'Instructor')
+ '</tr></table>'
// CTA
+ '<div style="margin:24px 0;"><a href="' + url + '" style="display:inline-block;background:#ff2d2d;color:#fff;text-decoration:none;padding:14px 32px;border-radius:8px;font-size:14px;font-weight:700;">View Your Certificate</a></div>'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.3);">Your certificate is also attached to this email as a PDF. Certificate #' + num + '</p>'
+ '</td></tr>'
// Footer
+ '<tr><td style="background:#000;border-top:1px solid rgba(255,255,255,0.05);padding:20px 24px;text-align:center;">'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.25);margin:2px 0;">' + sch + '</p>'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.25);margin:2px 0;">Thank you for choosing us. Drive safe!</p>'
+ '<p style="font-size:10px;color:rgba(255,255,255,0.15);margin:8px 0 0;"><a href="' + unsub + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe</a></p>'
+ '</td></tr>'
+ '</table></td></tr></table></body></html>';
const plainBody = 'Congratulations, ' + firstName + '! You\'ve completed your ' + pkg + ' program at '
+ CONFIG.SCHOOL_NAME + '. Your certificate is attached. Certificate #' + certNum;
MailApp.sendEmail({
to: email,
subject: subject,
body: plainBody,
htmlBody: htmlBody,
attachments: [pdfFile.getAs('application/pdf')],
name: CONFIG.SCHOOL_NAME
});
return true;
} catch (e) {
Logger.log('Email failed for ' + studentName + ': ' + e.message);
return false;
}
}
function statBox_(value, label) {
return '<td style="background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2);border-radius:12px;padding:16px 20px;text-align:center;">'
+ '<div style="font-size:18px;font-weight:800;color:#ff2d2d;">' + value + '</div>'
+ '<div style="font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px;margin-top:4px;">' + label + '</div></td>';
}
/* ================================================================
VERIFICATION PAGE HTML
================================================================ */
function getVerificationHTML_(certId) {
const sch = esc_(CONFIG.SCHOOL_NAME);
const escaped = esc_(certId);
return '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
+ '<title>Verify Certificate โ ' + sch + '</title>'
+ '<style>'
+ '*{margin:0;padding:0;box-sizing:border-box}'
+ 'body{background:#000;color:#fff;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}'
+ '.card{background:#0d0d0d;border:1px solid rgba(255,45,45,0.15);border-radius:16px;padding:40px;max-width:500px;width:90%;text-align:center}'
+ 'h1{font-size:20px;margin-bottom:4px}'
+ '.tagline{color:rgba(255,255,255,0.4);font-size:12px;margin-bottom:24px}'
+ 'h2{font-size:16px;color:#ff2d2d;margin-bottom:16px}'
+ 'input[type=text]{width:100%;padding:12px 16px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);border-radius:8px;color:#fff;font-size:14px;text-align:center;letter-spacing:2px;margin-bottom:12px}'
+ 'input:focus{outline:none;border-color:#ff2d2d}'
+ 'button{background:#ff2d2d;color:#fff;border:none;padding:12px 32px;border-radius:8px;font-size:14px;font-weight:700;cursor:pointer;width:100%}'
+ 'button:hover{background:#e02525}'
+ '.result{margin-top:24px;padding:20px;border-radius:12px;text-align:left}'
+ '.found{background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.2)}'
+ '.not-found{background:rgba(255,45,45,0.08);border:1px solid rgba(255,45,45,0.2)}'
+ '.field{margin-bottom:8px}.field-label{font-size:10px;color:rgba(255,255,255,0.4);text-transform:uppercase;letter-spacing:1px}'
+ '.field-value{font-size:14px;color:#fff;margin-top:2px}'
+ '.badge{display:inline-block;background:rgba(34,197,94,0.15);color:#22c55e;padding:4px 12px;border-radius:20px;font-size:12px;font-weight:600;margin-bottom:16px}'
+ '.spinner{display:none;margin:16px auto}'
+ '</style></head><body>'
+ '<div class="card">'
+ '<h1>' + sch + '</h1>'
+ '<p class="tagline">Certificate Verification Portal</p>'
+ '<h2>๐ Verify a Certificate</h2>'
+ '<p style="color:rgba(255,255,255,0.5);font-size:13px;margin-bottom:16px;">Enter the certificate number printed on the document.</p>'
+ '<input type="text" id="certInput" placeholder="FDS-XXXXXX-XXXX" value="' + escaped + '">'
+ '<button onclick="verify()">Verify Certificate</button>'
+ '<div id="spinner" class="spinner" style="color:rgba(255,255,255,0.4);font-size:13px;">Checking...</div>'
+ '<div id="result"></div>'
+ '</div>'
+ '<script>'
+ 'function verify(){'
+ 'var id=document.getElementById("certInput").value.trim();'
+ 'if(!id){alert("Please enter a certificate number.");return;}'
+ 'document.getElementById("spinner").style.display="block";'
+ 'document.getElementById("result").innerHTML="";'
+ 'google.script.run.withSuccessHandler(showResult).withFailureHandler(showError).verifyCertificate(id);'
+ '}'
+ 'function showResult(r){'
+ 'document.getElementById("spinner").style.display="none";'
+ 'var el=document.getElementById("result");'
+ 'if(r&&r.found){'
+ 'el.className="result found";'
+ 'el.innerHTML=\'<div class="badge">โ
Verified</div>\''
+ '+field("Student Name",r.name)+field("Program",r.pkg)+field("Lessons Completed",r.lessons)'
+ '+field("Primary Instructor",r.instructor)+field("Date Issued",r.date)+field("Certificate #",r.certNum);'
+ '}else{'
+ 'el.className="result not-found";'
+ 'el.innerHTML="<p style=\\"color:#ff2d2d;font-size:14px;text-align:center;\\">โ Certificate not found. Please check the number and try again.</p>";'
+ '}}'
+ 'function showError(e){'
+ 'document.getElementById("spinner").style.display="none";'
+ 'document.getElementById("result").className="result not-found";'
+ 'document.getElementById("result").innerHTML="<p style=\\"color:#ff2d2d;\\">Error: "+(e.message||"Please try again.")+"</p>";'
+ '}'
+ 'function field(l,v){'
+ 'return\'<div class="field"><div class="field-label">\'+l+\'</div><div class="field-value">\'+(v||"โ")+\'</div></div>\';'
+ '}'
+ 'document.getElementById("certInput").addEventListener("keypress",function(e){if(e.key==="Enter")verify();});'
+ (certId ? 'window.onload=function(){verify();};' : '')
+ '</script></body></html>';
}
/* ================================================================
SHARED HELPERS
================================================================ */
/** Header contains any candidate (case-insensitive, .includes()). Returns 0-based index or -1. */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function esc_(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
/* ================================================================
MANUAL TOOLS
================================================================ */
/** Generate a demo certificate (no real data). */
function generateDemoCertificate() {
const demo = DEMO_DATA.completedStudents[0];
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let certSheet = regSS.getSheetByName('Certificates Issued');
if (!certSheet) { fullSetup(); certSheet = regSS.getSheetByName('Certificates Issued'); }
const certNum = generateCertNumber_(certSheet);
const pdf = generateCertificatePDF_(demo.name, demo.pkg, demo.lessons, demo.instructor, certNum);
Logger.log('๐ญ Demo certificate generated: ' + pdf.getUrl());
Logger.log('Certificate #: ' + certNum);
}
/** Manually generate a certificate for a specific student. Edit values below. */
function generateManualCertificate() {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ running demo certificate instead.');
generateDemoCertificate();
return;
}
const studentName = 'Marcus Johnson';
const email = '[email protected]';
const pkg = '10 Lessons';
const instructor = 'Anisha';
const lessonsCompleted = 10;
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let certSheet = regSS.getSheetByName('Certificates Issued');
if (!certSheet) { fullSetup(); certSheet = regSS.getSheetByName('Certificates Issued'); }
const certNum = generateCertNumber_(certSheet);
const pdf = generateCertificatePDF_(studentName, pkg, lessonsCompleted, instructor, certNum);
const sent = sendCertificateEmail_(studentName, email, pkg, instructor, certNum, pdf);
Logger.log('Certificate: ' + pdf.getUrl());
Logger.log('Email sent: ' + sent);
certSheet.appendRow([certNum, studentName, email, '', pkg, lessonsCompleted, instructor, new Date(), pdf.getUrl(), sent ? 'Yes' : 'Failed']);
}
/** Test: generate a preview PDF only (no email). */
function testPreviewCertificate() {
const regSS = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let certSheet = regSS.getSheetByName('Certificates Issued');
if (!certSheet) { fullSetup(); certSheet = regSS.getSheetByName('Certificates Issued'); }
const certNum = generateCertNumber_(certSheet);
const pdf = generateCertificatePDF_('Sarah Johnson', 'Premium Package', 15, 'Anisha', certNum);
Logger.log('Preview certificate: ' + pdf.getUrl());
}
/**
* Student Portal โ Flavors Driving School
*
* A web app where students can look up their:
* - Upcoming lessons (from Instructor Schedule Board)
* - Payment balance (from Payment Tracker)
* - Progress (lessons completed vs purchased)
* - Package info (from Student Registration)
*
* FEATURES:
* - Demo mode with fake sample data for presentations
* - Email-first matching with fuzzy name fallback
* - Phone verification step before showing data (production)
* - Input sanitization
* - 100% completion celebration
*
* CONNECTED SHEETS:
* - Student Registration: 1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY
* - Instructor Schedule Board: 1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE
* - Payment Tracker: 1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk
*/
// ============ FORCE AUTH โ hits ALL 3 sheets ============
function forceAuth() {
const reg = SpreadsheetApp.openById('1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY');
Logger.log('Registration: ' + reg.getName());
const sched = SpreadsheetApp.openById('1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE');
Logger.log('Schedule: ' + sched.getName());
const pay = SpreadsheetApp.openById('1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk');
Logger.log('Payments: ' + pay.getName());
Logger.log('โ
Auth complete โ all 3 sheets accessible.');
}
const PORTAL_CONFIG = {
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
SCHEDULE_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
PAYMENT_ID: '1XWNU7PygQX9jV6mFUUrgHYU_vrhS7NP1OHwNUNWRoOk',
SCHOOL_NAME: 'Flavors Driving School',
// DEMO MODE โ set to true for presentations, false for production
DEMO_MODE: true,
// VERIFICATION โ set to true to require phone last 4 digits before showing data
REQUIRE_VERIFICATION: false,
PACKAGES: {
'3 Lessons': 3, '5 Lessons': 5, '10 Lessons': 10,
'15 Lessons': 15, '25 Lessons': 25, '5-Hour Class': 1
}
};
// ============ DEMO DATA ============
const DEMO_STUDENTS = {
'sarah johnson': {
name: 'Sarah Johnson',
email: '[email protected]',
phone: '5551234',
package: '10 Lessons',
totalPurchased: 10,
completedLessons: 7,
progressPct: 70,
upcoming: [
{ date: 'Mon, Jun 30', time: '10:00 AM', instructor: 'Carlos' },
{ date: 'Wed, Jul 2', time: '2:00 PM', instructor: 'Anisha' },
{ date: 'Fri, Jul 4', time: '11:00 AM', instructor: 'Carlos' }
],
balance: 150,
totalPaid: 550,
totalDue: 700,
recentPayments: [
{ amount: 200, date: 'Jun 1, 2025' },
{ amount: 200, date: 'Jun 10, 2025' },
{ amount: 150, date: 'Jun 20, 2025' }
]
},
'mike rivera': {
name: 'Mike Rivera',
email: '[email protected]',
phone: '5555678',
package: '5 Lessons',
totalPurchased: 5,
completedLessons: 5,
progressPct: 100,
upcoming: [],
balance: 0,
totalPaid: 445,
totalDue: 445,
recentPayments: [
{ amount: 245, date: 'May 15, 2025' },
{ amount: 200, date: 'Jun 5, 2025' }
]
},
'demo student': {
name: 'Demo Student',
email: '[email protected]',
phone: '0000',
package: '5 Lessons',
totalPurchased: 5,
completedLessons: 3,
progressPct: 60,
upcoming: [
{ date: 'Tue, Jul 1', time: '9:00 AM', instructor: 'Nick' },
{ date: 'Thu, Jul 3', time: '1:00 PM', instructor: 'Anisha' }
],
balance: 100,
totalPaid: 345,
totalDue: 445,
recentPayments: [
{ amount: 200, date: 'Jun 1, 2025' },
{ amount: 145, date: 'Jun 15, 2025' }
]
}
};
// ============ WEB APP ============
function doGet(e) {
return HtmlService.createHtmlOutput(buildPortalHTML())
.setTitle('Student Portal โ ' + PORTAL_CONFIG.SCHOOL_NAME)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL)
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// ============ API: Get Config ============
function getPortalConfig() {
return {
demoMode: PORTAL_CONFIG.DEMO_MODE,
requireVerification: PORTAL_CONFIG.REQUIRE_VERIFICATION,
schoolName: PORTAL_CONFIG.SCHOOL_NAME
};
}
// ============ API: Student Lookup ============
function lookupStudent(query) {
// Sanitize input
query = sanitize(query);
if (!query || query.length < 3) return { error: 'Please enter at least 3 characters.' };
// === DEMO MODE ===
if (PORTAL_CONFIG.DEMO_MODE) {
return lookupDemoStudent(query);
}
// === PRODUCTION MODE ===
// 1. Find student in Registration (email-first, then fuzzy name)
const student = findInRegistration(query);
if (!student) return { error: 'Student not found. Please check your name or email and try again.' };
// 2. If verification required, return partial data and ask for verification
if (PORTAL_CONFIG.REQUIRE_VERIFICATION && !student.verified) {
return {
needsVerification: true,
name: student.name,
maskedPhone: maskPhone(student.phone)
};
}
// 3. Get full data
return buildStudentResponse(student);
}
// ============ API: Verify Student ============
function verifyStudent(query, lastFour) {
query = sanitize(query);
lastFour = sanitize(lastFour).replace(/\D/g, '');
if (!lastFour || lastFour.length !== 4) return { error: 'Please enter the last 4 digits of your phone number.' };
if (PORTAL_CONFIG.DEMO_MODE) {
const demo = lookupDemoStudent(query);
if (demo.error) return demo;
if (demo.phone && demo.phone.slice(-4) === lastFour) {
return demo;
}
return { error: 'Verification failed. Please check the last 4 digits of your phone number.' };
}
const student = findInRegistration(query);
if (!student) return { error: 'Student not found.' };
const phoneDigits = (student.phone || '').replace(/\D/g, '');
if (phoneDigits.length >= 4 && phoneDigits.slice(-4) === lastFour) {
return buildStudentResponse(student);
}
return { error: 'Verification failed. Please check the last 4 digits of your phone number.' };
}
// ============ DEMO LOOKUP ============
function lookupDemoStudent(query) {
const q = query.toLowerCase();
// Try exact key match
if (DEMO_STUDENTS[q]) return Object.assign({}, DEMO_STUDENTS[q]);
// Try partial match
for (const [key, student] of Object.entries(DEMO_STUDENTS)) {
if (key.includes(q) || student.email.toLowerCase().includes(q) ||
student.name.toLowerCase().includes(q)) {
return Object.assign({}, student);
}
}
return { error: 'Demo student not found. Try "Sarah Johnson", "Mike Rivera", or "Demo Student".' };
}
// ============ BUILD RESPONSE ============
function buildStudentResponse(student) {
const schedule = getStudentSchedule(student.name, student.email);
const payments = getStudentPayments(student.name, student.email);
const now = new Date();
const completedLessons = schedule.filter(s => s.date < now && !isSkipStatus(s.status)).length;
const upcomingLessons = schedule.filter(s => s.date >= now && !isSkipStatus(s.status));
const totalPurchased = student.lessonCount || 0;
const progressPct = totalPurchased > 0 ? Math.min(100, Math.round((completedLessons / totalPurchased) * 100)) : 0;
return {
name: student.name,
email: student.email,
package: student.package,
totalPurchased: totalPurchased,
completedLessons: completedLessons,
progressPct: progressPct,
upcoming: upcomingLessons.map(s => ({
date: Utilities.formatDate(s.date, 'America/New_York', 'EEE, MMM d'),
time: Utilities.formatDate(s.date, 'America/New_York', 'h:mm a'),
instructor: s.instructor
})),
balance: payments.balance,
totalPaid: payments.totalPaid,
totalDue: payments.totalDue,
recentPayments: payments.recent
};
}
// ============ DATA FUNCTIONS ============
function findInRegistration(query) {
try {
const ss = SpreadsheetApp.openById(PORTAL_CONFIG.REGISTRATION_ID);
const sheet = ss.getSheets()[0];
const data = sheet.getDataRange().getValues();
if (data.length < 2) return null;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['name', 'full name', 'student name']);
const emailCol = findCol(headers, ['email', 'student email', 'email address']);
const phoneCol = findCol(headers, ['phone', 'phone number', 'cell', 'mobile', 'contact number']);
const pkgCol = findCol(headers, ['package', 'lesson package', 'selected package', 'number of lessons']);
const isEmail = query.includes('@');
let bestMatch = null;
let bestScore = 0;
for (let i = 1; i < data.length; i++) {
const name = nameCol !== -1 ? String(data[i][nameCol]).trim() : '';
const email = emailCol !== -1 ? String(data[i][emailCol]).trim() : '';
const phone = phoneCol !== -1 ? String(data[i][phoneCol]).trim() : '';
if (!name && !email) continue;
let score = 0;
// Email exact match = highest priority
if (isEmail && email.toLowerCase() === query) {
score = 100;
}
// Exact name match
else if (name.toLowerCase() === query) {
score = 90;
}
// Fuzzy name match
else if (!isEmail && fuzzyNameMatch(query, name.toLowerCase())) {
score = 70;
}
if (score > bestScore) {
bestScore = score;
const pkgRaw = pkgCol !== -1 ? String(data[i][pkgCol]).trim() : '';
let pkg = 'Unknown';
let lessonCount = 0;
for (const [key, count] of Object.entries(PORTAL_CONFIG.PACKAGES)) {
if (pkgRaw.toLowerCase().includes(key.toLowerCase())) {
pkg = key; lessonCount = count; break;
}
}
bestMatch = { name, email, phone, package: pkg, lessonCount };
}
}
return bestMatch;
} catch (e) { Logger.log('Registration error: ' + e.message); }
return null;
}
function getStudentSchedule(studentName, studentEmail) {
const lessons = [];
try {
const ss = SpreadsheetApp.openById(PORTAL_CONFIG.SCHEDULE_ID);
const data = ss.getSheets()[0].getDataRange().getValues();
if (data.length < 2) return lessons;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['student name', 'student', 'name']);
const emailCol = findCol(headers, ['email', 'student email']);
const dateCol = findCol(headers, ['date', 'lesson date', 'booking date']);
const timeCol = findCol(headers, ['time', 'lesson time', 'start time', 'time slot']);
const instrCol = findCol(headers, ['instructor', 'instructor name']);
const statusCol = findCol(headers, ['status', 'booking status']);
const nameKey = studentName.toLowerCase().trim();
const emailKey = (studentEmail || '').toLowerCase().trim();
for (let i = 1; i < data.length; i++) {
const rowName = nameCol !== -1 ? String(data[i][nameCol]).trim().toLowerCase() : '';
const rowEmail = emailCol !== -1 ? String(data[i][emailCol]).trim().toLowerCase() : '';
// Match by email first, then exact name, then fuzzy name
let matched = false;
if (emailKey && rowEmail === emailKey) matched = true;
else if (rowName === nameKey) matched = true;
else if (fuzzyNameMatch(nameKey, rowName)) matched = true;
if (!matched) continue;
let lessonDate = dateCol !== -1 ? data[i][dateCol] : null;
if (!(lessonDate instanceof Date)) continue;
// Merge time if separate column
if (timeCol !== -1 && data[i][timeCol]) {
const timeVal = data[i][timeCol];
if (timeVal instanceof Date) {
lessonDate.setHours(timeVal.getHours(), timeVal.getMinutes());
} else {
const match = String(timeVal).match(/(\d{1,2}):?(\d{2})?\s*(am|pm)?/i);
if (match) {
let h = parseInt(match[1]);
const m = parseInt(match[2] || '0');
const ampm = (match[3] || '').toLowerCase();
if (ampm === 'pm' && h < 12) h += 12;
if (ampm === 'am' && h === 12) h = 0;
lessonDate.setHours(h, m);
}
}
}
lessons.push({
date: lessonDate,
instructor: instrCol !== -1 ? String(data[i][instrCol]).trim() : 'TBD',
status: statusCol !== -1 ? String(data[i][statusCol]).trim() : 'Confirmed'
});
}
lessons.sort((a, b) => a.date - b.date);
} catch (e) { Logger.log('Schedule error: ' + e.message); }
return lessons;
}
function getStudentPayments(studentName, studentEmail) {
const result = { balance: 0, totalPaid: 0, totalDue: 0, recent: [] };
try {
const ss = SpreadsheetApp.openById(PORTAL_CONFIG.PAYMENT_ID);
const data = ss.getSheets()[0].getDataRange().getValues();
if (data.length < 2) return result;
const headers = data[0].map(h => String(h).toLowerCase().trim());
const nameCol = findCol(headers, ['student name', 'student', 'name']);
const emailCol = findCol(headers, ['email', 'student email']);
const amountCol = findCol(headers, ['amount', 'payment amount', 'amount paid']);
const dateCol = findCol(headers, ['date', 'payment date']);
const balanceCol = findCol(headers, ['balance', 'remaining balance', 'balance due']);
const totalCol = findCol(headers, ['total', 'total due', 'package price', 'total cost']);
const nameKey = studentName.toLowerCase().trim();
const emailKey = (studentEmail || '').toLowerCase().trim();
for (let i = 1; i < data.length; i++) {
const rowName = nameCol !== -1 ? String(data[i][nameCol]).trim().toLowerCase() : '';
const rowEmail = emailCol !== -1 ? String(data[i][emailCol]).trim().toLowerCase() : '';
// Match by email first, then exact name, then fuzzy
let matched = false;
if (emailKey && rowEmail === emailKey) matched = true;
else if (rowName === nameKey) matched = true;
else if (fuzzyNameMatch(nameKey, rowName)) matched = true;
if (!matched) continue;
const amount = amountCol !== -1 ? parseFloat(data[i][amountCol]) || 0 : 0;
const date = dateCol !== -1 ? data[i][dateCol] : null;
if (balanceCol !== -1) result.balance = parseFloat(data[i][balanceCol]) || 0;
if (totalCol !== -1) result.totalDue = parseFloat(data[i][totalCol]) || 0;
result.totalPaid += amount;
if (amount > 0) {
result.recent.push({
amount: amount,
date: date instanceof Date ? Utilities.formatDate(date, 'America/New_York', 'MMM d, yyyy') : 'N/A'
});
}
}
result.recent = result.recent.slice(-5);
if (result.totalDue === 0 && result.balance > 0) result.totalDue = result.totalPaid + result.balance;
} catch (e) { Logger.log('Payment error: ' + e.message); }
return result;
}
// ============ HELPERS ============
function sanitize(input) {
if (!input) return '';
return String(input)
.trim()
.replace(/[<>{}()\[\]\\\/]/g, '') // Strip dangerous chars
.replace(/\s+/g, ' ') // Collapse whitespace
.substring(0, 100) // Max length
.toLowerCase();
}
function isSkipStatus(status) {
const skip = ['cancelled', 'canceled', 'no-show', 'no show', 'noshow', 'rescheduled'];
return skip.includes((status || '').toLowerCase().trim());
}
function maskPhone(phone) {
const digits = (phone || '').replace(/\D/g, '');
if (digits.length < 4) return '****';
return '***-***-' + digits.slice(-4);
}
function fuzzyNameMatch(query, name) {
if (!query || !name) return false;
const q = query.toLowerCase().replace(/\s+/g, ' ').trim();
const n = name.toLowerCase().replace(/\s+/g, ' ').trim();
if (q === n) return true;
if (q.includes(n) || n.includes(q)) return true;
const qParts = q.split(' ').filter(Boolean);
const nParts = n.split(' ').filter(Boolean);
// Same last name + similar first name (3+ chars)
if (qParts.length >= 2 && nParts.length >= 2) {
const qLast = qParts[qParts.length - 1];
const nLast = nParts[nParts.length - 1];
const qFirst = qParts[0];
const nFirst = nParts[0];
if (qLast === nLast && qFirst.substring(0, 3) === nFirst.substring(0, 3)) return true;
if (qFirst === nLast && qLast === nFirst) return true; // Swapped order
}
// Levenshtein distance <= 2
if (levenshtein(q, n) <= 2) return true;
return false;
}
function levenshtein(a, b) {
if (a.length === 0) return b.length;
if (b.length === 0) return a.length;
const matrix = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
}
return matrix[b.length][a.length];
}
function findCol(headers, keywords) {
for (const kw of keywords) {
const idx = headers.findIndex(h => h.includes(kw));
if (idx !== -1) return idx;
}
return -1;
}
// ============ HTML TEMPLATE ============
function buildPortalHTML() {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Student Portal โ ${PORTAL_CONFIG.SCHOOL_NAME}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
* { margin:0; padding:0; box-sizing:border-box; }
:root {
--bg: #050505;
--surface: rgba(255,255,255,0.04);
--glass: rgba(255,255,255,0.06);
--glass-border: rgba(255,255,255,0.1);
--glass-hover: rgba(255,255,255,0.12);
--red: #ff2d2d;
--red-glow: rgba(255,45,45,0.3);
--text: #ffffff;
--text-dim: #888888;
--text-muted: #555555;
--success: #22c55e;
--warning: #f59e0b;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: -50%; left: -50%;
width: 200%; height: 200%;
background: radial-gradient(circle at 30% 20%, rgba(255,45,45,0.06) 0%, transparent 50%),
radial-gradient(circle at 70% 80%, rgba(255,45,45,0.03) 0%, transparent 50%);
pointer-events: none; z-index: 0;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
position: relative; z-index: 1;
}
.header { text-align: center; margin-bottom: 40px; }
.logo { font-size: 48px; margin-bottom: 8px; }
.header h1 {
font-size: 28px; font-weight: 800; letter-spacing: -0.5px;
background: linear-gradient(135deg, #fff 0%, #ccc 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
}
.header p { color: var(--text-dim); font-size: 14px; margin-top: 4px; }
.school-name { color: var(--red) !important; -webkit-text-fill-color: var(--red) !important; font-weight: 600; }
/* Demo Banner */
.demo-banner {
text-align: center;
margin-bottom: 20px;
padding: 10px 20px;
background: rgba(255,45,45,0.08);
border: 1px solid rgba(255,45,45,0.2);
border-radius: 14px;
}
.demo-badge {
display: inline-block;
color: #ff6b6b; font-size: 12px; font-weight: 700;
letter-spacing: 1px; text-transform: uppercase;
}
.demo-hint {
color: #666; font-size: 11px; margin-top: 4px;
}
/* Glass Card */
.glass-card {
background: var(--glass);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: 24px; padding: 32px; margin-bottom: 20px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glass-card:hover {
background: var(--glass-hover);
border-color: rgba(255,255,255,0.15);
transform: translateY(-2px);
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.search-card { padding: 40px; }
.search-wrapper { position: relative; margin-top: 20px; }
.search-input {
width: 100%; padding: 16px 24px 16px 52px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 16px; color: #fff; font-size: 16px;
font-family: 'Inter', sans-serif; outline: none;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--red);
box-shadow: 0 0 30px var(--red-glow);
background: rgba(255,255,255,0.08);
}
.search-input::placeholder { color: var(--text-muted); }
.search-icon { position: absolute; left: 20px; top: 50%; transform: translateY(-50%); font-size: 20px; opacity: 0.4; }
.search-btn {
width: 100%; margin-top: 16px; padding: 14px;
background: linear-gradient(135deg, #ff2d2d, #cc0000);
border: none; border-radius: 14px; color: #fff;
font-size: 16px; font-weight: 700; font-family: 'Inter', sans-serif;
cursor: pointer; transition: all 0.3s ease;
box-shadow: 0 4px 20px var(--red-glow);
}
.search-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 30px var(--red-glow); }
.search-btn:active { transform: translateY(0); }
.search-label { color: var(--text-dim); font-size: 13px; margin-bottom: 8px; display: block; }
.error-msg {
color: var(--red); text-align: center; padding: 12px; margin-top: 12px;
font-size: 14px; border-radius: 12px; background: rgba(255,45,45,0.08);
display: none;
}
.loading { text-align: center; padding: 40px; display: none; }
.spinner {
width: 40px; height: 40px;
border: 3px solid rgba(255,255,255,0.1);
border-top-color: var(--red); border-radius: 50%;
animation: spin 0.8s linear infinite; margin: 0 auto 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Verification */
.verify-card { padding: 40px; text-align: center; display: none; }
.verify-card h3 { font-size: 18px; margin-bottom: 8px; }
.verify-card p { color: var(--text-dim); font-size: 13px; margin-bottom: 20px; }
.verify-code {
width: 160px; padding: 14px; text-align: center;
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 14px; color: #fff; font-size: 24px;
font-family: 'Inter', sans-serif; letter-spacing: 8px;
outline: none; transition: all 0.3s;
}
.verify-code:focus { border-color: var(--red); box-shadow: 0 0 20px var(--red-glow); }
.verify-code::placeholder { letter-spacing: 4px; font-size: 16px; }
.verify-phone-hint { color: var(--text-muted); font-size: 12px; margin-top: 8px; }
/* Welcome */
.welcome { text-align: center; padding: 24px; }
.welcome h2 { font-size: 24px; font-weight: 700; margin-bottom: 4px; }
.package-badge {
display: inline-block; background: rgba(255,45,45,0.15);
color: var(--red); padding: 6px 16px; border-radius: 20px;
font-size: 13px; font-weight: 600; margin-top: 8px;
}
/* Bubble Icons */
.bubble-icon {
width: 56px; height: 56px; border-radius: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 26px; flex-shrink: 0;
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
cursor: default; position: relative;
box-shadow: 0 4px 15px rgba(0,0,0,0.2);
}
.bubble-icon::after {
content: ''; position: absolute; inset: 0; border-radius: 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.2) 0%, transparent 50%);
pointer-events: none;
}
.glass-card:hover .bubble-icon {
transform: scale(1.15) translateY(-4px);
box-shadow: 0 12px 30px rgba(0,0,0,0.3);
}
.bubble-progress { background: linear-gradient(135deg, #1a3a1a, #0a2e0a); }
.bubble-schedule { background: linear-gradient(135deg, #1a1a3a, #0a0a2e); }
.bubble-payment { background: linear-gradient(135deg, #3a2a1a, #2e1a0a); }
.bubble-history { background: linear-gradient(135deg, #2a1a2a, #1e0a1e); }
/* Stats Grid */
.stats-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px; margin-bottom: 20px;
}
.stat-card { display: flex; align-items: center; gap: 16px; padding: 20px; }
.stat-info h3 {
font-size: 12px; font-weight: 500; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px;
}
.stat-info .value { font-size: 28px; font-weight: 800; letter-spacing: -1px; }
.stat-info .sub { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
/* Progress */
.progress-section { padding: 24px; }
.progress-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.progress-header h3 { font-size: 16px; font-weight: 600; }
.progress-track {
width: 100%; height: 12px; background: rgba(255,255,255,0.06);
border-radius: 6px; overflow: hidden; margin-bottom: 8px;
}
.progress-fill {
height: 100%; border-radius: 6px;
transition: width 1s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 20px var(--red-glow);
}
.progress-fill.complete {
background: linear-gradient(90deg, var(--success), #4ade80) !important;
box-shadow: 0 0 20px rgba(34,197,94,0.4) !important;
}
.progress-fill.in-progress {
background: linear-gradient(90deg, var(--red), #ff6b6b);
}
.progress-label {
display: flex; justify-content: space-between;
font-size: 13px; color: var(--text-dim);
}
/* Completion Celebration */
.completion-banner {
display: none;
text-align: center;
padding: 24px;
margin-bottom: 20px;
background: linear-gradient(135deg, rgba(34,197,94,0.1), rgba(34,197,94,0.03));
border: 1px solid rgba(34,197,94,0.2);
border-radius: 24px;
animation: celebratePulse 2s ease-in-out infinite;
}
@keyframes celebratePulse {
0%, 100% { box-shadow: 0 0 20px rgba(34,197,94,0.1); }
50% { box-shadow: 0 0 40px rgba(34,197,94,0.2); }
}
.completion-banner .trophy { font-size: 56px; margin-bottom: 8px; }
.completion-banner h2 { color: var(--success); font-size: 22px; font-weight: 800; }
.completion-banner p { color: var(--text-dim); font-size: 14px; margin-top: 4px; }
/* Confetti */
.confetti-container {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none; z-index: 1000; overflow: hidden;
}
.confetti {
position: absolute; top: -10px;
width: 10px; height: 10px;
opacity: 0; border-radius: 2px;
animation: confettiFall 3s ease-in forwards;
}
@keyframes confettiFall {
0% { opacity: 1; top: -10px; transform: rotate(0deg) scale(1); }
100% { opacity: 0; top: 110vh; transform: rotate(720deg) scale(0.5); }
}
/* Section */
.section-header { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; }
.section-header h3 { font-size: 16px; font-weight: 600; }
.lesson-item {
display: flex; align-items: center; gap: 16px; padding: 16px;
background: rgba(255,255,255,0.03); border-radius: 14px;
margin-bottom: 10px; border: 1px solid rgba(255,255,255,0.04);
transition: all 0.2s ease;
}
.lesson-item:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.08); }
.lesson-date { min-width: 60px; text-align: center; }
.lesson-date .day { font-size: 22px; font-weight: 800; color: var(--red); line-height: 1; }
.lesson-date .month {
font-size: 11px; color: var(--text-dim); text-transform: uppercase;
font-weight: 600; letter-spacing: 0.5px;
}
.lesson-details { flex: 1; }
.lesson-details .time { font-size: 15px; font-weight: 600; }
.lesson-details .instructor { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
.no-data { text-align: center; color: var(--text-muted); padding: 24px; font-size: 14px; }
.payment-row {
display: flex; justify-content: space-between; align-items: center;
padding: 12px 16px; background: rgba(255,255,255,0.03);
border-radius: 12px; margin-bottom: 8px;
}
.payment-row .date { color: var(--text-dim); font-size: 13px; }
.payment-row .amount { font-weight: 700; color: var(--success); }
.balance-display {
text-align: center; padding: 20px; margin-bottom: 16px;
background: rgba(255,255,255,0.03); border-radius: 16px;
}
.balance-display .label {
font-size: 12px; color: var(--text-dim);
text-transform: uppercase; letter-spacing: 1px; margin-bottom: 4px;
}
.balance-display .amount { font-size: 36px; font-weight: 800; letter-spacing: -1px; }
.balance-zero { color: var(--success); }
.balance-owed { color: var(--warning); }
#dashboard { display: none; }
@media (max-width: 600px) {
.container { padding: 20px 12px; }
.glass-card { padding: 20px; border-radius: 18px; }
.search-card { padding: 24px; }
.stats-grid { grid-template-columns: 1fr; }
.header h1 { font-size: 22px; }
.welcome h2 { font-size: 20px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">๐</div>
<h1><span class="school-name">${PORTAL_CONFIG.SCHOOL_NAME}</span></h1>
<p>Student Portal</p>
</div>
<!-- Demo Banner -->
<div class="demo-banner" id="demoBanner" style="display:none;">
<div class="demo-badge">โฆ Demo Mode</div>
<p class="demo-hint">Try: "Sarah Johnson", "Mike Rivera", or "Demo Student"</p>
</div>
<!-- Search -->
<div id="searchSection" class="glass-card search-card">
<span class="search-label">Enter your name or email to view your dashboard</span>
<div class="search-wrapper">
<span class="search-icon">๐</span>
<input type="text" id="searchInput" class="search-input" placeholder="e.g. John Smith or [email protected]" autocomplete="off" maxlength="100">
</div>
<button id="searchBtn" class="search-btn" onclick="doSearch()">View My Dashboard</button>
<div id="errorMsg" class="error-msg"></div>
</div>
<!-- Verification -->
<div id="verifySection" class="glass-card verify-card">
<div style="font-size:40px;margin-bottom:12px;">๐</div>
<h3>Verify Your Identity</h3>
<p>Enter the last 4 digits of your phone number</p>
<p class="verify-phone-hint" id="verifyPhoneHint"></p>
<div style="margin:16px 0;">
<input type="text" id="verifyInput" class="verify-code" placeholder="โขโขโขโข" maxlength="4" inputmode="numeric">
</div>
<button class="search-btn" style="max-width:300px;margin:0 auto;" onclick="doVerify()">Verify</button>
<div id="verifyError" class="error-msg" style="margin-top:12px;"></div>
<button onclick="goBack()" style="background:none;border:none;color:var(--text-muted);font-size:13px;cursor:pointer;margin-top:16px;font-family:inherit;">โ Back</button>
</div>
<!-- Loading -->
<div id="loading" class="loading">
<div class="spinner"></div>
<p style="color:var(--text-dim);font-size:14px;">Looking up your information...</p>
</div>
<!-- Dashboard -->
<div id="dashboard">
<!-- Completion Celebration -->
<div class="completion-banner" id="completionBanner">
<div class="trophy">๐</div>
<h2>Package Complete!</h2>
<p>Congratulations! You've finished all your lessons. Safe driving! ๐</p>
</div>
<!-- Welcome -->
<div class="glass-card welcome">
<h2>Welcome back, <span id="studentName"></span>! ๐</h2>
<div class="package-badge" id="packageBadge"></div>
</div>
<!-- Progress -->
<div class="glass-card progress-section">
<div class="progress-header">
<div class="bubble-icon bubble-progress">๐</div>
<div>
<h3>Your Progress</h3>
<p style="font-size:13px;color:var(--text-dim);" id="progressText"></p>
</div>
</div>
<div class="progress-track">
<div class="progress-fill in-progress" id="progressBar" style="width:0%"></div>
</div>
<div class="progress-label">
<span id="progressLeft"></span>
<span id="progressRight"></span>
</div>
</div>
<!-- Stats -->
<div class="stats-grid">
<div class="glass-card stat-card">
<div class="bubble-icon bubble-schedule">๐
</div>
<div class="stat-info">
<h3>Upcoming</h3>
<div class="value" id="upcomingCount">0</div>
<div class="sub">lessons scheduled</div>
</div>
</div>
<div class="glass-card stat-card">
<div class="bubble-icon bubble-payment">๐ฐ</div>
<div class="stat-info">
<h3>Balance</h3>
<div class="value" id="balanceAmount">$0</div>
<div class="sub" id="balanceSub">remaining</div>
</div>
</div>
</div>
<!-- Upcoming Lessons -->
<div class="glass-card" id="scheduleCard">
<div class="section-header">
<div class="bubble-icon bubble-schedule">๐๏ธ</div>
<h3>Upcoming Lessons</h3>
</div>
<div id="lessonList"></div>
</div>
<!-- Payments -->
<div class="glass-card" id="paymentCard">
<div class="section-header">
<div class="bubble-icon bubble-history">๐งพ</div>
<h3>Payment History</h3>
</div>
<div class="balance-display">
<div class="label">Total Paid</div>
<div class="amount balance-zero" id="totalPaid">$0</div>
</div>
<div id="paymentList"></div>
</div>
<!-- Back button -->
<div style="text-align:center;margin-top:20px;">
<button class="search-btn" style="max-width:300px;" onclick="goBack()">โ Search Again</button>
</div>
</div>
</div>
<!-- Confetti container -->
<div class="confetti-container" id="confettiContainer"></div>
<script>
let currentQuery = '';
let portalConfig = {};
// Load config on start
google.script.run
.withSuccessHandler(function(cfg) {
portalConfig = cfg || {};
if (portalConfig.demoMode) {
document.getElementById('demoBanner').style.display = 'block';
}
})
.getPortalConfig();
document.getElementById('searchInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doSearch();
});
document.getElementById('verifyInput').addEventListener('keydown', function(e) {
if (e.key === 'Enter') doVerify();
});
function doSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query || query.length < 3) { showError('errorMsg', 'Please enter at least 3 characters.'); return; }
currentQuery = query;
hideError('errorMsg');
document.getElementById('searchSection').style.display = 'none';
document.getElementById('loading').style.display = 'block';
google.script.run
.withSuccessHandler(handleResult)
.withFailureHandler(function() { handleGenericError('searchSection'); })
.lookupStudent(query);
}
function doVerify() {
const code = document.getElementById('verifyInput').value.trim();
if (!code || code.length !== 4) { showError('verifyError', 'Please enter 4 digits.'); return; }
hideError('verifyError');
document.getElementById('verifySection').style.display = 'none';
document.getElementById('loading').style.display = 'block';
google.script.run
.withSuccessHandler(function(data) {
document.getElementById('loading').style.display = 'none';
if (data.error) {
document.getElementById('verifySection').style.display = 'block';
showError('verifyError', data.error);
return;
}
showDashboard(data);
})
.withFailureHandler(function() { handleGenericError('verifySection'); })
.verifyStudent(currentQuery, code);
}
function handleResult(data) {
document.getElementById('loading').style.display = 'none';
if (data.error) {
document.getElementById('searchSection').style.display = 'block';
showError('errorMsg', data.error);
return;
}
// Needs verification?
if (data.needsVerification) {
document.getElementById('verifyPhoneHint').textContent = 'Phone on file: ' + data.maskedPhone;
document.getElementById('verifySection').style.display = 'block';
document.getElementById('verifyInput').value = '';
document.getElementById('verifyInput').focus();
return;
}
showDashboard(data);
}
function showDashboard(data) {
// Welcome
document.getElementById('studentName').textContent = data.name.split(' ')[0];
document.getElementById('packageBadge').textContent = data.package + (data.totalPurchased > 1 ? ' Package' : '');
// Progress
const isComplete = data.progressPct >= 100;
document.getElementById('progressText').textContent = data.completedLessons + ' of ' + data.totalPurchased + ' lessons completed';
const bar = document.getElementById('progressBar');
bar.className = 'progress-fill ' + (isComplete ? 'complete' : 'in-progress');
setTimeout(function() { bar.style.width = data.progressPct + '%'; }, 100);
document.getElementById('progressLeft').textContent = data.completedLessons + ' done';
document.getElementById('progressRight').textContent = data.progressPct + '%';
// Completion celebration
if (isComplete) {
document.getElementById('completionBanner').style.display = 'block';
setTimeout(launchConfetti, 500);
} else {
document.getElementById('completionBanner').style.display = 'none';
}
// Stats
document.getElementById('upcomingCount').textContent = data.upcoming.length;
var bal = data.balance || 0;
document.getElementById('balanceAmount').textContent = '$' + bal.toFixed(0);
document.getElementById('balanceAmount').className = 'value ' + (bal > 0 ? 'balance-owed' : 'balance-zero');
document.getElementById('balanceSub').textContent = bal > 0 ? 'remaining' : 'all paid!';
// Upcoming lessons
var lessonDiv = document.getElementById('lessonList');
if (data.upcoming.length === 0) {
lessonDiv.innerHTML = '<div class="no-data">No upcoming lessons scheduled</div>';
} else {
lessonDiv.innerHTML = data.upcoming.map(function(l) {
var parts = l.date.split(', ');
var dayParts = (parts[1] || parts[0]).split(' ');
return '<div class="lesson-item">' +
'<div class="lesson-date"><div class="month">' + (dayParts[0] || '') + '</div><div class="day">' + (dayParts[1] || '') + '</div></div>' +
'<div class="lesson-details"><div class="time">' + l.time + '</div><div class="instructor">with ' + l.instructor + '</div></div>' +
'</div>';
}).join('');
}
// Payments
document.getElementById('totalPaid').textContent = '$' + (data.totalPaid || 0).toFixed(0);
var payDiv = document.getElementById('paymentList');
if (!data.recentPayments || data.recentPayments.length === 0) {
payDiv.innerHTML = '<div class="no-data">No payment records found</div>';
} else {
payDiv.innerHTML = data.recentPayments.map(function(p) {
return '<div class="payment-row"><span class="date">' + p.date + '</span><span class="amount">+$' + p.amount.toFixed(0) + '</span></div>';
}).join('');
}
document.getElementById('dashboard').style.display = 'block';
}
function launchConfetti() {
var container = document.getElementById('confettiContainer');
container.innerHTML = '';
var colors = ['#ff2d2d', '#22c55e', '#f59e0b', '#3b82f6', '#a855f7', '#ec4899', '#ffffff'];
for (var i = 0; i < 60; i++) {
var conf = document.createElement('div');
conf.className = 'confetti';
conf.style.left = Math.random() * 100 + '%';
conf.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
conf.style.animationDelay = (Math.random() * 2) + 's';
conf.style.animationDuration = (2 + Math.random() * 2) + 's';
conf.style.width = (6 + Math.random() * 8) + 'px';
conf.style.height = (6 + Math.random() * 8) + 'px';
container.appendChild(conf);
}
setTimeout(function() { container.innerHTML = ''; }, 5000);
}
function handleGenericError(showSection) {
document.getElementById('loading').style.display = 'none';
document.getElementById(showSection).style.display = 'block';
var errId = showSection === 'verifySection' ? 'verifyError' : 'errorMsg';
showError(errId, 'Something went wrong. Please try again.');
}
function showError(id, msg) {
var el = document.getElementById(id);
el.textContent = msg;
el.style.display = 'block';
}
function hideError(id) {
document.getElementById(id).style.display = 'none';
}
function goBack() {
document.getElementById('dashboard').style.display = 'none';
document.getElementById('verifySection').style.display = 'none';
document.getElementById('completionBanner').style.display = 'none';
document.getElementById('confettiContainer').innerHTML = '';
document.getElementById('searchSection').style.display = 'block';
document.getElementById('searchInput').value = '';
document.getElementById('progressBar').style.width = '0%';
document.getElementById('searchInput').focus();
}
</script>
</body>
</html>`;
}
/**
* =========================================================
* STUDENT REGISTRATION PROCESSOR
* Flavors Driving School
* =========================================================
* Auto-processes new form responses:
* - Normalizes dates (YYYY-MM-DD) and phone (XXX-XXX-XXXX)
* - Validates required fields + email format
* - Calculates age from DOB, flags underage students
* - Detects duplicate registrations
* - Generates unique Student ID (FDS-0001)
* - Sends branded welcome email with confirmation number
* - Notifies admin of new registrations
* - Payment OK? flag with smart logic
* - Demo mode for safe presentations
* =========================================================
*/
const CONFIG = {
// โโ Sheet IDs โโ
REGISTRATION_ID: '1HTqF_DqZW2yJu6ChutR6j1YCvfCEt1D_hS6Du-T0sEY',
SCHEDULE_BOARD_ID: '1ORLiNB_u0BkbbhLAa6B0OCTbgrJIO1AVQZ1L1Ex37VE',
// โโ Sheet tabs (empty = first sheet) โโ
REGISTRATION_SHEET_TAB: '',
// โโ Branding โโ
SCHOOL_NAME: 'Flavors Driving School',
SCHOOL_TAGLINE: 'Your Road to Freedom Starts Here',
ADMIN_EMAIL: '[email protected]',
SCHOOL_PHONE: '(718) 555-0100',
SCHOOL_ADDRESS: 'Queens, NY',
// โโ Timezone โโ
TIMEZONE: 'America/New_York',
// โโ Age rules โโ
MIN_DRIVING_AGE: 16,
MINOR_AGE: 18,
// โโ Demo mode โโ
DEMO_MODE: true
};
// โโ Column header keywords โโ
const COL_KEYWORDS = {
TIMESTAMP: ['timestamp', 'date submitted', 'submitted'],
NAME: ['student name', 'full name', 'name'],
EMAIL: ['email', 'student email', 'email address'],
PHONE: ['phone', 'phone number', 'mobile', 'cell'],
DOB: ['date of birth', 'dob', 'birth date', 'birthday'],
START_DATE: ['start date', 'preferred start', 'start'],
PACKAGE: ['package', 'lesson package', 'program'],
PAYMENT_STATUS: ['payment status', 'payment', 'status'],
AMOUNT_PAID: ['amount paid', 'amount', 'paid'],
PAYMENT_OK: ['payment ok', 'payment ok?'],
STUDENT_ID: ['student id', 'id', 'student #'],
AGE: ['age', 'student age'],
FLAGS: ['flags', 'notes', 'alert'],
PARENT_NAME: ['parent name', 'guardian', 'parent/guardian'],
PARENT_EMAIL: ['parent email', 'guardian email'],
PARENT_PHONE: ['parent phone', 'guardian phone'],
WELCOME_SENT: ['welcome sent', 'email sent', 'confirmation sent'],
UNSUBSCRIBE: ['unsubscribe', 'opt out', 'opted out']
};
/* ================================================================
DEMO DATA
================================================================ */
const DEMO = {
newStudent: {
name: 'Sarah Johnson',
email: '[email protected]',
phone: '(917) 555-0123',
dob: '2008-03-15',
package: 'Premium Package',
startDate: '2026-03-01',
age: 17,
studentId: 'FDS-0048'
},
summary: {
processed: 1,
welcomeEmails: 1,
duplicatesFound: 0,
underage: 1,
flagged: 1
}
};
/* ================================================================
SETUP & AUTH
================================================================ */
function forceAuth() {
SpreadsheetApp.openById(CONFIG.REGISTRATION_ID).getSheetByName('test_auth_ignore');
SpreadsheetApp.openById(CONFIG.SCHEDULE_BOARD_ID).getSheetByName('test_auth_ignore');
MailApp.getRemainingDailyQuota();
Logger.log('โ
All permissions authorized. You can close this now.');
}
function fullSetup() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
const sheet = getRegistrationSheet_();
if (!sheet) { Logger.log('No registration sheet found.'); return; }
// โโ Ensure required columns exist โโ
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(h => String(h || '').trim());
const ensureCols = [
{ keywords: COL_KEYWORDS.STUDENT_ID, label: 'Student ID' },
{ keywords: COL_KEYWORDS.AGE, label: 'Age' },
{ keywords: COL_KEYWORDS.FLAGS, label: 'Flags' },
{ keywords: COL_KEYWORDS.PAYMENT_OK, label: 'Payment OK?' },
{ keywords: COL_KEYWORDS.WELCOME_SENT, label: 'Welcome Sent' },
{ keywords: COL_KEYWORDS.UNSUBSCRIBE, label: 'Unsubscribe' }
];
const lowerHeaders = headers.map(h => h.toLowerCase().trim());
let added = 0;
for (const col of ensureCols) {
if (findCol_(lowerHeaders, col.keywords) < 0) {
const nextCol = sheet.getLastColumn() + 1;
sheet.getRange(1, nextCol).setValue(col.label).setFontWeight('bold');
lowerHeaders.push(col.label.toLowerCase());
added++;
}
}
if (added > 0) Logger.log('โ
Added ' + added + ' new column(s) to registration sheet.');
// โโ Create "Registration Log" sheet โโ
let logSheet = ss.getSheetByName('Registration Log');
if (!logSheet) {
logSheet = ss.insertSheet('Registration Log');
logSheet.appendRow([
'Timestamp', 'Student Name', 'Email', 'Student ID', 'Package',
'Age', 'Flags', 'Welcome Email', 'Admin Notified'
]);
logSheet.getRange('1:1').setFontWeight('bold');
logSheet.setFrozenRows(1);
Logger.log('โ
Created "Registration Log" sheet.');
}
// โโ Triggers (clean old first) โโ
ScriptApp.getProjectTriggers().forEach(t => {
if (['onFormSubmit', 'processAllRows'].includes(t.getHandlerFunction())) {
ScriptApp.deleteTrigger(t);
}
});
// Form submit trigger
ScriptApp.newTrigger('onFormSubmit')
.forSpreadsheet(CONFIG.REGISTRATION_ID)
.onFormSubmit()
.create();
Logger.log('โ
Form submit trigger created.');
Logger.log('โ
Student Registration setup complete.');
}
/* ================================================================
FORM SUBMIT HANDLER
================================================================ */
function onFormSubmit(e) {
try {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would process new registration: ' + DEMO.newStudent.name);
return;
}
let sheet = null;
let newRowIndex = -1;
if (e && e.range) {
sheet = e.range.getSheet();
newRowIndex = e.range.getRow();
}
if (!sheet) {
sheet = getRegistrationSheet_();
}
if (!sheet || sheet.getLastRow() < 2) return;
if (newRowIndex > 1) {
// Process only the new row
processRow_(sheet, newRowIndex);
} else {
// Fallback: process all unprocessed rows
processAllRows();
}
} catch (err) {
Logger.log('onFormSubmit error: ' + (err.message || err));
notifyAdmin_('Registration Error', 'onFormSubmit failed: ' + String(err.message || err).substring(0, 500));
throw err;
}
}
/** Process all rows that haven't been processed yet (no Student ID). */
function processAllRows() {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would process all unprocessed rows.');
return;
}
const sheet = getRegistrationSheet_();
if (!sheet || sheet.getLastRow() < 2) return;
const data = sheet.getDataRange().getValues();
const headers = data[0].map(h => String(h || '').toLowerCase().trim());
const idCol = findCol_(headers, COL_KEYWORDS.STUDENT_ID);
let processed = 0;
for (let r = 1; r < data.length; r++) {
const hasId = idCol >= 0 && String(data[r][idCol] || '').trim();
if (!hasId) {
processRow_(sheet, r + 1);
processed++;
}
}
Logger.log('Processed ' + processed + ' new row(s).');
}
/* ================================================================
CORE: PROCESS A SINGLE ROW
================================================================ */
function processRow_(sheet, rowIndex) {
// Re-read headers fresh (columns may have been added by fullSetup)
const headerRow = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const headers = headerRow.map(h => String(h || '').toLowerCase().trim());
const row = sheet.getRange(rowIndex, 1, 1, sheet.getLastColumn()).getValues()[0];
// โโ Find columns โโ
const cols = {};
for (const [key, keywords] of Object.entries(COL_KEYWORDS)) {
cols[key] = findCol_(headers, keywords);
}
// โโ Read values โโ
const getVal = (colKey) => cols[colKey] >= 0 ? String(row[cols[colKey]] || '').trim() : '';
const getRaw = (colKey) => cols[colKey] >= 0 ? row[cols[colKey]] : null;
const name = sanitize_(getVal('NAME'));
const email = getVal('EMAIL').toLowerCase();
const phone = getVal('PHONE');
const dob = getRaw('DOB');
const pkg = getVal('PACKAGE');
const startDate = getRaw('START_DATE');
const paymentStatus = getVal('PAYMENT_STATUS').toLowerCase();
const amountPaid = parseAmount_(getRaw('AMOUNT_PAID'));
// โโ Skip if already has Student ID โโ
if (getVal('STUDENT_ID')) return;
// โโ Skip if welcome already sent โโ
if (getVal('WELCOME_SENT').toUpperCase() === 'YES') return;
const flags = [];
const tz = CONFIG.TIMEZONE;
// โโ Validate required fields โโ
if (!name) flags.push('โ ๏ธ Missing name');
if (!email) flags.push('โ ๏ธ Missing email');
else if (!isValidEmail_(email)) flags.push('โ ๏ธ Invalid email format');
if (!phone) flags.push('โ ๏ธ Missing phone');
// โโ Normalize dates โโ
if (cols.TIMESTAMP >= 0 && getRaw('TIMESTAMP')) {
const norm = normalizeDate_(getRaw('TIMESTAMP'), tz);
if (norm) setCellValue_(sheet, rowIndex, cols.TIMESTAMP, norm);
}
if (cols.DOB >= 0 && dob) {
const norm = normalizeDate_(dob, tz);
if (norm) setCellValue_(sheet, rowIndex, cols.DOB, norm);
}
if (cols.START_DATE >= 0 && startDate) {
const norm = normalizeDate_(startDate, tz);
if (norm) setCellValue_(sheet, rowIndex, cols.START_DATE, norm);
}
// โโ Normalize phone โโ
if (cols.PHONE >= 0 && phone) {
setCellValue_(sheet, rowIndex, cols.PHONE, normalizePhone_(phone));
}
// โโ Calculate age โโ
let age = null;
if (dob) {
age = calculateAge_(dob);
if (age !== null && cols.AGE >= 0) {
setCellValue_(sheet, rowIndex, cols.AGE, age);
}
if (age !== null && age < CONFIG.MIN_DRIVING_AGE) {
flags.push('๐ด Under ' + CONFIG.MIN_DRIVING_AGE + ' โ cannot drive');
} else if (age !== null && age < CONFIG.MINOR_AGE) {
flags.push('๐ก Minor (under ' + CONFIG.MINOR_AGE + ') โ needs parent/guardian');
if (!getVal('PARENT_NAME') && !getVal('PARENT_EMAIL')) {
flags.push('โ ๏ธ No parent/guardian info on file');
}
}
}
// โโ Duplicate detection โโ
const isDuplicate = checkDuplicate_(sheet, headers, rowIndex, email, name);
if (isDuplicate) {
flags.push('๐ต Possible duplicate registration');
}
// โโ Payment OK? โโ
let paymentOk = 'Pending';
if (paymentStatus === 'paid' && amountPaid > 0) {
paymentOk = 'Yes';
} else if (paymentStatus === 'paid' && amountPaid <= 0) {
paymentOk = 'Check: Paid but $0';
} else if (paymentStatus === 'unpaid' || paymentStatus === 'no') {
paymentOk = 'No';
} else if (paymentStatus === 'pending' || paymentStatus === 'partial') {
paymentOk = 'Pending';
} else if (!paymentStatus) {
paymentOk = 'Pending';
}
if (cols.PAYMENT_OK >= 0) {
setCellValue_(sheet, rowIndex, cols.PAYMENT_OK, paymentOk);
}
// โโ Generate Student ID โโ
const studentId = generateStudentId_(sheet, headers);
if (cols.STUDENT_ID >= 0) {
setCellValue_(sheet, rowIndex, cols.STUDENT_ID, studentId);
}
// โโ Set flags โโ
if (cols.FLAGS >= 0) {
setCellValue_(sheet, rowIndex, cols.FLAGS, flags.join(' | '));
}
// โโ Send welcome email โโ
let welcomeSent = false;
if (email && isValidEmail_(email) && !isDuplicate) {
const unsubVal = getVal('UNSUBSCRIBE').toLowerCase();
if (unsubVal !== 'yes' && unsubVal !== 'true') {
welcomeSent = sendWelcomeEmail_(name, email, pkg, studentId, startDate, age);
}
}
if (cols.WELCOME_SENT >= 0) {
setCellValue_(sheet, rowIndex, cols.WELCOME_SENT, welcomeSent ? 'Yes' : (isDuplicate ? 'Duplicate' : 'No'));
}
// โโ Admin notification โโ
const adminNotified = sendAdminNotification_(name, email, phone, pkg, studentId, age, flags, isDuplicate);
// โโ Log it โโ
logRegistration_(name, email, studentId, pkg, age, flags, welcomeSent, adminNotified);
Logger.log('โ
Processed: ' + name + ' (' + studentId + ')' + (flags.length ? ' โ ' + flags.join(', ') : ''));
}
/* ================================================================
STUDENT ID GENERATOR
================================================================ */
function generateStudentId_(sheet, headers) {
const idCol = findCol_(headers, COL_KEYWORDS.STUDENT_ID);
let maxNum = 0;
if (idCol >= 0 && sheet.getLastRow() > 1) {
const ids = sheet.getRange(2, idCol + 1, sheet.getLastRow() - 1, 1).getValues();
for (const row of ids) {
const match = String(row[0] || '').match(/FDS-(\d+)/);
if (match) {
const num = parseInt(match[1], 10);
if (num > maxNum) maxNum = num;
}
}
}
const next = maxNum + 1;
return 'FDS-' + String(next).padStart(4, '0');
}
/* ================================================================
DUPLICATE DETECTION
================================================================ */
function checkDuplicate_(sheet, headers, currentRow, email, name) {
if (!email && !name) return false;
const emailCol = findCol_(headers, COL_KEYWORDS.EMAIL);
const nameCol = findCol_(headers, COL_KEYWORDS.NAME);
if (emailCol < 0 && nameCol < 0) return false;
const lastRow = sheet.getLastRow();
if (lastRow <= 1) return false;
const data = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn()).getValues();
for (let r = 0; r < data.length; r++) {
const dataRowIndex = r + 2;
if (dataRowIndex === currentRow) continue;
// Email match
if (email && emailCol >= 0) {
const existingEmail = String(data[r][emailCol] || '').toLowerCase().trim();
if (existingEmail === email) return true;
}
// Fuzzy name match
if (name && nameCol >= 0) {
const existingName = String(data[r][nameCol] || '').toLowerCase().trim();
if (existingName === name.toLowerCase()) return true;
if (levenshtein_(existingName, name.toLowerCase()) <= 2) return true;
}
}
return false;
}
/* ================================================================
WELCOME EMAIL (Mission Control theme)
================================================================ */
function sendWelcomeEmail_(studentName, email, pkg, studentId, startDate, age) {
try {
const firstName = (studentName || '').split(' ')[0] || studentName;
const f = esc_(firstName);
const sch = esc_(CONFIG.SCHOOL_NAME);
const tag = esc_(CONFIG.SCHOOL_TAGLINE);
const p = esc_(pkg || 'Your selected package');
const sid = esc_(studentId);
const unsub = 'mailto:' + CONFIG.ADMIN_EMAIL + '?subject=Unsubscribe&body=Please%20remove%20' + encodeURIComponent(email) + '%20from%20emails.';
let startStr = '';
if (startDate) {
const d = startDate instanceof Date ? startDate : new Date(startDate);
if (!isNaN(d.getTime())) {
startStr = Utilities.formatDate(d, CONFIG.TIMEZONE, 'MMMM d, yyyy');
}
}
const isMinor = age !== null && age < CONFIG.MINOR_AGE;
const subject = 'Welcome to ' + CONFIG.SCHOOL_NAME + ', ' + firstName + '! ๐';
const htmlBody = '<!DOCTYPE html><html><head><meta charset="utf-8"></head>'
+ '<body style="margin:0;padding:0;background:#000;font-family:Arial,Helvetica,sans-serif;">'
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#000;"><tr><td align="center">'
+ '<table width="600" cellpadding="0" cellspacing="0" border="0">'
// Header
+ '<tr><td style="background:#0d0d0d;border-bottom:2px solid #ff2d2d;padding:30px 24px;text-align:center;">'
+ '<h1 style="color:#fff;font-size:20px;margin:0 0 4px;">' + sch + '</h1>'
+ '<p style="color:rgba(255,255,255,0.5);font-size:12px;margin:0;">' + tag + '</p>'
+ '</td></tr>'
// Body
+ '<tr><td style="background:#0d0d0d;padding:32px 24px;">'
+ '<div style="text-align:center;margin-bottom:24px;">'
+ '<div style="font-size:48px;margin-bottom:12px;">๐</div>'
+ '<div style="font-size:26px;font-weight:800;color:#fff;">Welcome, ' + f + '!</div>'
+ '<p style="font-size:14px;color:rgba(255,255,255,0.5);margin:8px 0 0;">Your journey to the open road starts here.</p>'
+ '</div>'
// Confirmation card
+ '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background:rgba(255,45,45,0.06);border:1px solid rgba(255,45,45,0.15);border-radius:12px;">'
+ '<tr><td style="padding:20px 24px;">'
+ '<div style="font-size:12px;color:#ff2d2d;font-weight:700;text-transform:uppercase;letter-spacing:2px;margin-bottom:12px;">Registration Confirmed</div>'
+ confirmRow_('Student ID', sid)
+ confirmRow_('Program', p)
+ (startStr ? confirmRow_('Start Date', esc_(startStr)) : '')
+ (age !== null ? confirmRow_('Age', esc_(String(age))) : '')
+ '</td></tr></table>'
// What to expect
+ '<div style="margin-top:24px;">'
+ '<div style="font-size:14px;font-weight:700;color:#fff;margin-bottom:12px;">๐ What\'s Next</div>'
+ '<table width="100%" cellpadding="0" cellspacing="6" border="0">'
+ stepRow_('1', 'We\'ll reach out to schedule your first lesson')
+ stepRow_('2', 'Bring your learner permit and a valid photo ID')
+ stepRow_('3', 'Arrive 10 minutes early for your first session')
+ (isMinor ? stepRow_('โ ๏ธ', 'Since you\'re under 18, a parent/guardian must sign the enrollment form') : '')
+ '</table>'
+ '</div>'
// Save your ID
+ '<div style="margin-top:24px;padding:16px;background:rgba(255,255,255,0.03);border:1px solid rgba(255,255,255,0.08);border-radius:8px;text-align:center;">'
+ '<p style="font-size:12px;color:rgba(255,255,255,0.5);margin:0;">๐ Save your Student ID: <strong style="color:#ff2d2d;font-size:14px;">' + sid + '</strong></p>'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.3);margin:4px 0 0;">Use this to check your progress in the Student Portal.</p>'
+ '</div>'
+ '</td></tr>'
// Footer
+ '<tr><td style="background:#000;border-top:1px solid rgba(255,255,255,0.05);padding:20px 24px;text-align:center;">'
+ '<p style="font-size:12px;color:rgba(255,255,255,0.3);margin:0;">Questions? Contact us at ' + esc_(CONFIG.SCHOOL_PHONE) + '</p>'
+ '<p style="font-size:11px;color:rgba(255,255,255,0.25);margin:4px 0 0;">' + sch + ' โ ' + esc_(CONFIG.SCHOOL_ADDRESS) + '</p>'
+ '<p style="font-size:10px;color:rgba(255,255,255,0.15);margin:8px 0 0;"><a href="' + unsub + '" style="color:rgba(255,255,255,0.15);text-decoration:underline;">Unsubscribe</a></p>'
+ '</td></tr>'
+ '</table></td></tr></table></body></html>';
const plainBody = 'Welcome to ' + CONFIG.SCHOOL_NAME + ', ' + firstName + '!\n\n'
+ 'Your registration is confirmed.\n'
+ 'Student ID: ' + studentId + '\n'
+ 'Program: ' + (pkg || 'TBD') + '\n'
+ (startStr ? 'Start Date: ' + startStr + '\n' : '')
+ '\nWe\'ll be in touch to schedule your first lesson.\n'
+ 'Questions? Call ' + CONFIG.SCHOOL_PHONE + '\n\n'
+ 'โ ' + CONFIG.SCHOOL_NAME;
MailApp.sendEmail({
to: email,
subject: subject,
body: plainBody,
htmlBody: htmlBody,
name: CONFIG.SCHOOL_NAME
});
return true;
} catch (e) {
Logger.log('Welcome email failed for ' + studentName + ': ' + e.message);
return false;
}
}
function confirmRow_(label, value) {
return '<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:8px;"><tr>'
+ '<td width="35%" style="font-size:12px;color:rgba(255,255,255,0.4);">' + label + '</td>'
+ '<td style="font-size:13px;color:#fff;font-weight:600;">' + value + '</td>'
+ '</tr></table>';
}
function stepRow_(num, text) {
return '<tr><td style="padding:8px 12px;background:rgba(255,255,255,0.03);border-radius:8px;">'
+ '<table cellpadding="0" cellspacing="0" border="0"><tr>'
+ '<td style="width:28px;vertical-align:top;">'
+ '<div style="width:22px;height:22px;background:#ff2d2d;border-radius:50%;text-align:center;line-height:22px;font-size:11px;color:#fff;font-weight:700;">' + num + '</div>'
+ '</td>'
+ '<td style="font-size:13px;color:rgba(255,255,255,0.6);padding-left:8px;">' + esc_(text) + '</td>'
+ '</tr></table></td></tr>';
}
/* ================================================================
ADMIN NOTIFICATION
================================================================ */
function sendAdminNotification_(name, email, phone, pkg, studentId, age, flags, isDuplicate) {
if (!CONFIG.ADMIN_EMAIL) return false;
try {
const subject = (isDuplicate ? '๐ต Duplicate? ' : '๐ ') + 'New Registration: ' + name + ' (' + studentId + ')';
const flagText = flags.length ? '\n\nโ ๏ธ Flags:\n' + flags.join('\n') : '\n\nโ
No flags.';
const body = 'New student registration:\n\n'
+ 'Name: ' + name + '\n'
+ 'Email: ' + email + '\n'
+ 'Phone: ' + phone + '\n'
+ 'Package: ' + (pkg || 'Not specified') + '\n'
+ 'Student ID: ' + studentId + '\n'
+ (age !== null ? 'Age: ' + age + '\n' : '')
+ flagText;
MailApp.sendEmail({
to: CONFIG.ADMIN_EMAIL,
subject: subject,
body: body,
name: CONFIG.SCHOOL_NAME
});
return true;
} catch (e) {
Logger.log('Admin notification failed: ' + e.message);
return false;
}
}
/* ================================================================
REGISTRATION LOG
================================================================ */
function logRegistration_(name, email, studentId, pkg, age, flags, welcomeSent, adminNotified) {
try {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
let logSheet = ss.getSheetByName('Registration Log');
if (!logSheet) return;
logSheet.appendRow([
new Date(), name, email, studentId, pkg || '',
age || '', flags.join(' | '), welcomeSent ? 'Yes' : 'No', adminNotified ? 'Yes' : 'No'
]);
} catch (e) {
Logger.log('Logging failed: ' + e.message);
}
}
/* ================================================================
HELPERS
================================================================ */
function getRegistrationSheet_() {
const ss = SpreadsheetApp.openById(CONFIG.REGISTRATION_ID);
if (CONFIG.REGISTRATION_SHEET_TAB) {
const sheet = ss.getSheetByName(CONFIG.REGISTRATION_SHEET_TAB);
if (sheet) return sheet;
}
return ss.getSheets()[0];
}
function setCellValue_(sheet, rowIndex, colIndex, value) {
sheet.getRange(rowIndex, colIndex + 1).setValue(value);
}
/** Header contains any candidate (.includes() matching). Returns 0-based index or -1. */
function findCol_(headers, candidates) {
for (let i = 0; i < headers.length; i++) {
const h = String(headers[i] || '').toLowerCase().trim();
for (const kw of candidates) {
if (kw && h.includes(String(kw).toLowerCase().trim())) return i;
}
}
return -1;
}
function normalizeDate_(value, tz) {
if (value == null || value === '') return '';
tz = tz || CONFIG.TIMEZONE;
if (value instanceof Date) {
return isNaN(value.getTime()) ? '' : Utilities.formatDate(value, tz, 'yyyy-MM-dd');
}
const s = String(value).trim();
let d = new Date(s);
if (!isNaN(d.getTime())) return Utilities.formatDate(d, tz, 'yyyy-MM-dd');
const match = s.match(/^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})/);
if (match) {
d = new Date(parseInt(match[3], 10), parseInt(match[1], 10) - 1, parseInt(match[2], 10));
if (!isNaN(d.getTime())) return Utilities.formatDate(d, tz, 'yyyy-MM-dd');
}
return '';
}
function normalizePhone_(phone) {
if (!phone) return '';
const digits = String(phone).replace(/\D/g, '');
if (digits.length === 10) return digits.slice(0, 3) + '-' + digits.slice(3, 6) + '-' + digits.slice(6);
if (digits.length === 11 && digits[0] === '1') return digits.slice(1, 4) + '-' + digits.slice(4, 7) + '-' + digits.slice(7);
return String(phone).trim();
}
function calculateAge_(dob) {
let d = dob instanceof Date ? dob : new Date(dob);
if (isNaN(d.getTime())) return null;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age >= 0 && age < 120 ? age : null;
}
function isValidEmail_(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email || ''));
}
function parseAmount_(value) {
if (value == null || value === '') return 0;
const n = parseFloat(String(value).replace(/[\$,]/g, '').trim());
return isNaN(n) ? 0 : n;
}
function sanitize_(str) {
return String(str || '').replace(/[<>{}()\[\]\\\/]/g, '').substring(0, 150).trim();
}
function esc_(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"');
}
function levenshtein_(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => i);
for (let j = 1; j <= n; j++) {
let prev = dp[0];
dp[0] = j;
for (let i = 1; i <= m; i++) {
const temp = dp[i];
dp[i] = a[i - 1] === b[j - 1] ? prev : 1 + Math.min(prev, dp[i], dp[i - 1]);
prev = temp;
}
}
return dp[m];
}
function notifyAdmin_(subject, body) {
if (!CONFIG.ADMIN_EMAIL) return;
try {
MailApp.sendEmail(CONFIG.ADMIN_EMAIL, subject, body, { name: CONFIG.SCHOOL_NAME });
} catch (_) { /* silent */ }
}
/* ================================================================
MANUAL TOOLS
================================================================ */
/** Re-process all rows without a Student ID. */
function reprocessMissing() {
if (CONFIG.DEMO_MODE) {
Logger.log('๐ญ DEMO MODE โ would reprocess all rows missing Student ID.');
return;
}
processAllRows();
}
/** Test: log demo registration. */
function testDemoRegistration() {
Logger.log('๐ญ Demo registration:');
Logger.log(' Student: ' + DEMO.newStudent.name);
Logger.log(' Email: ' + DEMO.newStudent.email);
Logger.log(' Student ID: ' + DEMO.newStudent.studentId);
Logger.log(' Package: ' + DEMO.newStudent.package);
Logger.log(' Age: ' + DEMO.newStudent.age + ' (minor โ needs parent/guardian)');
Logger.log(' Welcome email would be sent โ
');
Logger.log(' Admin notification would be sent โ
');
}