/* ============================================================ Open Group — ad-hoc games set up on the fly. A member picks how many foursomes, drags players (drawn from the member list + off-app guests) within/between groups, chooses the game + add-ons, and begins. Each foursome opens the SAME scorecard used for weekend play (dots, CTP, longest drive). Open-group data is stored separately from the weekend sheet and feeds the Season tab under its own toggle. ============================================================ */ const OG_LETTERS = "ABCDEFGH"; function ogInitials(n) { return (n || "?").trim().split(/\s+/).map((s) => s[0]).join("").slice(0, 2).toUpperCase(); } function ogStakeLabel(id) { return ({ skins: "Skin value", stableford: "Ante (each)", nassau: "Bet / match", "split-sixes": "Point value", wolf: "Point value", "bbb": "Point value" })[id] || "Stake / point ($)"; } // Build the selectable player pool from account members + the signed-in member. function ogCandidates(accountMembers, member) { const list = [], seen = new Set(); const push = (o) => { const key = (o.email || o.name || "").toLowerCase(); if (!o.name || seen.has(key)) return; seen.add(key); list.push(o); }; (accountMembers || []).forEach((m) => { const name = `${m.first_name || ""} ${m.last_name || ""}`.trim() || m.email; push({ name, initials: ogInitials(name), hi: (m.handicap_index != null ? m.handicap_index : "—"), ghin: m.ghin || "—", memberId: m.id, tee: m.tee || "White", email: m.email }); }); if (member && member.name) push({ name: member.name, initials: member.initials || ogInitials(member.name), hi: (member.handicapIndex != null ? member.handicapIndex : "—"), ghin: member.ghin || "—", memberId: member.id, tee: member.tee || "White", email: member.email }); return list.sort((a, b) => a.name.localeCompare(b.name)); } /* -------------------- SETUP OVERLAY -------------------- */ function OpenGroupSetup({ ctx, existing, onClose, onDone }) { const Y = window.YCC, G = window.YCC_GAMES; const { accountMembers, member, toast } = ctx; const candidates = useMemo(() => ogCandidates(accountMembers, member), [accountMembers, member]); const candByName = (n) => candidates.find((c) => c.name === n) || { name: n, initials: ogInitials(n), hi: "—", ghin: "—" }; const todayISO = Y.isoDay(Y.TODAY); const editing = !!existing; const [step, setStep] = useState(0); // 0 groups · 1 lineup · 2 game const [count, setCount] = useState(() => (existing ? Math.max(1, (existing.foursomes || []).length) : 1)); const [playNow, setPlayNow] = useState(() => (existing ? (existing.playNow !== false) : true)); const [dateISO, setDateISO] = useState(() => (existing ? existing.dateISO : todayISO)); // foursomes: [{id, label, players:[{name,initials,hi,ghin,tee,guest,memberId}]}] const seed = () => { if (existing) return (existing.foursomes || []).map((f, i) => ({ id: f.id, label: f.label || ("Group " + OG_LETTERS[i]), players: (f.players || []).map((p) => ({ ...p })) })); const arr = []; for (let i = 0; i < count; i++) arr.push({ id: "f" + i, label: "Group " + OG_LETTERS[i], players: [] }); // default the creator into Group A const me = candidates.find((c) => member && c.name === member.name); if (me) arr[0].players.push({ ...me }); return arr; }; const [foursomes, setFoursomes] = useState(seed); // keep foursome count in sync when the stepper changes (step 0 only) function applyCount(n) { n = Math.max(1, Math.min(8, n)); setCount(n); setFoursomes((prev) => { const next = prev.slice(0, n).map((f, i) => ({ ...f, label: f.label || ("Group " + OG_LETTERS[i]) })); while (next.length < n) next.push({ id: "f" + next.length + "_" + Date.now(), label: "Group " + OG_LETTERS[next.length], players: [] }); return next; }); } // ---- game config ---- const cfg0 = (existing && existing.config) || {}; const [gameType, setGameType] = useState(cfg0.gameType || "skins"); const [groupTee, setGroupTee] = useState(cfg0.groupTee || "White"); const [scoring, setScoring] = useState(cfg0.scoring || "gross"); const [carryover, setCarryover] = useState(cfg0.carryover !== false); const [stake, setStake] = useState(cfg0.skinValue != null ? cfg0.skinValue : 5); const sg0 = (cfg0.sideGames && (cfg0.sideGames.sat || cfg0.sideGames.sun)) || {}; const [ctp, setCtp] = useState(sg0.ctp !== undefined ? !!sg0.ctp : true); const parThrees = G.parThreeHoles(); const [ctpHoles, setCtpHoles] = useState(() => Array.isArray(sg0.ctpHoles) ? sg0.ctpHoles.slice() : parThrees.slice()); const toggleCtpHole = (n) => setCtpHoles((prev) => prev.includes(n) ? prev.filter((x) => x !== n) : [...prev, n].sort((a, b) => a - b)); const [longDrive, setLongDrive] = useState(sg0.longDrive !== undefined ? !!sg0.longDrive : true); const [ldHole, setLdHole] = useState(sg0.longDriveHole || 7); const [lowMode, setLowMode] = useState(cfg0.lowMode || "day"); const [rulesOpen, setRulesOpen] = useState(null); const placedNames = new Set(); foursomes.forEach((f) => f.players.forEach((p) => placedNames.add(p.name))); const available = candidates.filter((c) => !placedNames.has(c.name)); const totalPlayers = placedNames.size; // ---- drag & drop ---- const drag = useRef(null); // { fromFid|null, name } const [dragName, setDragName] = useState(null); const [overFid, setOverFid] = useState(null); function startDrag(fromFid, name) { drag.current = { fromFid, name }; setDragName(name); } function endDrag() { drag.current = null; setDragName(null); setOverFid(null); } function dropInto(toFid, beforeName) { const d = drag.current; endDrag(); if (!d) return; setFoursomes((prev) => { const next = prev.map((f) => ({ ...f, players: [...f.players] })); let pObj; if (d.fromFid == null) pObj = { ...candByName(d.name) }; else { const ff = next.find((f) => f.id === d.fromFid); const idx = ff.players.findIndex((p) => p.name === d.name); if (idx < 0) return prev; pObj = ff.players[idx]; } const target = next.find((f) => f.id === toFid); if (!target) return prev; if (d.fromFid !== toFid && target.players.length >= 4) { toast && toast("That group is full (4 max)"); return prev; } if (d.fromFid != null) { const ff = next.find((f) => f.id === d.fromFid); ff.players = ff.players.filter((p) => p.name !== d.name); } let insertIdx = target.players.length; if (beforeName) { const bi = target.players.findIndex((p) => p.name === beforeName); if (bi >= 0) insertIdx = bi; } target.players.splice(insertIdx, 0, pObj); return next; }); } function dropToPool() { const d = drag.current; endDrag(); if (!d || d.fromFid == null) return; setFoursomes((prev) => prev.map((f) => f.id === d.fromFid ? { ...f, players: f.players.filter((p) => p.name !== d.name) } : f)); } function addToFirstOpen(cand) { setFoursomes((prev) => { const next = prev.map((f) => ({ ...f, players: [...f.players] })); const target = next.find((f) => f.players.length < 4); if (!target) { toast && toast("All groups are full"); return prev; } target.players.push({ ...cand }); return next; }); } function removePlayer(fid, name) { setFoursomes((prev) => prev.map((f) => f.id === fid ? { ...f, players: f.players.filter((p) => p.name !== name) } : f)); } // ---- guest add ---- const [guestOpen, setGuestOpen] = useState(false); const [guestName, setGuestName] = useState(""); const [guestHi, setGuestHi] = useState(""); const [guestFid, setGuestFid] = useState(null); function addGuest() { const name = guestName.trim(); if (!name) return; if (placedNames.has(name) || candidates.some((c) => c.name === name)) { toast && toast("That name is already in the list"); return; } const fid = guestFid || (foursomes.find((f) => f.players.length < 4) || foursomes[0]).id; setFoursomes((prev) => prev.map((f) => f.id === fid ? (f.players.length >= 4 ? f : { ...f, players: [...f.players, { name, initials: ogInitials(name), hi: (guestHi === "" ? "—" : Number(guestHi)), ghin: "—", tee: groupTee, guest: true }] }) : f)); setGuestName(""); setGuestHi(""); setGuestOpen(false); } const game = G.gameById(gameType); function begin() { const live = foursomes.filter((f) => f.players.length > 0).map((f, i) => ({ id: f.id, label: f.label || ("Group " + OG_LETTERS[i]), players: f.players })); if (!live.length) { toast && toast("Add at least one player to a group"); return; } const sg = { ctp, longDrive, ctpHoles: ctpHoles.slice(), longDriveHole: Number(ldHole) || 7 }; const config = { gameType, groupTee, scoring, carryover, skinValue: Number(stake) || 0, splitDays: false, lowMode: (live.length > 1 ? lowMode : "group"), sideGames: { sat: { ...sg }, sun: { ...sg } }, }; const base = existing || {}; const session = { id: existing ? existing.id : "og-" + Date.now(), name: existing && existing.name ? existing.name : (game.name + " · " + Y.shortDate(new Date(dateISO + "T00:00:00"))), createdBy: base.createdBy || (member && member.name) || "", createdById: base.createdById || (member && member.id) || null, createdAt: base.createdAt || new Date().toISOString(), dateISO: playNow ? todayISO : dateISO, playNow, status: base.status || "active", config, foursomes: live, scores: base.scores || {}, sideResults: base.sideResults || {}, locks: base.locks || {}, }; onDone(session); } // ---- step chrome ---- const steps = ["Groups", "Players", "Game"]; const canNext = step === 0 ? count >= 1 : step === 1 ? totalPlayers >= 1 : true; return ( {/* step rail */}
{steps.map((s, i) => ( ))}
{/* STEP 0 — group count + when */} {step === 0 && (
How many groups?

Each group is a foursome — up to 4 players. You can drag players between groups in the next step.

{count}
group{count > 1 ? "s" : ""} · up to {count * 4} players
When
{!playNow && ( setDateISO(e.target.value)} className="input" style={{ width: "auto", padding: "9px 12px" }} /> )}

{playNow ? "The scorecard opens immediately so you can enter scores as you play." : "The scorecard opens on the chosen day — set up the lineup now."}

)} {/* STEP 1 — lineup with drag & drop */} {step === 1 && (
Drag a player into a group, between groups, or reorder within one. Tap to add to the first open seat.
{/* available pool */}
{ if (drag.current) e.preventDefault(); }} onDrop={(e) => { e.preventDefault(); dropToPool(); }} style={{ border: "1px dashed var(--line-strong)", borderRadius: 12, padding: 12, background: "var(--surface-2)" }}>
Members ({available.length})
{guestOpen && (
setGuestName(e.target.value)} style={{ flex: "1 1 150px", width: "auto", padding: "9px 12px" }} /> setGuestHi(e.target.value.replace(/[^0-9.\-]/g, ""))} style={{ width: 70, padding: "9px 12px" }} />
)}
{available.length === 0 && Everyone is assigned to a group.} {available.map((c) => ( ))}
{/* group columns */}
1 ? "repeat(auto-fit, minmax(220px, 1fr))" : "1fr", gap: 12 }}> {foursomes.map((f, i) => (
{ if (drag.current) { e.preventDefault(); setOverFid(f.id); } }} onDragLeave={() => setOverFid((p) => p === f.id ? null : p)} onDrop={(e) => { e.preventDefault(); dropInto(f.id, null); }} style={{ border: "1px solid " + (overFid === f.id ? "var(--accent)" : "var(--line)"), borderRadius: 12, background: overFid === f.id ? "color-mix(in srgb,var(--accent) 8%,var(--surface))" : "var(--surface)", overflow: "hidden", transition: "border-color .12s, background .12s" }}>
{f.label} {f.players.length}/4
{f.players.length === 0 &&
Drop players here
} {f.players.map((p) => (
startDrag(f.id, p.name)} onDragEnd={endDrag} onDragOver={(e) => { if (drag.current) e.preventDefault(); }} onDrop={(e) => { e.preventDefault(); e.stopPropagation(); dropInto(f.id, p.name); }} style={{ display: "flex", alignItems: "center", gap: 9, padding: "7px 9px", borderRadius: 9, background: "var(--surface-2)", border: "1px solid var(--line)", cursor: "grab", opacity: dragName === p.name ? .4 : 1 }}>
{p.name}{p.guest ? "" : ""}
HI {p.hi}{p.guest ? " · guest" : ""}
))}
))}
)} {/* STEP 2 — game + add-ons */} {step === 2 && (
Game format
{G.GAME_LIBRARY.map((g) => { const on = gameType === g.id; return ( ); })}
{/* tees + scoring + stake */}
Tees
Scoring
{[["gross", "Gross"], ["net", "Net"]].map(([v, l]) => ( ))}
{ogStakeLabel(gameType)}
$ setStake(e.target.value.replace(/[^0-9]/g, ""))} style={{ width: 72, padding: "9px 11px" }} />
{gameType === "skins" && (
Carryover
)} {foursomes.filter((f) => f.players.length > 0).length > 1 && (
Strokes off
{[["day", "Field low"], ["group", "Group low"]].map(([v, l]) => ( ))}
)}
{/* add-on games */}
Add-on games
{ctp && (
{parThrees.map((n) => { const on = ctpHoles.includes(n); const hole = Y.HOLES.find((h) => h.n === n); return ( ); })}
)}
{/* summary */}
{foursomes.filter((f) => f.players.length > 0).length} group{foursomes.filter((f) => f.players.length > 0).length > 1 ? "s" : ""} {totalPlayers} player{totalPlayers > 1 ? "s" : ""} {game.name} · {scoring} {groupTee} tees {(ctp || longDrive) && {[ctp && "CTP", longDrive && "Long drive"].filter(Boolean).join(" · ")}}
)}
{/* footer nav */}
{step < 2 ? : }
{rulesOpen && setRulesOpen(null)} />}
); } function ogSegStyle(on) { return { display: "inline-flex", alignItems: "center", gap: 7, padding: "9px 15px", borderRadius: 10, fontSize: 13, fontWeight: 700, border: "1px solid " + (on ? "var(--brand)" : "var(--line-strong)"), background: on ? "var(--brand)" : "var(--surface)", color: on ? "var(--on-brand)" : "var(--ink)", cursor: "pointer", }; } function ogPillStyle(on, standalone) { if (standalone) return { padding: "8px 13px", borderRadius: 9, fontSize: 12.5, fontWeight: 700, border: "1px solid " + (on ? "var(--brand)" : "var(--line-strong)"), background: on ? "var(--brand-soft)" : "var(--surface)", color: on ? "var(--brand)" : "var(--ink-soft)", cursor: "pointer" }; return { padding: "6px 13px", borderRadius: 7, fontSize: 12.5, fontWeight: 600, background: on ? "var(--surface)" : "transparent", color: on ? "var(--brand)" : "var(--ink-soft)", boxShadow: on ? "var(--shadow-sm)" : "none", cursor: "pointer" }; } function ogToggleRow(on) { return { display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, width: "100%", textAlign: "left", padding: "11px 14px", borderRadius: 11, border: "1px solid " + (on ? "var(--brand)" : "var(--line)"), background: on ? "var(--brand-soft)" : "var(--surface)", cursor: "pointer" }; } function ogCheck(on) { return { width: 22, height: 22, borderRadius: 6, flex: "0 0 auto", display: "grid", placeItems: "center", background: on ? "var(--brand)" : "var(--surface-2)", color: "#fff", border: "1px solid " + (on ? "var(--brand)" : "var(--line-strong)") }; } /* -------------------- OPEN GROUP SCREEN -------------------- */ function OpenGroupScreen({ ctx }) { const Y = window.YCC, G = window.YCC_GAMES; const { openDoc, member, role, toast } = ctx; const sessions = (openDoc && openDoc.sessions) || []; const isManager = role === "manager"; const todayISO = Y.isoDay(Y.TODAY); const [setupOpen, setSetupOpen] = useState(false); const [editId, setEditId] = useState(null); const [selId, setSelId] = useState(null); const [rulesOpen, setRulesOpen] = useState(null); const [renaming, setRenaming] = useState(false); const [nameDraft, setNameDraft] = useState(""); // default selection → newest active, else newest const sorted = [...sessions].sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || "")); const selected = sorted.find((s) => s.id === selId) || sorted.find((s) => s.status !== "done") || sorted[0] || null; useEffect(() => { if (selected && selected.id !== selId) setSelId(selected.id); }, [selected && selected.id]); const editSession = editId ? sessions.find((s) => s.id === editId) : null; function onSetupDone(session) { if (editId) ctx.updateOpenSession(session.id, session); else { ctx.createOpenSession(session); setSelId(session.id); } setSetupOpen(false); setEditId(null); } return (
DIV Tour · Open Play

Open Group

Set up a game on the fly — any members, any format. Scorecards work exactly like weekend play, and results are kept separate from the weekend group.

{sessions.length === 0 ? (

Start an Open Group

Pick how many groups, drag your players in from the member list, choose a game and add-ons, and tee off. Every group gets the full Yardley scorecard with stroke dots, closest-to-the-pin and longest drive.

{[["users", "Pick groups & players", "1–8 foursomes, drag to arrange"], ["trophy", "Choose the game", "Any format + CTP / long drive"], ["flag", "Play now", "Live scorecards, saved separately"]].map(([ic, t, s]) => (
{t}
{s}
))}
) : ( <> {/* session switcher */}
{sorted.map((s) => { const on = selected && s.id === selected.id; const gm = G.gameById((s.config || {}).gameType); const players = (s.foursomes || []).reduce((n, f) => n + f.players.length, 0); const done = s.status === "done"; return ( ); })}
{selected && setRulesOpen(g)} onEdit={() => { setEditId(selected.id); setSetupOpen(true); }} />} )} {setupOpen && { setSetupOpen(false); setEditId(null); }} onDone={onSetupDone} />} {rulesOpen && setRulesOpen(null)} />}
); } /* -------- One open session: header + a scorecard per foursome -------- */ function OpenSessionView({ ctx, session, onRules, onEdit }) { const Y = window.YCC, G = window.YCC_GAMES; const { member, role, toast } = ctx; const isManager = role === "manager"; const todayISO = Y.isoDay(Y.TODAY); const cfg = session.config || {}; const game = G.gameById(cfg.gameType); const tee = cfg.groupTee || "White"; const canManage = isManager || (session.createdById && member && session.createdById === member.id) || (session.createdBy && member && session.createdBy === member.name); const [renaming, setRenaming] = useState(false); const [nameDraft, setNameDraft] = useState(session.name || ""); const [confirmDel, setConfirmDel] = useState(false); // field low across all foursomes — each player's course handicap off THEIR OWN tee const chs = []; (session.foursomes || []).forEach((f) => f.players.forEach((p) => { const c = Y.courseHcpOf(p, tee); if (c != null) chs.push(c); })); const fieldLow = chs.length ? Math.min(...chs) : null; const lowMode = cfg.lowMode || ((session.foursomes || []).length > 1 ? "day" : "group"); // build a scores/sideResults map keyed dayISO|foursomeId so GroupScorecard reads them const sessScores = {}, sessSide = {}; (session.foursomes || []).forEach((f) => { sessScores[session.dateISO + "|" + f.id] = (session.scores || {})[f.id] || {}; sessSide[session.dateISO + "|" + f.id] = (session.sideResults || {})[f.id] || {}; }); const setScore = (dayISO, slotId, name, hIdx, val) => ctx.setOpenScore(session.id, slotId, name, hIdx, val); const setSideResult = (dayISO, slotId, kind, holeIdx, name) => ctx.setOpenSideResult(session.id, slotId, kind, holeIdx, name); const setScoreLock = (dayISO, slotId, locked, by) => ctx.setOpenScoreLock(session.id, slotId, locked, by); const groups = (session.foursomes || []).map((f, i) => ({ dayISO: session.dateISO, dayLabel: "Open Group", slotId: f.id, time: f.label || ("Group " + OG_LETTERS[i]), slot: { id: f.id, time: f.label, players: f.players }, })); const done = session.status === "done"; function saveName() { const n = nameDraft.trim(); if (n) ctx.updateOpenSession(session.id, { name: n }); setRenaming(false); } return (
{/* session header */}
{renaming ? ( setNameDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && saveName()} style={{ padding: "6px 10px", width: 240, color: "var(--ink)" }} /> ) : ( {session.name} )} {done ? "Final" : "Active"}
{Y.fmtDate(new Date(session.dateISO + "T00:00:00"))} · {game.name} · {tee.toUpperCase()} TEES · set up by {session.createdBy ? session.createdBy.split(" ")[0] : "a member"}
{canManage && !done && } {canManage && !renaming && } {canManage && ( )} {canManage && ( confirmDel ? : )}
{/* one scorecard per foursome — reuses the weekend GroupScorecard */}
{groups.map((g) => ( ))}
); } /* -------- Compact session history for the Season tab (Open Group toggle) -------- */ function OpenSessionHistory({ ctx }) { const Y = window.YCC, G = window.YCC_GAMES, ENG = window.YCC_SCORING; const sessions = ((ctx.openDoc && ctx.openDoc.sessions) || []).slice().sort((a, b) => (b.dateISO || "").localeCompare(a.dateISO || "")); const me = ctx.member && ctx.member.name; const ini = (n) => (n || "?").trim().split(/\s+/).map((s) => s[0]).join("").slice(0, 2).toUpperCase(); if (!sessions.length) { return (

Open Group sessions

No Open Group games yet

Start one from the Open Group tab — its stats land here, separate from weekend play.

); } return (

Open Group sessions

Every ad-hoc game played, newest first.
{sessions.map((s) => { const gm = G.gameById((s.config || {}).gameType); const stats = ENG.openGroupStats ? ENG.openGroupStats([s]) : {}; const leaders = Object.entries(stats).map(([n, st]) => ({ n, wins: st.wins || 0, ctp: st.ctp || 0 })).filter((r) => r.wins || r.ctp).sort((a, b) => b.wins - a.wins); const players = (s.foursomes || []).reduce((n, f) => n + f.players.length, 0); return (
{s.name || gm.name} {gm.name} {Y.shortDate(new Date(s.dateISO + "T00:00:00"))} · {players} player{players === 1 ? "" : "s"} {s.status === "done" ? "Final" : "Active"}
{leaders.length > 0 && (
{leaders.slice(0, 6).map((r) => ( {r.n === me ? "You" : r.n.split(" ")[0]} {r.wins > 0 && {r.wins}} {r.ctp > 0 && {r.ctp}} ))}
)}
); })}
); } Object.assign(window, { OpenGroupScreen, OpenGroupSetup, OpenSessionView, OpenSessionHistory, ogCandidates });