/* ============================================================
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) => (
(i < step || canNext || i < step) && setStep(i)} style={{
display: "flex", alignItems: "center", gap: 8, padding: "6px 12px", borderRadius: 999, fontSize: 12.5, fontWeight: 700,
border: "1px solid " + (i === step ? "var(--brand)" : "var(--line-strong)"),
background: i === step ? "var(--brand)" : (i < step ? "var(--brand-soft)" : "var(--surface)"),
color: i === step ? "var(--on-brand)" : (i < step ? "var(--brand)" : "var(--ink-soft)"), cursor: "pointer",
}}>
{i < step ? : i + 1}
{s}
))}
{/* 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.
applyCount(count - 1)} disabled={count <= 1} style={{ width: 46, height: 46, padding: 0, borderRadius: 12 }}>
{count}
group{count > 1 ? "s" : ""} · up to {count * 4} players
applyCount(count + 1)} disabled={count >= 8} style={{ width: 46, height: 46, padding: 0, borderRadius: 12 }}>
When
setPlayNow(true)} style={ogSegStyle(playNow)}> Play now
setPlayNow(false)} style={ogSegStyle(!playNow)}> Pick a date
{!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})
setGuestOpen((v) => !v)}> Off-app guest
{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" }} />
setGuestFid(e.target.value)} style={{ width: "auto", padding: "9px 12px" }}>
{foursomes.map((f, i) => {f.label} )}
Add
)}
{available.length === 0 &&
Everyone is assigned to a group. }
{available.map((c) => (
startDrag(null, c.name)} onDragEnd={endDrag} onClick={() => addToFirstOpen(c)}
title={`HI ${c.hi} · GHIN ${c.ghin} — tap or drag to add`}
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 12px 5px 5px", borderRadius: 999, border: "1px solid var(--line-strong)", background: "var(--surface)", cursor: "grab", opacity: dragName === c.name ? .4 : 1 }}>
{c.name}
{c.hi}
))}
{/* 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" : ""}
removePlayer(f.id, p.name)} title="Remove" style={{ width: 22, height: 22, borderRadius: "50%", display: "grid", placeItems: "center", color: "var(--danger)", border: "1px solid color-mix(in srgb,var(--danger) 28%,transparent)", flex: "0 0 auto" }}>
))}
))}
)}
{/* STEP 2 — game + add-ons */}
{step === 2 && (
Game format
{G.GAME_LIBRARY.map((g) => {
const on = gameType === g.id;
return (
setGameType(g.id)} style={{
textAlign: "left", padding: "11px 12px", borderRadius: 11, border: "1px solid " + (on ? "var(--accent)" : "var(--line)"),
background: on ? "color-mix(in srgb,var(--accent) 10%,var(--surface))" : "var(--surface)", cursor: "pointer",
}}>
{g.name}
{on && }
{g.best}
);
})}
setRulesOpen(game)} style={{ background: "none", padding: "8px 0 0", color: "var(--accent-deep)", fontWeight: 700, fontSize: 12.5, display: "inline-flex", alignItems: "center", gap: 5 }}>
{game.name} rules & scoring
{/* tees + scoring + stake */}
Scoring
{[["gross", "Gross"], ["net", "Net"]].map(([v, l]) => (
setScoring(v)} style={ogPillStyle(scoring === v)}>{l}
))}
{gameType === "skins" && (
Carryover
setCarryover((v) => !v)} style={ogPillStyle(carryover, true)}>{carryover ? "On — ties carry" : "Off"}
)}
{foursomes.filter((f) => f.players.length > 0).length > 1 && (
Strokes off
{[["day", "Field low"], ["group", "Group low"]].map(([v, l]) => (
setLowMode(v)} style={ogPillStyle(lowMode === v)}>{l}
))}
)}
{/* add-on games */}
Add-on games
setCtp((v) => !v)} style={ogToggleRow(ctp)}>
Closest to the Pin {ctp ? (ctpHoles.length === parThrees.length ? "Marked on every par 3" : ctpHoles.length === 0 ? "Pick at least one par 3 below" : "On " + ctpHoles.length + " of " + parThrees.length + " par 3s") : "Mark the par 3s to play"}
{ctp && }
{ctp && (
{parThrees.map((n) => {
const on = ctpHoles.includes(n);
const hole = Y.HOLES.find((h) => h.n === n);
return (
toggleCtpHole(n)} style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "7px 12px", borderRadius: 9, border: "1px solid " + (on ? "var(--accent)" : "var(--line-strong)"), background: on ? "color-mix(in srgb,var(--accent) 12%,var(--surface))" : "var(--surface)", cursor: "pointer" }}>
{on && }
Hole {n}
par {hole ? hole.par : 3} · {Y.yardsForTee(groupTee, n - 1)} yds
);
})}
)}
setLongDrive((v) => !v)} style={ogToggleRow(longDrive)}>
Longest Drive One designated hole
{longDrive && (
e.stopPropagation()} onChange={(e) => setLdHole(Number(e.target.value))} style={{ border: "1px solid var(--line-strong)", borderRadius: 8, padding: "5px 8px", background: "var(--surface)", fontFamily: "var(--font-body)", fontSize: 12.5, fontWeight: 600 }}>
{Y.HOLES.map((h) => Hole {h.n} · par {h.par} )}
)}
{longDrive && }
{/* 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 */}
setStep(step - 1)}>{step === 0 ? "Cancel" : <> Back>}
{step < 2
? setStep(step + 1)} title={!canNext ? (step === 1 ? "Add at least one player" : "") : ""}>Next
: {editing ? "Save changes" : (playNow ? "Begin — open scorecards" : "Create Open Group")} }
{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.
{ setEditId(null); setSetupOpen(true); }}> New Open 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.
{ setEditId(null); setSetupOpen(true); }}> New Open Group
{[["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]) => (
))}
) : (
<>
{/* 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 (
setSelId(s.id)} style={{
flex: "0 0 auto", textAlign: "left", padding: "11px 15px", borderRadius: 12, minWidth: 180,
border: "1px solid " + (on ? "var(--brand)" : "var(--line)"), background: on ? "var(--brand)" : "var(--surface)",
color: on ? "var(--on-brand)" : "var(--ink)", cursor: "pointer",
}}>
{done ? "Final" : "Active"}
{gm.name}
{Y.shortDate(new Date(s.dateISO + "T00:00:00"))} · {players} player{players === 1 ? "" : "s"}
);
})}
{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 */}
onRules(game)}> Rules
{canManage && !done && Edit lineup }
{canManage && !renaming && { setNameDraft(session.name || ""); setRenaming(true); }} title="Rename"> }
{canManage && (
ctx.updateOpenSession(session.id, { status: done ? "active" : "done" })}>
{done ? <> Reopen> : <> End game>}
)}
{canManage && (
confirmDel
? ctx.deleteOpenSession(session.id)}> Delete — sure?
: { setConfirmDel(true); setTimeout(() => setConfirmDel(false), 3500); }}>
)}
{/* 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 });