/* ============================================================ App shell — routing, state, modals, Tweaks Yardley Country Club member portal. ============================================================ */ const Y = window.YCC; const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "theme": "fairway", "fontPair": "elegant", "density": "regular", "bookingFlow": "sheet" }/*EDITMODE-END*/; const THEMES = { fairway: { label: "Fairway", dots: ["#1f3a2d", "#b4904e"], vars: { "--bg": "#eef0e6", "--surface": "#fbfaf4", "--surface-2": "#f3f2e8", "--ink": "#1b2620", "--ink-soft": "#5b6b60", "--ink-faint": "#8a978c", "--line": "#dcdccb", "--line-strong": "#c7c8b3", "--brand": "#1f3a2d", "--brand-deep": "#142519", "--brand-mid": "#2f5240", "--brand-soft": "#e4ebe2", "--accent": "#b4904e", "--accent-deep": "#8f6f37", "--on-brand": "#f4f1e6", }, }, heritage: { label: "Heritage", dots: ["#1c2c4a", "#c2a14e"], vars: { "--bg": "#ecedf2", "--surface": "#fbfbf8", "--surface-2": "#eef0f4", "--ink": "#1a2233", "--ink-soft": "#5a6378", "--ink-faint": "#8a92a3", "--line": "#dadee6", "--line-strong": "#c4c9d4", "--brand": "#1c2c4a", "--brand-deep": "#13203a", "--brand-mid": "#2c416b", "--brand-soft": "#e3e8f1", "--accent": "#c2a14e", "--accent-deep": "#9c7e34", "--on-brand": "#f2f0e6", }, }, earth: { label: "Earth", dots: ["#4a5436", "#bd7b4f"], vars: { "--bg": "#efeae0", "--surface": "#fcf9f1", "--surface-2": "#f3efe3", "--ink": "#2a2820", "--ink-soft": "#67614f", "--ink-faint": "#988f78", "--line": "#ddd6c5", "--line-strong": "#c9c0a9", "--brand": "#4a5436", "--brand-deep": "#333a23", "--brand-mid": "#5d6a45", "--brand-soft": "#e9e7d8", "--accent": "#bd7b4f", "--accent-deep": "#9a5e38", "--on-brand": "#f5f1e6", }, }, }; const FONT_PAIRS = { elegant: { display: '"Libre Baskerville", Georgia, serif', body: '"Hanken Grotesk", system-ui, sans-serif' }, editorial: { display: '"Cormorant Garamond", Georgia, serif', body: '"Hanken Grotesk", system-ui, sans-serif' }, modern: { display: '"Archivo", system-ui, sans-serif', body: '"Hanken Grotesk", system-ui, sans-serif' }, }; const NAV = [ ["dashboard", "Dashboard", "flag"], ["book", "Tee Times", "calendar"], ["bookings", "My Tee Times", "clock"], ["games", "Weekend Games", "trophy"], ["standings", "Season", "star"], ["profile", "Profile", "user"], ]; const MANAGER_NAV = [ ["manage", "Tour Setup", "shield"], ["roster", "Members", "users"], ["standings", "Season", "trophy"], ["book", "Tee Sheet", "calendar"], ["games", "Weekend Games", "trophy"], ]; // map a name to a player object function toPlayer(name, member) { if (name === member.name) return { name, initials: member.initials, hi: member.handicapIndex, ghin: member.ghin }; const r = Y.ROSTER.find(x => x.name === name); if (r) return { ...r }; const initials = name.split(" ").map(s => s[0]).join("").slice(0, 2).toUpperCase(); return { name, initials, hi: "—", ghin: "—" }; } function buildInitialTour(member) { const tour = Y.buildDivTour(member); return { tour, bookings: tour.bookings.map(b => ({ id: b.id, dateISO: b.dateISO, slotId: b.slotId })) }; } function initialsFromName(n) { return (n || "?").trim().split(/\s+/).map(s => s[0]).join("").slice(0, 2).toUpperCase(); } function mapMember(row, user) { const meta = (user && user.user_metadata) || {}; if (row) { const name = `${row.first_name || ""} ${row.last_name || ""}`.trim() || meta.full_name || row.email; return { id: row.id, first: row.first_name || "", last: row.last_name || "", name, email: row.email, ghin: row.ghin || "", handicapIndex: (row.handicap_index ?? null), memberSince: row.member_since || null, homeClub: "Yardley Country Club", tier: row.tier || "Member", tee: row.tee || "White", phone: row.phone || "", initials: initialsFromName(name), }; } const name = meta.full_name || meta.name || (user.email || "").split("@")[0]; return { id: user.id, first: (name.split(" ")[0] || ""), last: name.split(" ").slice(1).join(" "), name, email: user.email, ghin: "", handicapIndex: null, memberSince: null, homeClub: "Yardley Country Club", tier: "Member", tee: "White", phone: "", initials: initialsFromName(name), }; } function App() { // Each weekend gets its own saved state, so every Sat/Sun auto-populates from // the season defaults until a manager edits it. (Matches the existing config // value for the current weekend, so no data is lost.) if (window.YCC_CONFIG && window.YCC.SAT) window.YCC_CONFIG.weekendId = "wknd-" + window.YCC.isoDay(window.YCC.SAT); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [authReady, setAuthReady] = useState(false); const [session, setSession] = useState(null); const [view, setView] = useState("dashboard"); const [role, setRole] = useState("member"); // member | manager const [member, setMember] = useState(null); const init = useMemo(() => buildInitialTour(Y.MEMBER), []); const [tour, setTour] = useState(init.tour); const [bookings, setBookings] = useState(init.bookings); const [gameConfig, setGameConfig] = useState({ gameType: "skins", skinValue: 5, carryover: true, scoring: "gross", groupTee: "Blue", splitDays: false, gameSat: "skins", gameSun: "skins", lowMode: "day", sideGames: (window.YCC_GAMES && window.YCC_GAMES.defaultSideGames ? window.YCC_GAMES.defaultSideGames() : { sat: { ctp: true }, sun: { ctp: true }, longDriveHole: 7 }) }); const [scores, setScores] = useState({}); const [sideResults, setSideResults] = useState({}); const [modal, setModal] = useState(null); const [toastMsg, setToastMsg] = useState(""); const [mobileNav, setMobileNav] = useState(false); const [userMenu, setUserMenu] = useState(false); const [accountMembers, setAccountMembers] = useState([]); const [seasonDoc, setSeasonDoc] = useState({ weeks: {} }); const loadedRef = useRef(false); const saveTimer = useRef(null); const remoteApplyRef = useRef(false); const lastEditRef = useRef(0); const dayKeys = [ { iso: tour.satISO, label: Y.shortDate(Y.SAT) }, { iso: tour.sunISO, label: Y.shortDate(Y.SUN) }, ]; // apply theme + fonts + density useEffect(() => { const root = document.documentElement; const theme = THEMES[t.theme] || THEMES.fairway; Object.entries(theme.vars).forEach(([k, v]) => root.style.setProperty(k, v)); const fp = FONT_PAIRS[t.fontPair] || FONT_PAIRS.elegant; root.style.setProperty("--font-display", fp.display); root.style.setProperty("--font-body", fp.body); document.body.setAttribute("data-density", t.density || "regular"); }, [t.theme, t.fontPair, t.density]); // On load, restore the saved tee sheet once we know who's signed in. // (Gated on `member` so the booking pointers resolve to the right person, // and so a background restore never clobbers state before auth resolves.) useEffect(() => { const db = window.YCCdb; if (!member || loadedRef.current) return; if (!(db && db.isConfigured && db.isConfigured())) { loadedRef.current = true; return; } db.pull().then(obj => { if (obj && (obj._appState || (obj.tee_times && obj.tee_times.length))) applyImport(obj, { silent: true }); }).catch(() => {}).finally(() => { loadedRef.current = true; }); }, [member]); // Auto-save the live tee sheet on every change, so signing out and back in // restores exactly what was saved (including a day cleared to zero tee times). useEffect(() => { const db = window.YCCdb; if (!loadedRef.current || !member) return; if (remoteApplyRef.current) { remoteApplyRef.current = false; return; } // don't echo a remote update back if (!(db && db.isConfigured && db.isConfigured()) || !db.saveState) return; lastEditRef.current = Date.now(); clearTimeout(saveTimer.current); saveTimer.current = setTimeout(() => { try { db.saveState(buildExportData()._appState).catch(() => {}); } catch (e) {} }, 900); return () => clearTimeout(saveTimer.current); }, [tour.slots, tour.published, tour.managers, gameConfig, scores, sideResults, member]); // Live updates — apply another device's changes (e.g. scores entered hole-by-hole), // unless we're mid-edit locally (so we don't stomp the active typist). useEffect(() => { const db = window.YCCdb; if (!member || !db || !db.subscribeState || !db.isConfigured || !db.isConfigured()) return; let unsub = () => {}; db.subscribeState((state) => { if (!state) return; if (Date.now() - lastEditRef.current < 3000) return; remoteApplyRef.current = true; applyImport({ _appState: state }, { silent: true }); }).then((fn) => { unsub = fn || (() => {}); }); return () => unsub(); }, [member]); // Account roster — powers the manager's "Add a member" dropdown. useEffect(() => { const db = window.YCCdb; if (!member || !(db && db.getMembers)) return; db.getMembers().then(list => setAccountMembers(list || [])).catch(() => {}); }, [member]); // Season win standings doc (shared across weekends). const SEASON_ID = "season-2026"; useEffect(() => { const db = window.YCCdb; if (!member || !(db && db.getDoc && db.isConfigured && db.isConfigured())) return; db.getDoc(SEASON_ID).then(doc => { if (doc && doc.weeks) setSeasonDoc(doc); }).catch(() => {}); }, [member]); function postWeekendResults() { const wins = window.YCC_SCORING.weekendWins({ tour, scores, gameConfig }); const stats = window.YCC_SCORING.weekendStats ? window.YCC_SCORING.weekendStats({ tour, scores, sideResults, gameConfig }) : {}; const next = { ...seasonDoc, weeks: { ...(seasonDoc.weeks || {}), [tour.satISO]: wins.winsByName }, stats: { ...(seasonDoc.stats || {}), [tour.satISO]: stats }, updatedAt: new Date().toISOString() }; setSeasonDoc(next); const db = window.YCCdb; if (db && db.putDoc && db.isConfigured && db.isConfigured()) { db.putDoc(SEASON_ID, next).then((r) => toast(r && r.error ? "Saved locally, cloud sync failed" : "Weekend results posted to the season")); } else { toast("Weekend results posted"); } } function clearWeekendResults() { const weeks = { ...(seasonDoc.weeks || {}) }; delete weeks[tour.satISO]; const stats = { ...(seasonDoc.stats || {}) }; delete stats[tour.satISO]; const next = { ...seasonDoc, weeks, stats }; setSeasonDoc(next); const db = window.YCCdb; if (db && db.putDoc && db.isConfigured && db.isConfigured()) db.putDoc(SEASON_ID, next); toast("This weekend's results cleared from the season"); } // Authentication gate — require a real signed-in account. useEffect(() => { const db = window.YCCdb; if (!db || !db.isConfigured || !db.isConfigured()) { setAuthReady(true); return; } const watchdog = setTimeout(() => setAuthReady(true), 6000); // never get stuck on "Loading…" try { db.auth.onAuthChange((s) => { setSession(s || null); if (s && s.user) { // Render immediately from the account; never block on a DB call. const fallback = mapMember(null, s.user); setMember(fallback); setView(fallback.first && fallback.last && fallback.ghin ? "dashboard" : "profile"); clearTimeout(watchdog); setAuthReady(true); // Then refine from the members table if/when it answers (non-blocking). db.getMemberByEmail(s.user.email).then((row) => { if (row) { const m = mapMember(row, s.user); setMember(m); setView(m.first && m.last && m.ghin ? "dashboard" : "profile"); } }).catch(() => {}); } else { setMember(null); clearTimeout(watchdog); setAuthReady(true); } }); } catch (e) { clearTimeout(watchdog); setAuthReady(true); } return () => clearTimeout(watchdog); }, []); // Load saved managers from Supabase so their assignments + access persist across reloads. useEffect(() => { const db = window.YCCdb; if (!db || !db.isConfigured || !db.isConfigured() || !db.getManagersList) return; db.getManagersList().then((rows) => { if (!rows || !rows.length) return; setTour(prev => { const managers = prev.managers.map(m => { const row = rows.find(r => (r.role || "").toLowerCase() === (m.role || "").toLowerCase()); if (!row) return m; const name = row.name || m.name; return { ...m, name, initials: row.initials || initialsFromName(name) || m.initials, email: row.email || m.email, phone: row.phone || m.phone, available: row.available !== false, placeholder: !name, id: row.id || m.id }; }); return { ...prev, managers }; }); }).catch(() => {}); }, [session]); const memberEmail = ((member && member.email) || "").trim().toLowerCase(); const isAdmin = Y.isAdminEmail(memberEmail); const isManagerUser = isAdmin || (!!memberEmail && tour.managers.some(m => m.email && m.email.trim().toLowerCase() === memberEmail)); const entry = Y.entryWindow(Y.SAT); const phase = entry.phase; // open | managerOnly | locked const confirmedNames = useMemo(() => { const set = new Set(); (accountMembers || []).forEach(m => { if (Y.confirmedForWeek(m.handicap_confirmed_at, Y.SAT)) { const n = `${m.first_name || ""} ${m.last_name || ""}`.trim() || m.email; set.add(n); } }); return set; }, [accountMembers]); const MANAGER_VIEWS = ["manage", "roster"]; useEffect(() => { if (!isManagerUser) { if (role === "manager") setRole("member"); if (MANAGER_VIEWS.includes(view)) setView("dashboard"); } }, [isManagerUser, role, view]); async function signOut() { const db = window.YCCdb; if (db && db.isConfigured && db.isConfigured()) { try { await db.auth.signOut(); } catch (e) {} } setSession(null); setMember(null); setRole("member"); } function toast(msg) { setToastMsg(msg); } function go(v) { setView(v); setMobileNav(false); window.scrollTo({ top: 0 }); } function daysUntil(iso) { const d = new Date(iso + "T00:00:00"); const diff = Math.round((d - Y.TODAY) / 86400000); if (diff === 0) return "Today"; if (diff === 1) return "Tomorrow"; if (diff < 0) return "Past"; return `In ${diff} days`; } function switchRole(r) { setRole(r); setMobileNav(false); go(r === "manager" ? "manage" : "dashboard"); } // --- live derivations from the tour --- function slotOf(b) { return (tour.slots[b.dateISO] || []).find(s => s.id === b.slotId); } const liveBookings = bookings.filter(b => slotOf(b)); const onDutyIdx = tour.managers.findIndex(m => m.available && !m.placeholder && m.name); const activeManager = tour.managers[onDutyIdx >= 0 ? onDutyIdx : (tour.managers.findIndex(m => m.name) >= 0 ? tour.managers.findIndex(m => m.name) : 0)]; // The primary manager receives member change-request emails once sign-ups close. const primaryManager = tour.managers.find(m => (m.role || "").toLowerCase() === "primary" && m.name && m.email) || tour.managers.find(m => m.name && m.email) || activeManager; const primaryManagerEmail = (primaryManager && primaryManager.email) || (Y.PRIMARY_MANAGER && Y.PRIMARY_MANAGER.email) || ""; // --- member: claim / release / move an individual spot --- function requestClaim(dayISO, slot) { if (phase !== "open") { toast(phase === "managerOnly" ? "Sign-ups closed — ask your manager to add you." : "Groups are final for this weekend."); return; } setModal({ type: "claim", dayISO, slotId: slot.id }); } function confirmClaim(dayISO, slotId, transport = "walk") { if (phase !== "open") { setModal(null); return; } const slot = (tour.slots[dayISO] || []).find(s => s.id === slotId); if (!slot || slot.players.length >= 4) { setModal(null); return; } // One tee time per day per member — can't be in two groups the same day. const alreadyThatDay = (tour.slots[dayISO] || []).some(s => s.players.some(p => p.name === member.name)); if (alreadyThatDay) { setModal(null); toast("You already have a tee time that day — one per day."); return; } setTour(prev => { const day = prev.slots[dayISO]; const newDay = day.map(s => s.id === slotId ? { ...s, players: [...s.players, { name: member.name, initials: member.initials, hi: member.handicapIndex, ghin: member.ghin, tee: member.tee || "White", transport }] } : s); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); setBookings(prev => [...prev.filter(b => b.slotId !== slotId), { id: "bk-" + Date.now(), dateISO: dayISO, slotId }]); setModal(null); toast(`You're in — ${slot.time}, ${Y.shortDate(new Date(dayISO + "T00:00:00"))}`); } // Set a player's walk/ride preference (member sets own; manager sets anyone's). function setTransport(dayISO, slotId, name, mode) { setTour(prev => { const day = prev.slots[dayISO]; if (!day) return prev; const newDay = day.map(s => s.id === slotId ? { ...s, players: s.players.map(p => p.name === name ? { ...p, transport: mode } : p) } : s); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); } // Shared, synced group score entry (game day). Keyed by day|slot → name → 18 holes. function setScore(dayISO, slotId, name, hIdx, val) { const key = dayISO + "|" + slotId; setScores(prev => { const grp = { ...(prev[key] || {}) }; const arr = grp[name] ? [...grp[name]] : new Array(18).fill(null); arr[hIdx] = val; grp[name] = arr; return { ...prev, [key]: grp }; }); } // Shared, synced add-on game winners (Closest-to-the-Pin per par 3, Longest Drive). // Keyed by day|slot → { ctp: { [holeIdx]: name }, longDrive: name }. function setSideResult(dayISO, slotId, kind, holeIdx, name) { const key = dayISO + "|" + slotId; setSideResults(prev => { const grp = { ...(prev[key] || {}) }; if (kind === "ctp") { const ctp = { ...(grp.ctp || {}) }; if (name == null) delete ctp[holeIdx]; else ctp[holeIdx] = name; grp.ctp = ctp; } else if (kind === "longDrive") { if (name == null) delete grp.longDrive; else grp.longDrive = name; } return { ...prev, [key]: grp }; }); } function releaseSpot(booking) { if (phase !== "open") { setModal(null); toast("Sign-ups closed — ask your manager to release your spot."); return; } const dayISO = booking.dateISO; setTour(prev => { const day = prev.slots[dayISO]; if (!day) return prev; const newDay = day.map(s => s.id === booking.slotId ? { ...s, players: s.players.filter(p => p.name !== member.name) } : s); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); setBookings(prev => prev.filter(b => b.id !== booking.id)); setModal(null); toast("Spot released"); } function moveSpot(booking, newSlot) { if (phase !== "open") { setModal(null); toast("Sign-ups closed — ask your manager to move you."); return; } const dayISO = booking.dateISO; setTour(prev => { const day = prev.slots[dayISO]; const fromSlot = day.find(s => s.id === booking.slotId); const me = (fromSlot && fromSlot.players.find(p => p.name === member.name)) || { name: member.name, initials: member.initials, hi: member.handicapIndex, ghin: member.ghin, tee: member.tee || "White", transport: "walk" }; const newDay = day.map(s => { if (s.id === booking.slotId) return { ...s, players: s.players.filter(p => p.name !== member.name) }; if (s.id === newSlot.id) return { ...s, players: [...s.players, { ...me }] }; return s; }); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); setBookings(prev => prev.map(b => b.id === booking.id ? { ...b, slotId: newSlot.id } : b)); setModal(null); toast(`Moved to ${newSlot.time}`); } // --- manager: move ANY player between groups & add off-app members --- function managerMovePlayer(dayISO, fromSlotId, toSlotId, name) { setTour(prev => { const day = prev.slots[dayISO]; if (!day) return prev; const fromSlot = day.find(s => s.id === fromSlotId); const target = day.find(s => s.id === toSlotId); const player = fromSlot && fromSlot.players.find(p => p.name === name); if (!player || !target || target.players.length >= 4) return prev; const newDay = day.map(s => { if (s.id === fromSlotId) return { ...s, players: s.players.filter(p => p.name !== name) }; if (s.id === toSlotId) return { ...s, players: [...s.players, { ...player }] }; return s; }); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); // keep the member's own booking pointer in sync if the manager moved them if (member && name === member.name) { setBookings(prev => prev.map(b => (b.dateISO === dayISO && b.slotId === fromSlotId) ? { ...b, slotId: toSlotId } : b)); } const tLabel = (tour.slots[dayISO] || []).find(s => s.id === toSlotId); setModal(null); toast(`${name.split(" ")[0]} moved to ${tLabel ? tLabel.time : "new group"}`); } // Game-day drag-and-drop: swap two players between groups (A↔B). If the drop // target seat is empty, this is just a move (handled by managerMovePlayer). function managerSwapPlayers(dayISO, slotAId, nameA, slotBId, nameB) { if (slotAId === slotBId && nameA === nameB) return; setTour(prev => { const day = prev.slots[dayISO]; if (!day) return prev; const slotA = day.find(s => s.id === slotAId), slotB = day.find(s => s.id === slotBId); const pA = slotA && slotA.players.find(p => p.name === nameA); const pB = slotB && slotB.players.find(p => p.name === nameB); if (!pA || !pB) return prev; const newDay = day.map(s => { if (s.id === slotAId) return { ...s, players: s.players.map(p => p.name === nameA ? { ...pB } : p) }; if (s.id === slotBId) return { ...s, players: s.players.map(p => p.name === nameB ? { ...pA } : p) }; return s; }); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); // keep the signed-in member's booking pointer correct if they were swapped if (member && (nameA === member.name || nameB === member.name)) { const myFrom = nameA === member.name ? slotAId : slotBId; const myTo = nameA === member.name ? slotBId : slotAId; setBookings(prev => prev.map(b => (b.dateISO === dayISO && b.slotId === myFrom) ? { ...b, slotId: myTo } : b)); } toast(`Swapped ${nameA.split(" ")[0]} ↔ ${nameB.split(" ")[0]}`); } // Add a member (from the account list) or an off-app guest to a tee time. function managerAddPlayer(dayISO, slotId, info) { const name = (info.name || "").trim(); if (!name) { setModal(null); return; } const dup = (tour.slots[dayISO] || []).some(s => s.players.some(p => p.name.toLowerCase() === name.toLowerCase())); if (dup) { setModal(null); toast(`${name.split(" ")[0]} already has a tee time that day.`); return; } const initials = name.split(/\s+/).map(s => s[0]).join("").slice(0, 2).toUpperCase(); setTour(prev => { const day = prev.slots[dayISO]; if (!day) return prev; const newDay = day.map(s => { if (s.id !== slotId) return s; if (s.players.length >= 4 || s.players.some(p => p.name.toLowerCase() === name.toLowerCase())) return s; return { ...s, players: [...s.players, { name, initials, hi: (info.hi === "" || info.hi == null ? "—" : Number(info.hi)), ghin: info.ghin || "—", tee: info.tee || "White", transport: info.transport || "walk", guest: !!info.guest, memberId: info.memberId || undefined, }] }; }); return { ...prev, slots: { ...prev.slots, [dayISO]: newDay } }; }); setModal(null); toast(`${name.split(" ")[0]} added to the group`); } // --- manager: edit the weekly entry --- function addTime(dayISO, timeLabel) { setTour(prev => { const day = prev.slots[dayISO] || []; if (day.some(s => s.time === timeLabel)) return prev; const slot = { id: `${dayISO}-n${Date.now()}`, time: timeLabel, players: [] }; return { ...prev, slots: { ...prev.slots, [dayISO]: [...day, slot] } }; }); toast(`Added ${timeLabel}`); } function editTime(dayISO, slotId, newTime) { setTour(prev => { const day = prev.slots[dayISO]; return { ...prev, slots: { ...prev.slots, [dayISO]: day.map(s => s.id === slotId ? { ...s, time: newTime } : s) } }; }); toast(`Updated to ${newTime}`); } function removeTime(dayISO, slotId) { setTour(prev => { const day = prev.slots[dayISO]; return { ...prev, slots: { ...prev.slots, [dayISO]: day.filter(s => s.id !== slotId) } }; }); setBookings(prev => prev.filter(b => b.slotId !== slotId)); setModal(null); toast("Tee time removed"); } function togglePublish() { setTour(prev => ({ ...prev, published: !prev.published })); } // manager can edit ANY signup; members only act on their own (handled by claim/release/move). function removeSignup(dayISO, slotId, name) { setTour(prev => { const day = prev.slots[dayISO]; return { ...prev, slots: { ...prev.slots, [dayISO]: day.map(s => s.id === slotId ? { ...s, players: s.players.filter(p => p.name !== name) } : s) } }; }); if (name === member.name) setBookings(prev => prev.filter(b => !(b.dateISO === dayISO && b.slotId === slotId))); toast(name === member.name ? "Spot released" : `${name.split(" ")[0]} removed from group`); } function toggleManagerAvail(idx) { setTour(prev => ({ ...prev, managers: prev.managers.map((m, i) => i === idx ? { ...m, available: !m.available } : m) })); } // Admin can fill in / edit a manager (e.g. add the primary manager). function setManagerInfo(idx, patch) { let saved = null; setTour(prev => { const managers = prev.managers.map((m, i) => { if (i !== idx) return m; const next = { ...m, ...patch }; if (patch.name !== undefined) { next.initials = initialsFromName(patch.name); next.placeholder = !patch.name; } saved = next; return next; }); return { ...prev, managers }; }); // Persist to Supabase so the assignment (and their manager access) survives reloads. const db = window.YCCdb; if (saved && db && db.isConfigured && db.isConfigured() && db.upsertManagerRow) { db.upsertManagerRow({ id: saved.id || ("mgr-" + saved.role.toLowerCase()), name: saved.name || "", initials: saved.initials || "", role: (saved.role || "Primary").toLowerCase(), title: saved.title || "", email: (saved.email || "").toLowerCase(), phone: saved.phone || "", available: saved.available !== false, }).then((res) => { if (res && res.error) toast("Manager saved locally, cloud sync failed: " + (res.error.message || res.error)); else toast("Manager saved"); }); } else { toast("Manager updated"); } } // --- JSON export / import + Supabase sync --- function initialsOf(name) { return name.split(" ").map(s => s[0]).join("").slice(0, 2).toUpperCase(); } function to24(label) { const t = Y.parseTime(label); return `${String(Math.floor(t / 60)).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`; } function to12(hhmm) { const [h, m] = hhmm.split(":").map(Number); return Y.timeLabel(h, m); } function buildExportData() { const idByName = { [Y.MEMBER.name]: "m1" }; Y.ROSTER.forEach((r, i) => { idByName[r.name] = "m" + (i + 2); }); const members = [ { id: "m1", first_name: Y.MEMBER.first, last_name: Y.MEMBER.last, email: Y.MEMBER.email, ghin: Y.MEMBER.ghin, handicap_index: Y.MEMBER.handicapIndex }, ...Y.ROSTER.map((r, i) => ({ id: "m" + (i + 2), first_name: r.name.split(" ")[0], last_name: r.name.split(" ").slice(1).join(" "), ghin: r.ghin, handicap_index: r.hi })), ]; const tee_times = [], signups = []; let su = 1; [tour.satISO, tour.sunISO].forEach(dayISO => { Y.sortByTime(tour.slots[dayISO] || []).forEach(s => { tee_times.push({ id: s.id, play_date: dayISO, tee_time: to24(s.time), max_players: 4 }); s.players.forEach((p, idx) => signups.push({ id: "su" + (su++), tee_time_id: s.id, member_id: p.memberId || idByName[p.name] || null, member_name: p.name, ghin: p.ghin, handicap_index: p.hi, transport: p.transport || "walk", guest: !!p.guest, position: idx + 1 })); }); }); return { _about: "Yardley Country Club — DIV Tour export. Re-importable here; tables match the Supabase schema in supabase/divtour-schema.sql.", exportedAt: new Date().toISOString(), club: { name: "Yardley Country Club", program: "DIV Tour", course_rating: Y.COURSE.rating, slope: Y.COURSE.slope, par: Y.COURSE.par, tee: Y.COURSE.tee }, weekend: { label: tour.weekendLabel, sat_date: tour.satISO, sun_date: tour.sunISO, published: tour.published }, managers: tour.managers, members, course_holes: Y.HOLES.map(h => ({ hole: h.n, par: h.par, yards: h.yards, stroke_index: h.si })), tee_times, signups, game: gameConfig, _appState: { slots: tour.slots, published: tour.published, managers: tour.managers, gameConfig, scores, sideResults }, }; } function exportTourJSON() { const text = JSON.stringify(buildExportData(), null, 2); const blob = new Blob([text], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `divtour-${tour.satISO}.json`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); toast("Tour exported as JSON"); } function applyImport(obj, opts = {}) { const silent = !!opts.silent; let newSlots; if (obj._appState && obj._appState.slots) { newSlots = obj._appState.slots; setTour(prev => ({ ...prev, slots: newSlots, published: !!obj._appState.published, managers: obj._appState.managers || prev.managers })); if (obj._appState.gameConfig) setGameConfig(obj._appState.gameConfig); if (obj._appState.scores) setScores(obj._appState.scores); if (obj._appState.sideResults) setSideResults(obj._appState.sideResults); } else if (obj.tee_times) { const memById = {}; (obj.members || []).forEach(m => { const name = m.name || `${m.first_name || ""} ${m.last_name || ""}`.trim(); memById[m.id] = { name, initials: initialsOf(name), hi: m.handicap_index, ghin: m.ghin }; }); newSlots = { [tour.satISO]: [], [tour.sunISO]: [] }; obj.tee_times.forEach(tt => { const dayISO = tt.play_date; if (!newSlots[dayISO]) newSlots[dayISO] = []; const players = (obj.signups || []) .filter(s => s.tee_time_id === tt.id) .sort((a, b) => (a.position || 0) - (b.position || 0)) .map(s => { const base = memById[s.member_id] || { name: s.member_name, initials: initialsOf(s.member_name || "?"), hi: s.handicap_index, ghin: s.ghin }; return { ...base, transport: s.transport || base.transport || "walk", guest: s.guest != null ? !!s.guest : !!base.guest }; }); newSlots[dayISO].push({ id: tt.id, time: to12(tt.tee_time), players }); }); setTour(prev => ({ ...prev, slots: newSlots, published: obj.weekend ? !!obj.weekend.published : true })); if (obj.game) setGameConfig(obj.game); } else { if (!silent) toast("Unrecognized file format"); return; } // recompute the member's claimed spots from the imported slots const meName = member && member.name; const mine = []; Object.entries(newSlots).forEach(([dayISO, slots]) => { (slots || []).forEach(s => { if (meName && s.players.some(p => p.name === meName)) mine.push({ id: "bk-" + dayISO + "-" + s.id, dateISO: dayISO, slotId: s.id }); }); }); setBookings(mine); if (!silent) toast("Tour data imported"); } function importTourJSON(file) { if (!file) return; const reader = new FileReader(); reader.onload = () => { try { applyImport(JSON.parse(reader.result)); } catch (e) { toast("Couldn't read that JSON file"); } }; reader.readAsText(file); } const supabaseReady = !!(window.YCCdb && window.YCCdb.isConfigured && window.YCCdb.isConfigured()); function pushSupabase() { if (!supabaseReady) { toast("Add Supabase keys in config.js first"); return; } toast("Pushing to Supabase…"); window.YCCdb.push(buildExportData()).then(() => toast("Synced to Supabase")).catch(() => toast("Supabase push failed")); } function pullSupabase() { if (!supabaseReady) { toast("Add Supabase keys in config.js first"); return; } toast("Pulling from Supabase…"); window.YCCdb.pull().then(obj => obj && applyImport(obj)).catch(() => toast("Supabase pull failed")); } function saveMember(next) { setMember(next); const db = window.YCCdb; if (db && db.isConfigured && db.isConfigured() && next && next.email) { return db.upsertMember({ id: next.id || next.email, email: next.email, first_name: next.first || "", last_name: next.last || "", ghin: next.ghin || null, handicap_index: (next.handicapIndex === "" ? null : next.handicapIndex), phone: next.phone || null, tee: next.tee || null, tier: next.tier || "Member", }).then((res) => { if (res && res.error) { const msg = res.error.message || String(res.error); toast("Cloud save failed: " + msg); return { ok: false, error: msg }; } toast("Profile saved"); return { ok: true }; }).catch((e) => { const msg = (e && e.message) || "network error"; toast("Cloud save error: " + msg); return { ok: false, error: msg }; }); } return Promise.resolve({ ok: true, local: true }); } const ctx = { member, setMember: saveMember, tour, setTour, bookings: liveBookings, slotOf, dayKeys, go, daysUntil, toast, role, t, activeManager, gameConfig, setGameConfig, scores, setScore, sideResults, setSideResult, primaryManager, primaryManagerEmail, requestManagerEmail: () => setModal({ type: "emailManager" }), phase, entry, confirmedNames, requestClaim, setTransport, requestEdit: (b) => setModal({ type: "move", booking: b }), requestCancel: (b) => setModal({ type: "release", booking: b }), // manager addTime, editTime, toggleManagerAvail, removeSignup, setManagerInfo, isAdmin, isManagerUser, managerMovePlayer, managerAddPlayer, managerSwapPlayers, accountMembers, seasonDoc, postWeekendResults, clearWeekendResults, requestManagerMove: (dayISO, slot, player) => setModal({ type: "managerMove", dayISO, slotId: slot.id, player }), requestAddPlayer: (dayISO, slot) => setModal({ type: "addPlayer", dayISO, slotId: slot.id }), requestRemoveTime: (dayISO, slot) => setModal({ type: "removeTime", dayISO, slot }), togglePublish, exportTourJSON, importTourJSON, pushSupabase, pullSupabase, supabaseReady, }; if (!authReady) { return (