// ============================================================ // RAGU PHOTOGRAPHY — Cloudflare Worker // Reads Google Sheet → calculates metrics → serves JSON API // ============================================================ const SHEET_ID = '1ZJXklT-rZNKG5_FbiYbRDgAOMlrDtCJNeez2lsVFfbU'; const API_KEY = 'AIzaSyAbgGPk-ZKgPvIZoAUQWjAGtlpMfP3Le3U'; const SHEET_TAB = 'Customers'; const EVENTS_TAB = 'Events'; // Column indices (0-based) — must match setup_sheet.gs const COL = { EVENT_ID: 0, // A SEASON: 1, // B EVENT_NAME: 2, // C EVENT_DATE: 3, // D ATHLETE_ID_GLOBAL: 4, // E ATHLETE_ID_EVENT: 5, // F ATHLETE_NAME: 6, // G AGE_GROUP: 7, // H SIGNUP_DATE: 8, // I SIGNUP_TYPE: 9, // J PARENT1_NAME: 10, // K PARENT1_EMAIL: 11, // L ← identity key PARENT1_PHONE: 12, // M PARENT2_NAME: 13, // N PARENT2_EMAIL: 14, // O PARENT2_PHONE: 15, // P SHIPPING_ADDRESS: 16, // Q NOTES: 17, // R PREEVENT_CONTACT: 18, // S PREEVENT_INTENT: 19, // T DATE_PHOTOGRAPHED: 20, // U FAMILY_FILM: 21, // V FILM_PEOPLE: 22, // W FIRST_SESSION: 23, // X NUM_SESSIONS: 24, // Y CUSTOMER_STATUS: 25, // Z INTERNAL_STATUS: 26, // AA WALL_ART: 27, // AB BOOK: 28, // AC MATTED_PRINT: 29, // AD CUSTOM: 30, // AE TOTAL_SPEND: 31, // AF ARCHIVAL_PLAN: 32, // AG FUTURE_ORDER: 33, // AH CIRCLE_BACK_DATE: 34, // AI CIRCLE_BACK_DAYS: 35, // AJ TESTIMONIAL: 36, // AK // Calculated cols (37-42) not parsed — computed in Worker instead }; export default { async fetch(request) { const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type', 'Content-Type': 'application/json', }; if (request.method === 'OPTIONS') { return new Response(null, { headers: corsHeaders }); } try { const [rows, eventRows] = await Promise.all([ fetchSheet(SHEET_TAB), fetchSheet(EVENTS_TAB), ]); const data = parseCustomers(rows); const events = parseEvents(eventRows); const url = new URL(request.url); const selectedEvents = url.searchParams.get('events') ? url.searchParams.get('events').split(',').map(e => e.trim()) : null; const filtered = selectedEvents ? data.filter(r => selectedEvents.includes(r.eventName)) : data; const metrics = { summary: calcSummary(filtered), funnel: calcFunnel(filtered), aov: calcAOV(filtered), byEvent: calcByEvent(data), trends: calcTrends(data, events), filmImpact: calcFilmImpact(filtered), preEventImpact: calcPreEventImpact(filtered), filmSizeImpact: calcFilmSizeImpact(filtered), signupTypeImpact: calcSignupTypeImpact(filtered), athleteRetention: calcAthleteRetention(data), seasonTrends: calcSeasonTrends(data), archival: calcArchival(filtered), products: calcProducts(filtered), circleBack: calcCircleBack(filtered), events: events, allEventNames: [...new Set(data.map(r => r.eventName).filter(Boolean))].sort(), allSeasons: [...new Set(data.map(r => r.season).filter(Boolean))].sort(), generatedAt: new Date().toISOString(), }; return new Response(JSON.stringify(metrics), { headers: corsHeaders }); } catch (err) { return new Response(JSON.stringify({ error: err.message, stack: err.stack }), { status: 500, headers: corsHeaders, }); } } }; // ───────────────────────────────────────────── // FETCH // ───────────────────────────────────────────── async function fetchSheet(tabName) { const range = encodeURIComponent(`${tabName}!A1:AQ10000`); const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}/values/${range}?key=${API_KEY}`; const res = await fetch(url); if (!res.ok) throw new Error(`Sheets API ${res.status}: ${await res.text()}`); const json = await res.json(); return json.values || []; } // ───────────────────────────────────────────── // PARSE // ───────────────────────────────────────────── function parseCustomers(rows) { if (rows.length < 2) return []; return rows.slice(1).map(row => { const g = (i) => (row[i] || '').toString().trim(); const n = (i) => parseFloat(g(i).replace(/[^0-9.-]/g, '')) || 0; return { eventId: g(COL.EVENT_ID), season: g(COL.SEASON), eventName: g(COL.EVENT_NAME), eventDate: g(COL.EVENT_DATE), athleteIdGlobal: g(COL.ATHLETE_ID_GLOBAL), athleteIdEvent: g(COL.ATHLETE_ID_EVENT), athleteName: g(COL.ATHLETE_NAME), ageGroup: g(COL.AGE_GROUP), signupDate: g(COL.SIGNUP_DATE), signupType: g(COL.SIGNUP_TYPE), parent1Name: g(COL.PARENT1_NAME), parent1Email: g(COL.PARENT1_EMAIL), parent1Phone: g(COL.PARENT1_PHONE), parent2Name: g(COL.PARENT2_NAME), parent2Email: g(COL.PARENT2_EMAIL), preEventContact: g(COL.PREEVENT_CONTACT), preEventIntent: g(COL.PREEVENT_INTENT), datePhotographed: g(COL.DATE_PHOTOGRAPHED), familyFilm: g(COL.FAMILY_FILM), filmPeople: n(COL.FILM_PEOPLE), firstSession: g(COL.FIRST_SESSION), numSessions: n(COL.NUM_SESSIONS), customerStatus: g(COL.CUSTOMER_STATUS), internalStatus: g(COL.INTERNAL_STATUS), wallArt: g(COL.WALL_ART), book: g(COL.BOOK), mattedPrint: g(COL.MATTED_PRINT), custom: g(COL.CUSTOM), totalSpend: n(COL.TOTAL_SPEND), archivalPlan: g(COL.ARCHIVAL_PLAN), futureOrder: g(COL.FUTURE_ORDER), circleBackDate: g(COL.CIRCLE_BACK_DATE), circleBackDays: n(COL.CIRCLE_BACK_DAYS), testimonial: g(COL.TESTIMONIAL), // Computed identity key athleteKey: `${g(COL.ATHLETE_NAME)}|${g(COL.PARENT1_EMAIL)}`, }; }).filter(r => r.eventName && r.athleteName); } function parseEvents(rows) { if (rows.length < 2) return []; return rows.slice(1).map(row => ({ id: (row[0] || '').trim(), season: (row[1] || '').trim(), name: (row[2] || '').trim(), date: (row[3] || '').trim(), location: (row[4] || '').trim(), sport: (row[5] || '').trim(), athletes: parseInt(row[6] || '0') || 0, })).filter(e => e.name); } // ───────────────────────────────────────────── // STAGE HELPERS // ───────────────────────────────────────────── const isBooked = r => r.customerStatus === 'Booked'; const isCustomer = r => ['Session Complete - Order Placed','Delivered'].includes(r.customerStatus); const isLost = r => ['Session Complete - Lost','Lost - Expressed Not Interested'].includes(r.customerStatus); // ───────────────────────────────────────────── // METRICS // ───────────────────────────────────────────── function calcSummary(rows) { const prospects = rows.length; const booked = rows.filter(r => isBooked(r) || isCustomer(r)).length; const customers = rows.filter(r => isCustomer(r)).length; const totalRev = rows.reduce((s, r) => s + r.totalSpend, 0); // Unique athletes by key const uniqueAthletes = new Set(rows.map(r => r.athleteKey)).size; return { prospects, booked, customers, uniqueAthletes, totalRevenue: totalRev, aovPerCustomer: customers > 0 ? totalRev / customers : 0, aovPerBooked: booked > 0 ? totalRev / booked : 0, aovPerProspect: prospects > 0 ? totalRev / prospects : 0, }; } function calcFunnel(rows) { const prospects = rows.length; const booked = rows.filter(r => isBooked(r) || isCustomer(r)).length; const customers = rows.filter(r => isCustomer(r)).length; const delivered = rows.filter(r => r.customerStatus === 'Delivered').length; const lost = rows.filter(r => isLost(r)).length; return { prospects, booked, customers, delivered, lost, prospectToBooked: prospects > 0 ? booked / prospects * 100 : 0, bookedToCustomer: booked > 0 ? customers / booked * 100 : 0, prospectToCustomer: prospects > 0 ? customers / prospects * 100 : 0, }; } function calcAOV(rows) { const byStage = (filterFn) => { const subset = rows.filter(filterFn); const total = subset.reduce((s, r) => s + r.totalSpend, 0); return { count: subset.length, total, aov: subset.length > 0 ? total / subset.length : 0 }; }; return { asProspect: byStage(() => true), asBooked: byStage(r => isBooked(r) || isCustomer(r)), asCustomer: byStage(r => isCustomer(r)), }; } function calcByEvent(allRows) { const names = [...new Set(allRows.map(r => r.eventName).filter(Boolean))]; return names.map(eventName => { const rows = allRows.filter(r => r.eventName === eventName); const s = calcSummary(rows); return { eventName, season: rows[0]?.season || '', eventDate:rows[0]?.eventDate || '', ...s, funnel: calcFunnel(rows), }; }).sort((a, b) => { if (a.eventDate && b.eventDate) return new Date(a.eventDate) - new Date(b.eventDate); return a.eventName.localeCompare(b.eventName); }); } function calcTrends(allRows, events) { return calcByEvent(allRows); } function calcAthleteRetention(allRows) { // Build a map: athleteKey → sorted list of events attended const athleteMap = {}; allRows.forEach(r => { if (!athleteMap[r.athleteKey]) { athleteMap[r.athleteKey] = { athleteName: r.athleteName, email: r.parent1Email, events: [], totalSpend: 0, isCustomer: false, }; } athleteMap[r.athleteKey].events.push(r.eventName); athleteMap[r.athleteKey].totalSpend += r.totalSpend; if (isCustomer(r)) athleteMap[r.athleteKey].isCustomer = true; }); const athletes = Object.values(athleteMap); const total = athletes.length; const returning = athletes.filter(a => a.events.length > 1).length; const newAthletes = total - returning; const repeatBuyers = athletes.filter(a => a.isCustomer && a.events.length > 1).length; // Top returning athletes by lifetime spend const topReturning = athletes .filter(a => a.events.length > 1) .sort((a, b) => b.totalSpend - a.totalSpend) .slice(0, 10) .map(a => ({ athleteName: a.athleteName, eventCount: a.events.length, totalSpend: a.totalSpend, })); // Per-event: how many athletes were new vs returning const eventNames = [...new Set(allRows.map(r => r.eventName))]; const seenBefore = new Set(); const perEvent = []; // Process events in chronological order const sortedEvents = eventNames.slice().sort((a, b) => { const da = allRows.find(r => r.eventName === a)?.eventDate || ''; const db = allRows.find(r => r.eventName === b)?.eventDate || ''; return da.localeCompare(db); }); sortedEvents.forEach(eventName => { const eventRows = allRows.filter(r => r.eventName === eventName); const newCount = eventRows.filter(r => !seenBefore.has(r.athleteKey)).length; const returnCount = eventRows.filter(r => seenBefore.has(r.athleteKey)).length; perEvent.push({ eventName, newAthletes: newCount, returningAthletes: returnCount, total: eventRows.length }); eventRows.forEach(r => seenBefore.add(r.athleteKey)); }); return { totalUniqueAthletes: total, returning, newAthletes, returningPct: total > 0 ? returning / total * 100 : 0, newPct: total > 0 ? newAthletes / total * 100 : 0, repeatBuyers, topReturning, perEvent, }; } function calcSeasonTrends(allRows) { const seasons = [...new Set(allRows.map(r => r.season).filter(Boolean))].sort(); return seasons.map(season => { const rows = allRows.filter(r => r.season === season); const customers = rows.filter(r => isCustomer(r)); const totalRev = customers.reduce((s, r) => s + r.totalSpend, 0); const events = [...new Set(rows.map(r => r.eventName))]; return { season, events: events.length, prospects: rows.length, customers: customers.length, totalRevenue: totalRev, aov: customers.length > 0 ? totalRev / customers.length : 0, convRate: rows.length > 0 ? customers.length / rows.length * 100 : 0, }; }); } function calcFilmImpact(rows) { const withFilm = rows.filter(r => r.familyFilm === 'Yes'); const withoutFilm = rows.filter(r => r.familyFilm !== 'Yes'); const custWith = withFilm.filter(r => isCustomer(r)); const custWithout = withoutFilm.filter(r => isCustomer(r)); const aov = arr => arr.length > 0 ? arr.reduce((s,r) => s+r.totalSpend, 0) / arr.length : 0; return { withFilm: { prospects: withFilm.length, customers: custWith.length, aov: aov(custWith), conversionRate: withFilm.length > 0 ? custWith.length / withFilm.length * 100 : 0, }, withoutFilm: { prospects: withoutFilm.length, customers: custWithout.length, aov: aov(custWithout), conversionRate: withoutFilm.length > 0 ? custWithout.length / withoutFilm.length * 100 : 0, }, filmLift: aov(custWith) - aov(custWithout), }; } function calcPreEventImpact(rows) { const pre = rows.filter(r => r.signupType === 'Pre-Event'); const dayOf = rows.filter(r => r.signupType !== 'Pre-Event'); const preCust = pre.filter(r => isCustomer(r)); const dayCust = dayOf.filter(r => isCustomer(r)); const aov = arr => arr.length > 0 ? arr.reduce((s,r) => s+r.totalSpend, 0) / arr.length : 0; return { preEvent: { total: pre.length, customers: preCust.length, aov: aov(preCust), conversionRate: pre.length > 0 ? preCust.length / pre.length * 100 : 0, contactBreakdown: { no: pre.filter(r => r.preEventContact === 'No').length, text: pre.filter(r => r.preEventContact === 'Text').length, call: pre.filter(r => r.preEventContact === 'Call').length, video: pre.filter(r => r.preEventContact === 'Video').length, }, intentBreakdown: { digital: pre.filter(r => r.preEventIntent === 'Digital').length, print: pre.filter(r => r.preEventIntent === 'Print').length, }, }, dayOf: { total: dayOf.length, customers: dayCust.length, aov: aov(dayCust), conversionRate: dayOf.length > 0 ? dayCust.length / dayOf.length * 100 : 0, }, preEventLift: aov(preCust) - aov(dayCust), }; } function calcFilmSizeImpact(rows) { const buckets = { '1':[], '2':[], '3':[], '4':[], '5+':[] }; rows.filter(r => r.familyFilm === 'Yes' && isCustomer(r)).forEach(r => { const n = r.filmPeople; if (n <= 0) return; const key = n >= 5 ? '5+' : String(n); buckets[key].push(r); }); return Object.entries(buckets).map(([size, arr]) => ({ size, customers: arr.length, aov: arr.length > 0 ? arr.reduce((s,r) => s+r.totalSpend, 0) / arr.length : 0, totalRevenue: arr.reduce((s,r) => s+r.totalSpend, 0), })); } function calcSignupTypeImpact(rows) { return ['Pre-Event','Clinic','Day 1','Day 2'].map(type => { const subset = rows.filter(r => r.signupType === type); const customers = subset.filter(r => isCustomer(r)); const aov = customers.length > 0 ? customers.reduce((s,r) => s+r.totalSpend, 0) / customers.length : 0; return { type, total: subset.length, customers: customers.length, aov, conversionRate: subset.length > 0 ? customers.length / subset.length * 100 : 0, }; }); } function calcArchival(rows) { return ['None','1 Month','6 Months','12 Months','60 Months','120 Months'].map(plan => ({ plan, count: rows.filter(r => r.archivalPlan === plan).length, totalRevenue: rows.filter(r => r.archivalPlan === plan).reduce((s,r) => s+r.totalSpend, 0), })); } function calcProducts(rows) { const customers = rows.filter(r => isCustomer(r)); return { wallArt: customers.filter(r => r.wallArt).length, book: customers.filter(r => r.book).length, mattedPrint: customers.filter(r => r.mattedPrint).length, custom: customers.filter(r => r.custom).length, total: customers.length, }; } function calcCircleBack(rows) { const today = new Date(); const upcoming = rows .filter(r => r.circleBackDate && new Date(r.circleBackDate) >= today) .sort((a,b) => new Date(a.circleBackDate) - new Date(b.circleBackDate)) .slice(0, 20) .map(r => ({ athleteName: r.athleteName, eventName: r.eventName, circleBackDate: r.circleBackDate, customerStatus: r.customerStatus })); const overdue = rows .filter(r => r.circleBackDate && new Date(r.circleBackDate) < today) .map(r => ({ athleteName: r.athleteName, eventName: r.eventName, circleBackDate: r.circleBackDate, customerStatus: r.customerStatus })); return { upcoming, overdue, overdueCount: overdue.length }; }