/* * app.jsx — Core module: shared constants, utilities, hooks, * small reusable components, and the main App shell. * * ── LOAD ORDER ── * This file is loaded FIRST. It defines everything the other * three files (pitch.jsx, scouting-form.jsx, timeline.jsx) * depend on: theme colors, uid(), Field, ToggleGroup, etc. * * The App component defined here references TacticalBoard, * ScoutingForm, and MatchTimeline — which don't exist yet * when this file executes. That's fine: JavaScript doesn't * evaluate a function body until the function is called. * By the time the mount script calls App(), all four files * have executed and those globals exist. * * Data persistence uses localStorage (the browser's built-in * key-value store). */ /* eslint-disable no-unused-vars */ var { useState, useRef, useCallback, useEffect, useReducer } = React; /* ═══════════════════════════════════════════════════════════ THEME ═══════════════════════════════════════════════════════════ */ var C = { bg: "#0c0e11", bgCard: "#161a20", bgInput: "#1c2128", bgHover: "#232831", border: "#2a313c", borderActive: "#3a424f", accent: "#b8e636", accentDim: "rgba(184,230,54,0.15)", blue: "#5fa8ff", blueDim: "rgba(95,168,255,0.15)", red: "#ff6b5a", redDim: "rgba(255,107,90,0.15)", yellow: "#ffd44a", amber: "#ff9d3d", amberDim: "rgba(255,157,61,0.15)", danger: "#f15545", dangerDim: "rgba(241,85,69,0.15)", arrow: "#e8eef2", text: "#e3e6ec", textMuted: "#8b94a3", textDim: "#6b7380", pitch: "#15301e", pitchLine: "#3a5a2e", font: "'Saira', sans-serif", fontCond: "'Saira Condensed', sans-serif", }; /* ── Header segmented controls (view tabs + Save/Load/Export) ── Shared styling so both groups look identical. The container's flex-direction is flipped to a column on mobile (see App) so the buttons stack vertically instead of crowding the match clock. */ var segGroupStyle = { display: "flex", alignItems: "center", gap: 2, background: C.bgInput, border: "1px solid " + C.border, borderRadius: 4, padding: 2, }; var segBtnStyle = { padding: "4px 11px", fontSize: 10, fontFamily: C.fontCond, fontWeight: 700, textTransform: "uppercase", letterSpacing: 0.5, whiteSpace: "nowrap", border: "none", borderRadius: 3, cursor: "pointer", transition: "background 0.12s, color 0.12s", }; var INITIAL_DATA = { match: { opponent: "", ourTeam: "", date: "", formationIn: "", formationOut: "", dangerPlayers: "" }, boards: [{ id: "b1", name: "Formation", markers: [], arrows: [] }], activeBoard: "b1", scouting: { buildUp: {}, progression: {}, pressing: {}, block: {}, posTransition: {}, negTransition: {}, setPieces: [{ type: null, side: null, notes: "", minute: "" }], }, momentum: {}, observations: [], summary: { problems: "", opportunities: "", notes: "" }, }; var _id = Date.now(); var uid = function () { return "" + (++_id); }; var STORAGE_KEY = "match-analysis-v3"; var LIBRARY_KEY = "match-analysis-library-v1"; function loadLibrary() { try { var raw = localStorage.getItem(LIBRARY_KEY); return raw ? JSON.parse(raw) : []; } catch (e) { return []; } } function writeLibrary(items) { try { localStorage.setItem(LIBRARY_KEY, JSON.stringify(items)); } catch (e) { console.warn("Library save failed", e); } } function findDuplicateLabel(data) { var opp = data.match.opponent || ""; var our = data.match.ourTeam || ""; var dt = data.match.date || ""; var match = loadLibrary().find(function (e) { return e.opponent === opp && e.ourTeam === our && e.date === dt; }); if (!match) return null; return (match.ourTeam || "Our Team") + " vs " + (match.opponent || "Opponent") + (match.date ? " (" + match.date + ")" : ""); } function saveToLibrary(data) { var lib = loadLibrary(); var entry = { id: "" + Date.now() + "-" + Math.floor(Math.random() * 1000), savedAt: new Date().toISOString(), opponent: data.match.opponent || "", ourTeam: data.match.ourTeam || "", date: data.match.date || "", data: JSON.parse(JSON.stringify(data)), }; var idx = lib.findIndex(function (e) { return e.opponent === entry.opponent && e.ourTeam === entry.ourTeam && e.date === entry.date; }); if (idx > -1) { lib[idx] = entry; } else { lib.unshift(entry); } writeLibrary(lib); return entry; } function deleteFromLibrary(id) { writeLibrary(loadLibrary().filter(function (e) { return e.id !== id; })); } /* ═══════════════════════════════════════════════════════════ AUTO-SAVE using localStorage ═══════════════════════════════════════════════════════════ */ function useAutoSave(data, loaded) { var dataRef = useRef(data); dataRef.current = data; var tick = useRef(0); useEffect(function () { if (!loaded) return; tick.current++; var snap = tick.current; var t = setTimeout(function () { if (snap !== tick.current) return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(dataRef.current)); } catch (e) { console.warn("Auto-save failed", e); } }, 600); return function () { clearTimeout(t); }; }, [data, loaded]); } function loadSaved() { try { var raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : null; } catch (e) { return null; } } function clearSaved() { try { localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ } } /* Merge a loaded/saved blob over INITIAL_DATA. A plain top-level spread isn't enough: if `saved.scouting` exists it would wholesale-replace INITIAL_DATA.scouting, dropping any keys added after that save was made (e.g. setPieces). Components then crash on s.setPieces.map(...). So we also merge the nested objects one level down to keep their defaults. */ function mergeWithDefaults(saved) { saved = saved || {}; return { ...INITIAL_DATA, ...saved, match: { ...INITIAL_DATA.match, ...(saved.match || {}) }, scouting: { ...INITIAL_DATA.scouting, ...(saved.scouting || {}) }, summary: { ...INITIAL_DATA.summary, ...(saved.summary || {}) }, }; } /* ═══════════════════════════════════════════════════════════ CONFIRM MODAL Replaces native window.confirm() — promise-based so callers can `.then(ok => ...)`. Mount the returned `modal` once in the tree and call `confirm({ message, ... })` to await a user decision. ═══════════════════════════════════════════════════════════ */ function ConfirmModal(props) { useEffect(function () { var onKey = function (e) { if (e.key === "Escape") { e.preventDefault(); props.onCancel(); } else if (e.key === "Enter") { e.preventDefault(); props.onConfirm(); } }; document.addEventListener("keydown", onKey); return function () { document.removeEventListener("keydown", onKey); }; }, [props.onCancel, props.onConfirm]); return (
{props.title || "Confirm"}
{props.message}
); } function useConfirm() { var _s = useState(null), state = _s[0], setState = _s[1]; var confirm = useCallback(function (opts) { return new Promise(function (resolve) { setState({ title: opts.title, message: opts.message, confirmLabel: opts.confirmLabel, cancelLabel: opts.cancelLabel, danger: !!opts.danger, resolve: resolve, }); }); }, []); var handleCancel = function () { if (state) { state.resolve(false); setState(null); } }; var handleConfirm = function () { if (state) { state.resolve(true); setState(null); } }; var modal = state ? ( ) : null; return { confirm: confirm, modal: modal }; } /* ═══════════════════════════════════════════════════════════ MATCH CLOCK ═══════════════════════════════════════════════════════════ */ function useMatchClock() { var _s = useState(0), seconds = _s[0], setSeconds = _s[1]; var _r = useState(false), running = _r[0], setRunning = _r[1]; var _h = useState(1), half = _h[0], setHalf = _h[1]; var interval = useRef(null); useEffect(function () { if (running) { interval.current = setInterval(function () { setSeconds(function (s) { return s + 1; }); }, 1000); } else { clearInterval(interval.current); } return function () { clearInterval(interval.current); }; }, [running]); var minute = Math.floor(seconds / 60) + (half === 2 ? 45 : 0); var toggle = function () { setRunning(function (r) { return !r; }); }; var reset = function () { setSeconds(0); setRunning(false); setHalf(1); }; var startSecondHalf = function () { setHalf(2); setSeconds(0); setRunning(true); }; return { minute: minute, seconds: seconds % 60, running: running, half: half, toggle: toggle, reset: reset, startSecondHalf: startSecondHalf }; } /* ═══════════════════════════════════════════════════════════ RESPONSIVE — mobile breakpoint Matches the 768px threshold the Scout/Timeline layouts use in match-analysis.css. Below it, the header's tab + action groups stack their buttons vertically instead of sitting in a row. ═══════════════════════════════════════════════════════════ */ var MOBILE_BREAKPOINT = 768; function useIsMobile() { var query = "(max-width: " + (MOBILE_BREAKPOINT - 0.02) + "px)"; var _m = useState(function () { return window.matchMedia(query).matches; }); var isMobile = _m[0], setIsMobile = _m[1]; useEffect(function () { var mql = window.matchMedia(query); var handler = function (e) { setIsMobile(e.matches); }; mql.addEventListener("change", handler); return function () { mql.removeEventListener("change", handler); }; }, []); return isMobile; } /* ═══════════════════════════════════════════════════════════ SMALL REUSABLE COMPONENTS ═══════════════════════════════════════════════════════════ */ function ToggleGroup(_ref) { var options = _ref.options, value = _ref.value, onChange = _ref.onChange, multi = _ref.multi || false; var handle = function (opt) { if (multi) { var a = value || []; onChange(a.includes(opt) ? a.filter(function (v) { return v !== opt; }) : a.concat([opt])); } else { onChange(value === opt ? null : opt); } }; return (
{options.map(function (opt) { var on = multi ? (value || []).includes(opt) : value === opt; return ( ); })}
); } function Field(_ref) { var label = _ref.label, value = _ref.value, onChange = _ref.onChange, placeholder = _ref.placeholder, multiline = _ref.multiline, minute = _ref.minute, onMinuteChange = _ref.onMinuteChange, type = _ref.type; var shared = { value: value || "", onChange: function (e) { onChange(e.target.value); }, placeholder: placeholder || "", type: type || "text", style: { width: "100%", padding: "8px 11px", fontSize: 14, background: C.bgInput, border: "1px solid " + C.border, borderRadius: 4, color: C.text, fontFamily: C.font, resize: multiline ? "vertical" : "none", minHeight: multiline ? 60 : "auto", outline: "none", boxSizing: "border-box", colorScheme: "dark", }, }; return (
{label && (
{onMinuteChange !== undefined && ( )}
)} {multiline ?