HomeMAL' // MANUFACTORUM
code & tools
index.html39.5 KB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Thumb Shooters UK Winter Portsmouth Postal Scorer</title>
<meta name="description" content="Score sheet for the Thumb Shooters UK Winter Portsmouth Postal">
<meta name="theme-color" content="#ffffff">
<meta name="color-scheme" content="light only">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icon-192.png">
<link rel="apple-touch-icon" href="icon-192.png">
<style>
:root{
  --bg:#f3f1ec; --card:#fff; --ink:#1d2733; --muted:#6b7480; --line:#d8d4cb; --accent:#1d2733;
  --gold:#ffd83d; --red:#e8414a; --blue:#27a9e1; --black:#23262b; --white:#fbfbfb; --miss:#8a9099;
  --sel:#ff7a00;
}
[hidden]{display:none!important}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%}
body{background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
  display:flex;flex-direction:column;padding:env(safe-area-inset-top) 0 env(safe-area-inset-bottom)}
header{display:flex;align-items:center;gap:4px;padding:6px 16px 6px 6px;min-height:56px;background:var(--card);color:var(--ink);border-bottom:1px solid var(--line)}
header h1{font-size:17px;line-height:1.25;margin:0;flex:1;font-weight:650;letter-spacing:.2px}
header button{background:none;border:0;color:var(--ink);font:inherit;font-size:17px;font-weight:600;min-height:44px;padding:0 14px 0 10px;border-radius:10px}
header button:active{background:var(--line)}
header .help{flex:none;width:40px;height:40px;min-height:0;padding:0;border-radius:50%;border:2px solid var(--ink);font-size:20px;font-weight:800;line-height:1}
.rules h3{font-size:16px;margin:16px 0 6px}
.rules h3:first-child{margin-top:0}
.rules ul{margin:0;padding-left:20px}
.rules li{margin:4px 0}
.rules p{margin:6px 0}
.rules a{color:var(--ink)}
header h1:first-of-type{padding-left:10px}
/* home title with "Scorer" underneath */
header .brand{display:block}
header .sub{display:block;font-size:12px;font-weight:600;letter-spacing:1.6px;text-transform:uppercase;color:var(--muted);margin-top:2px}
main{flex:1;overflow:auto;padding:16px}
.view{display:none} .view.on{display:block}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:14px;margin-bottom:14px}
.card h2{font-size:13px;text-transform:uppercase;letter-spacing:.8px;color:var(--muted);margin:0 0 10px;font-weight:600}
label{display:block;font-size:13px;color:var(--muted);margin:8px 0 4px}
input,select{width:100%;font:inherit;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:var(--bg);color:var(--ink)}
.row2{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.btn{display:block;width:100%;font:inherit;font-weight:650;padding:14px;border-radius:12px;border:0;background:var(--accent);color:var(--bg);margin-top:4px}
.btn.ghost{background:transparent;color:var(--ink);border:1px solid var(--line)}
.btn.danger{background:transparent;color:var(--red);border:1px solid var(--line)}
.logo{display:block;width:170px;max-width:50%;margin:0 auto 12px}
.inline{display:flex;gap:8px}
.archerbar{padding:10px 14px}
.archerbar label{margin-top:0}
.seasonhead{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.seasonhead h2{flex:1;margin:0;text-align:center}
.sbtn.nav{width:40px;height:36px;padding:0;font-size:22px;line-height:1}
.sbtn.nav:disabled{opacity:.3}
.inline select,.inline input{flex:1;min-width:0}
.sbtn{font:inherit;font-size:15px;font-weight:600;padding:0 14px;border-radius:10px;border:1px solid var(--line);background:var(--card);color:var(--ink)}
.sbtn.armed,.btn.armed{background:var(--red);color:#fff;border-color:var(--red)}
.sbtn.primary{background:var(--accent);color:var(--bg);border-color:var(--accent)}
.btns{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:10px}
.season{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}
.season td,.season th{padding:7px 4px;border-bottom:1px solid var(--line);text-align:right}
.season th{font-size:12px;color:var(--muted);font-weight:600}
.season td:first-child,.season th:first-child{text-align:left}
.season tr.now td{font-weight:700}
.deadline{font-size:14px;color:var(--muted);margin-top:8px}
.list{list-style:none;margin:0;padding:0}
.list li{display:flex;align-items:center;gap:12px;padding:12px 2px;border-bottom:1px solid var(--line)}
.list li:last-child{border:0}
.list .big{font-size:22px;font-weight:700;min-width:48px;font-variant-numeric:tabular-nums}
.list .meta{flex:1;font-size:14px;color:var(--muted)}
.list .meta b{color:var(--ink);font-weight:600}
.pill{font-size:11px;padding:2px 7px;border-radius:99px;background:var(--line);color:var(--ink);margin-left:4px}
.empty{color:var(--muted);font-size:14px;text-align:center;padding:10px}

/* scoring */
#v-score.on{display:flex;flex-direction:column;height:100%}
main.scoring{padding:0;display:flex;flex-direction:column;overflow:hidden}
.totals{display:grid;grid-template-columns:repeat(4,1fr);background:var(--card);border-bottom:1px solid var(--line);text-align:center;padding:8px 4px}
.totals div{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
.totals b{display:block;font-size:24px;color:var(--ink);font-variant-numeric:tabular-nums;letter-spacing:0}
.sheet{flex:1;overflow:auto;padding:10px 8px}
.srow{display:grid;grid-template-columns:22px repeat(6,1fr) 40px 48px;gap:4px;margin-bottom:6px;align-items:stretch;max-width:520px;margin-inline:auto}
.shead{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:4px}
.shead span{text-align:center}
.srow .n{font-size:12px;color:var(--muted);display:flex;align-items:center}
.cell{height:40px;display:flex;align-items:center;justify-content:center;font-weight:750;font-size:18px;border-radius:7px;border:1px solid var(--line);background:var(--card);font-variant-numeric:tabular-nums;min-width:0}
.cell.cur{outline:2px solid var(--ink);outline-offset:-2px}
.cell.sel{outline:3px solid var(--sel);outline-offset:-3px}
.e3 .srow{grid-template-columns:22px repeat(3,1fr) 2px repeat(3,1fr) 40px 48px}
.et,.rt{display:flex;align-items:center;justify-content:center;font-size:15px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}
.rt{color:var(--ink)}
.v10,.v9{background:var(--gold);color:#222;border-color:#d9b400}
.v10,.pad .v10{border:2.5px solid #111}   /* 10 stands out from 9 */
.v8,.v7{background:var(--red);color:#fff;border-color:#c52d35}
.v6,.v5{background:var(--blue);color:#fff;border-color:#1a8cbf}
.v4,.v3{background:var(--black);color:#fff;border-color:#555}
.v2,.v1{background:var(--white);color:#222;border-color:#bbb}
.v0{background:var(--miss);color:#fff;border-color:#6d737b}
.pad{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;padding:10px;background:var(--card);border-top:1px solid var(--line)}
.pad button{font:inherit;font-size:24px;font-weight:750;padding:0;height:58px;border-radius:12px;border:1px solid var(--line);font-variant-numeric:tabular-nums}
.pad button:active{transform:scale(.96)}
.pad .undo{background:var(--bg);color:var(--ink);font-size:18px}
.endinfo{text-align:center;font-size:13px;color:var(--muted);padding:6px 0 0}

/* summary */
#sheetImg{width:100%;border-radius:10px;border:1px solid var(--line);background:#fff;display:block}
.stat3{display:grid;grid-template-columns:repeat(3,1fr);text-align:center;gap:8px}
.stat3 b{display:block;font-size:30px;font-variant-numeric:tabular-nums}
.stat3 span{font-size:12px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px}
.toast{position:fixed;left:50%;top:calc(env(safe-area-inset-top) + 64px);transform:translateX(-50%);background:#1d2733;color:#fff;padding:10px 16px;border-radius:10px;font-size:14px;opacity:0;transition:opacity .2s;pointer-events:none}
.toast.on{opacity:1}
</style>
</head>
<body>
<header>
  <button id="backBtn" hidden aria-label="Back to home">‹ Home</button>
  <h1 id="title"><span class="brand">Thumb Shooters UK Winter Portsmouth Postal</span><span class="sub">Scorer</span></h1>
  <button id="helpBtn" class="help" aria-label="Rules and help">?</button>
</header>
<main id="main">

  <!-- HOME -->
  <section id="v-home" class="view on">
    <img id="logo" class="logo" src="logo.png" alt="Thumb Shooters UK" onerror="this.remove()">
    <div class="card archerbar">
      <label for="archerSel">Archer</label>
      <div class="inline">
        <select id="archerSel"></select>
        <button class="sbtn" id="archerDel" aria-label="Remove this archer">Remove</button>
      </div>
    </div>
    <div class="card">
      <div class="seasonhead">
        <button class="sbtn nav" id="seasonPrev" aria-label="Previous season">‹</button>
        <h2>Season <span id="seasonLabel"></span></h2>
        <button class="sbtn nav" id="seasonNext" aria-label="Next season">›</button>
      </div>
      <table class="season" id="seasonTbl"></table>
      <div class="deadline" id="deadline"></div>
    </div>
    <button class="btn" id="newBtn">Start new round</button>
    <div style="height:14px"></div>
    <div class="card">
      <h2>Archer details</h2>
      <label for="pName">Name</label>
      <input id="pName" autocomplete="name">
      <label for="pBow">Bow</label>
      <div class="inline">
        <select id="pBow"></select>
        <button class="sbtn" id="bowDel" aria-label="Remove this bow from the list">Remove</button>
      </div>
      <div class="inline" id="bowAdd" hidden style="margin-top:8px">
        <input id="bowNew" placeholder="e.g. Mariner Qing Dragon II - 53#@36&quot;" enterkeyhint="done">
        <button class="sbtn primary" id="bowSave">Save</button>
      </div>
      <div class="row2">
        <div><label for="pArrows">Arrows</label>
          <select id="pArrows"><option>Wood</option><option>Bamboo</option><option>Carbon</option><option>Aluminium</option><option>Other</option></select></div>
        <div><label for="pEnd">Ends of</label>
          <select id="pEnd"><option value="3">3 arrows</option><option value="6">6 arrows</option></select></div>
      </div>
    </div>
    <div class="card">
      <h2>Rounds shot · <span id="roundsSeason"></span></h2>
      <ul class="list" id="roundList"></ul>
    </div>
  </section>

  <!-- SCORING -->
  <section id="v-score" class="view">
    <div class="totals">
      <div>Total<b id="tTotal">0</b></div>
      <div>Hits<b id="tHits">0</b></div>
      <div>10s<b id="tTens">0</b></div>
      <div>Arrows<b id="tArrows">0</b></div>
    </div>
    <div class="sheet" id="sheet"></div>
    <div class="endinfo" id="endInfo"></div>
    <div class="pad" id="pad"></div>
  </section>

  <!-- RULES / HELP -->
  <section id="v-help" class="view rules">
    <div class="card">
      <h3>The competition</h3>
      <ul>
        <li>Runs for 5 months: 1 October to the end of February.</li>
        <li>Shoot the round as often as you like each month; send only your <b>best score of the month</b>.</li>
        <li>Scores must reach Richard by <b>midnight on the last day of each month</b>.</li>
        <li>Monthly results are published as the season goes on. Medals at the end of the season, with a separate medal for overseas guests.</li>
        <li>All abilities welcome – you are shooting against yourself.</li>
      </ul>
      <h3>Equipment</h3>
      <ul>
        <li><b>Thumb draw only.</b> Any arrow, shot off the hand and on the thumb side of the bow (bow in the left hand → arrow on the right side).</li>
        <li>No shelves or rests.</li>
        <li>No sights, pencil marks on the limb, elastic bands or ground markers.</li>
      </ul>
      <h3>The Portsmouth round</h3>
      <ul>
        <li>5 dozen arrows (60) on a standard 60cm face at 20 yards (18m). Indoors or out, wherever you have space.</li>
        <li>Ends of 3 arrows. Ends of 6 are allowed if you are short of time.</li>
        <li>10-zone scoring, 10 down to 1. No need to mark Xs – a gold is a 10. A miss is always <b>M</b>.</li>
        <li>No timed ends.</li>
      </ul>
      <h3>Scoring</h3>
      <ul>
        <li>Don't touch the arrows until all of them are scored.</li>
        <li>An arrow cutting a line scores the higher value.</li>
      </ul>
      <h3>Sending your score</h3>
      <ul>
        <li>Send the score sheet with your <b>total, hits and 10s</b> filled in – Richard will not add it up for you.</li>
        <li>Send it to Richard by email or Facebook Messenger. His contact details are in the competition post on the Thumb Shooters UK group.</li>
        <li>Please say what arrows you use: wood, carbon, aluminium or other.</li>
      </ul>
    </div>
    <div class="card">
      <h3>Using this app</h3>
      <ul>
        <li>Set your name, bow and arrows once on the home screen. Save each bow you shoot with “+ Add a bow…”.</li>
        <li>Tap <b>Start new round</b>, then tap each arrow's score on the keypad. Finished ends are sorted highest first, as on a paper sheet.</li>
        <li>Wrong score? Tap the arrow in the grid, then tap the right score. <b>Undo</b> removes the last arrow.</li>
        <li>Every arrow is saved as you go – you can leave and come back to a round.</li>
        <li>When the round is finished, tap <b>Save picture</b> to download the score sheet, then send it to Richard.</li>
        <li>The home screen shows your best score for each month of the season.</li>
      </ul>
    </div>
    <div class="card">
      <h3>Backup</h3>
      <div class="btns">
        <button class="btn ghost" id="exportBtn">Save backup</button>
        <button class="btn ghost" id="importBtn">Restore backup</button>
      </div>
      <input type="file" id="importFile" accept=".json,application/json" hidden>
    </div>
  </section>

  <!-- SUMMARY -->
  <section id="v-sum" class="view">
    <div class="card stat3">
      <div><b id="sTotal">0</b><span>Total</span></div>
      <div><b id="sHits">0</b><span>Hits</span></div>
      <div><b id="sTens">0</b><span>10s</span></div>
    </div>
    <div class="card">
      <h2>Score sheet</h2>
      <img id="sheetImg" alt="Completed Portsmouth score sheet">
      <label for="sScorer">Scorer's name (optional)</label>
      <input id="sScorer" placeholder="Who checked the scores">
      <div class="btns">
        <button class="btn" id="saveImg">Save picture</button>
        <button class="btn ghost" id="editRound">Edit scores</button>
      </div>
      <button class="btn danger" id="delRound" style="margin-top:10px">Delete round</button>
    </div>
    <button class="btn" id="doneBtn">Done</button>
  </section>
</main>
<div class="toast" id="toast"></div>

<script>
const ARROWS = 60;
const MONTHS = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const $ = id => document.getElementById(id);
const ADD = '__add';   // select value for the "+ Add…" entries

// ---------- storage ----------
function load(k, d){ try{ const v = localStorage.getItem(k); return v ? JSON.parse(v) : d; }catch(e){ return d; } }
function save(k, v){ try{ localStorage.setItem(k, JSON.stringify(v)); }catch(e){ toast('Could not save'); } }
let rounds = load('pp.rounds', []);
const newArcher = (name='') => ({ id: 'a' + Date.now() + Math.random().toString(36).slice(2,6), name, bows: [], bow: '', arrows: 'Wood', end: 3 });
let archers = load('pp.archers', null);
if (!archers){
  // first run after multi-archer support: turn the old single profile into the first archer,
  // and give every existing round an archer (matched by name)
  const old = load('pp.profile', null);
  archers = [];
  if (old){ const a = Object.assign(newArcher(old.name || ''), old); a.bows = old.bows || (old.bow ? [old.bow] : []); archers.push(a); }
  for (const r of rounds){
    let a = archers.find(x => x.name.trim() === (r.name || '').trim());
    if (!a){ a = newArcher(r.name || ''); if (r.bow){ a.bows = [r.bow]; a.bow = r.bow; } archers.push(a); }
    r.archer = a.id;
  }
  if (!archers.length) archers.push(newArcher());
  save('pp.archers', archers); save('pp.rounds', rounds);
}
let profile = archers.find(a => a.id === load('pp.archer', '')) || archers[0];   // the selected archer
const saveArchers = () => { save('pp.archers', archers); save('pp.archer', profile.id); };
const saveRounds = () => save('pp.rounds', rounds);
const myRounds = () => rounds.filter(r => r.archer === profile.id);

// ---------- scoring maths ----------
const filled = r => r.scores.filter(v => v !== null);
function stats(r){
  const f = filled(r);
  return { total: f.reduce((a,b)=>a+b,0), hits: f.filter(v=>v>0).length, tens: f.filter(v=>v===10).length, n: f.length };
}
const fmt = v => v === null ? '' : v === 0 ? 'M' : String(v);
const localDate = (d=new Date()) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;

// Order of display slots for one end: completed ends are shown highest-first (sheet convention),
// the end in progress stays in the order it was typed.
function endOrder(r, start){
  const idx = [...Array(r.end).keys()].map(i => start + i);
  if (idx.every(i => r.scores[i] !== null)) idx.sort((a,b) => r.scores[b] - r.scores[a] || a - b);
  return idx;
}
// display slot -> underlying score index, for all 60 arrows
function slotMap(r){
  const m = [];
  for (let s = 0; s < ARROWS; s += r.end) m.push(...endOrder(r, s));
  return m;
}

// ---------- season / month best ----------
// Competition months Oct..Feb; season label is the October year.
function seasonOf(dateStr){
  const [y,m] = dateStr.split('-').map(Number);
  return m >= 7 ? y : y - 1;
}
const thisSeason = seasonOf(localDate());
let viewSeason = thisSeason;
const seasonName = y => `${y}/${String(y+1).slice(2)}`;
function renderSeason(){
  const now = new Date();
  const season = viewSeason;
  const first = Math.min(thisSeason, ...myRounds().map(r => seasonOf(r.date)));
  $('seasonPrev').disabled = season <= first;
  $('seasonNext').disabled = season >= thisSeason;
  $('seasonLabel').textContent = seasonName(season);
  const months = [[season,10],[season,11],[season,12],[season+1,1],[season+1,2]];
  const done = myRounds().filter(r => filled(r).length === ARROWS);
  let html = '<tr><th>Month</th><th>Best</th><th>Hits</th><th>10s</th><th>Rounds</th></tr>';
  for (const [y,m] of months){
    const key = `${y}-${String(m).padStart(2,'0')}`;
    const inM = done.filter(r => r.date.startsWith(key)).map(r => ({r, s:stats(r)}));
    inM.sort((a,b) => b.s.total - a.s.total || b.s.hits - a.s.hits || b.s.tens - a.s.tens);
    const best = inM[0];
    const isNow = now.getFullYear() === y && now.getMonth()+1 === m;
    html += `<tr class="${isNow?'now':''}" ${best?`data-id="${best.r.id}"`:''}><td>${MONTHS[m-1]} ${String(y).slice(2)}</td>
      <td>${best?best.s.total:'–'}</td><td>${best?best.s.hits:'–'}</td><td>${best?best.s.tens:'–'}</td><td>${inM.length||'–'}</td></tr>`;
  }
  $('seasonTbl').innerHTML = html;
  $('seasonTbl').querySelectorAll('tr[data-id]').forEach(tr => tr.onclick = () => openRound(tr.dataset.id));

  const m = now.getMonth()+1;
  if (season !== thisSeason) $('deadline').textContent = '';
  else if ([10,11,12,1,2].includes(m)){
    const last = new Date(now.getFullYear(), m, 0).getDate();
    const left = last - now.getDate();
    $('deadline').textContent = left === 0
      ? `⚠ Today is the last day – send your ${MONTHS[m-1]} best score by midnight.`
      : `Send your ${MONTHS[m-1]} best score by midnight on ${last} ${MONTHS[m-1]} (${left} day${left>1?'s':''} left).`;
  } else {
    $('deadline').textContent = '';
  }
}

// ---------- views ----------
let current = null;   // round being viewed/scored
let sel = null;       // underlying index selected for correction
let wakeLock = null;

function show(view){
  document.querySelectorAll('.view').forEach(v => v.classList.toggle('on', v.id === 'v-'+view));
  $('main').classList.toggle('scoring', view === 'score');
  $('backBtn').hidden = view === 'home';
  if (view === 'home') $('title').innerHTML = '<span class="brand">Thumb Shooters UK Winter Portsmouth Postal</span><span class="sub">Scorer</span>';
  else $('title').textContent = {score:'Scoring', sum:'Round complete', help:'Rules & help'}[view];
  $('helpBtn').hidden = view === 'help';
  if (view === 'score') lockScreen(); else releaseScreen();
  if (view === 'home') renderHome();
}
async function lockScreen(){ try{ wakeLock = await navigator.wakeLock?.request('screen'); }catch(e){} }
function releaseScreen(){ try{ wakeLock?.release(); }catch(e){} wakeLock = null; }
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && $('v-score').classList.contains('on')) lockScreen(); });

function renderHome(){
  renderArchers();
  $('pName').value = profile.name; renderBows();
  $('pArrows').value = profile.arrows; $('pEnd').value = String(profile.end);
  renderSeason();
  $('roundsSeason').textContent = seasonName(viewSeason);
  const list = myRounds().filter(r => seasonOf(r.date) === viewSeason).sort((a,b) => b.date.localeCompare(a.date) || b.created - a.created);
  $('roundList').innerHTML = list.length ? list.map(r => {
    const s = stats(r), complete = s.n === ARROWS;
    const d = new Date(r.date+'T12:00');
    return `<li data-id="${r.id}"><span class="big">${s.total}</span>
      <span class="meta"><b>${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}</b>${r.created > 1 ? ` · ${new Date(r.created).toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'})}` : ''}${complete?'':` <span class="pill">${s.n}/60</span>`}<br>
      ${s.hits} hits · ${s.tens} tens · ${esc(r.bow || r.arrows)}</span><span>›</span></li>`;
  }).join('') : `<li class="empty">${viewSeason === thisSeason ? 'No rounds yet. Tap “Start new round”.' : 'No rounds this season.'}</li>`;
  $('roundList').querySelectorAll('li[data-id]').forEach(li => li.onclick = () => openRound(li.dataset.id));
}
const esc = s => String(s??'').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));

$('pName').addEventListener('input', e => {
  profile.name = e.target.value;
  // renaming an archer (e.g. fixing a typo) renames their rounds too
  for (const r of myRounds()) r.name = profile.name.trim();
  saveArchers(); saveRounds(); renderArchers();
});
$('pArrows').addEventListener('input', e => { profile.arrows = e.target.value; saveArchers(); });

// ---------- archers ----------
// In-app "tap twice" confirmation (native confirm() pop-ups are blocked in some browsers/webviews).
// First tap arms the button for a few seconds; returns true only on the second tap.
function twoTap(btn, message){
  if (btn.dataset.armed){ disarm(btn); return true; }
  btn.dataset.armed = btn.textContent;
  btn.textContent = btn.id === 'delRound' ? 'Tap again to delete' : 'Confirm';
  btn.classList.add('armed');
  toast(message);
  btn._t = setTimeout(() => disarm(btn), 4000);
  return false;
}
function disarm(btn){
  if (!btn.dataset.armed) return;
  clearTimeout(btn._t); btn.textContent = btn.dataset.armed; delete btn.dataset.armed; btn.classList.remove('armed');
}
function renderArchers(){
  disarm($('archerDel'));
  $('archerSel').innerHTML = archers.map(a => `<option value="${a.id}">${esc(a.name.trim() || 'New archer')}</option>`).join('')
    + `<option value="${ADD}">+ Add an archer…</option>`;
  $('archerSel').value = profile.id;
  $('archerDel').hidden = archers.length < 2;
}
$('archerSel').addEventListener('change', e => {
  if (e.target.value === ADD){
    profile = newArcher(); archers.push(profile);
    saveArchers(); viewSeason = thisSeason; renderHome();
    $('pName').focus(); toast('Type the new archer’s name');
    return;
  }
  profile = archers.find(a => a.id === e.target.value); saveArchers(); viewSeason = thisSeason; renderHome();
});
$('archerDel').onclick = () => {
  const n = myRounds().length, who = profile.name.trim() || 'this archer';
  if (!twoTap($('archerDel'), `Tap Confirm to remove ${who}${n ? ` and their ${n} round${n>1?'s':''}` : ''}`)) return;
  rounds = rounds.filter(r => r.archer !== profile.id);
  archers = archers.filter(a => a !== profile); profile = archers[0];
  saveRounds(); saveArchers(); renderHome();
};
$('seasonPrev').onclick = () => { viewSeason--; renderHome(); };
$('seasonNext').onclick = () => { viewSeason++; renderHome(); };
// ---------- saved bows ----------
function renderBows(){
  disarm($('bowDel'));
  const sel = $('pBow');
  sel.innerHTML = profile.bows.map(b => `<option>${esc(b)}</option>`).join('') + `<option value="${ADD}">+ Add a bow…</option>`;
  const has = profile.bows.includes(profile.bow);
  sel.value = has ? profile.bow : ADD;
  $('bowDel').hidden = !has;
  $('bowAdd').hidden = has;
}
$('pBow').addEventListener('change', e => {
  if (e.target.value === ADD){ $('bowAdd').hidden = false; $('bowDel').hidden = true; $('bowNew').focus(); return; }
  profile.bow = e.target.value; saveArchers(); renderBows();
});
function saveBow(){
  const v = $('bowNew').value.trim();
  if (!v){ toast('Type the bow first'); $('bowNew').focus(); return; }
  if (!profile.bows.includes(v)) profile.bows.push(v);
  profile.bow = v; $('bowNew').value = '';
  saveArchers(); renderBows(); toast('Bow saved');
}
$('bowSave').onclick = saveBow;
$('bowNew').addEventListener('keydown', e => { if (e.key === 'Enter') saveBow(); });
$('bowDel').onclick = () => {
  if (!twoTap($('bowDel'), `Tap Confirm to remove "${profile.bow}" (rounds already shot keep it)`)) return;
  profile.bows = profile.bows.filter(b => b !== profile.bow);
  profile.bow = profile.bows[0] || '';
  saveArchers(); renderBows();
};
$('pEnd').addEventListener('change', e => { profile.end = Number(e.target.value); saveArchers(); });

$('newBtn').onclick = () => {
  if (!profile.name.trim()){ toast('Enter the archer’s name first'); $('pName').focus(); return; }
  current = { id: String(Date.now()), created: Date.now(), date: localDate(), archer: profile.id, name: profile.name.trim(),
    bow: profile.bow.trim(), arrows: profile.arrows, end: profile.end, scorer:'', scores: Array(ARROWS).fill(null) };
  rounds.push(current); saveRounds(); sel = null; viewSeason = thisSeason;
  show('score'); renderScore();
};
function openRound(id){
  current = rounds.find(r => r.id === id); sel = null;
  if (!current) return;
  if (filled(current).length === ARROWS){ show('sum'); renderSummary(); }
  else { show('score'); renderScore(); }
}
// help opens on top of whatever screen you are on; back returns to it
let beforeHelp = 'home';
$('helpBtn').onclick = () => {
  beforeHelp = document.querySelector('.view.on').id.slice(2);
  history.pushState({v:'help'}, ''); _show('help'); $('main').scrollTop = 0;
};
function closeHelp(){
  _show(beforeHelp);
  if (beforeHelp === 'score') renderScore(); else if (beforeHelp === 'sum') renderSummary();
}
function goHome(){
  // drop rounds that were started but never scored
  if (current && filled(current).length === 0){ rounds = rounds.filter(r => r !== current); saveRounds(); }
  show('home');
}
$('backBtn').onclick = $('doneBtn').onclick = () => history.state?.v === 'home' ? goHome() : history.back();
// (from help, history.back() lands on popstate → closeHelp)

// ---------- scoring screen ----------
const KEYS = [10,9,8,7,6,5,4,3,2,1,0,'undo'];
$('pad').innerHTML = KEYS.map(k => k === 'undo'
  ? '<button class="undo" data-k="undo">⌫ Undo</button>'
  : `<button class="v${k}" data-k="${k}">${fmt(k)}</button>`).join('');
$('pad').addEventListener('click', e => {
  const b = e.target.closest('button'); if (!b) return;
  navigator.vibrate?.(8);
  b.dataset.k === 'undo' ? undo() : enter(Number(b.dataset.k));
});

function nextIndex(){ return current.scores.indexOf(null); }
function enter(v){
  if (sel !== null){ current.scores[sel] = v; sel = null; }
  else {
    const i = nextIndex(); if (i < 0) return;
    current.scores[i] = v;
  }
  saveRounds(); renderScore();
  if (nextIndex() < 0){ setTimeout(() => { show('sum'); renderSummary(); }, 350); }
}
function undo(){
  if (sel !== null){ sel = null; renderScore(); return; }
  const f = current.scores.map((v,i) => v === null ? -1 : i).filter(i => i >= 0);
  if (!f.length) return;
  current.scores[f[f.length-1]] = null;
  saveRounds(); renderScore();
}

function renderScore(){
  const r = current, s = stats(r), map = slotMap(r), next = nextIndex();
  $('tTotal').textContent = s.total; $('tHits').textContent = s.hits;
  $('tTens').textContent = s.tens; $('tArrows').textContent = `${s.n}/60`;
  // on screen: one line per 6 arrows (half a dozen) so cells stay thumb-sized
  let html = '', running = 0;
  for (let row = 0; row < 10; row++){
    html += `<div class="srow"><span class="n">${row+1}</span>`;
    let et = 0, any = false;
    for (let j = 0; j < 6; j++){
      const i = map[row*6 + j], v = r.scores[i];
      if (v !== null){ et += v; any = true; }
      const cls = ['cell', v !== null ? 'v'+v : '', i === sel ? 'sel' : '', sel === null && i === next ? 'cur' : '',
        ''].join(' ');
      if (r.end === 3 && j === 3) html += '<span></span>';
      html += `<div class="${cls}" data-i="${i}">${fmt(v)}</div>`;
    }
    running += et;
    html += `<span class="et">${any ? et : ''}</span><span class="rt">${any ? running : ''}</span></div>`;
  }
  $('sheet').classList.toggle('e3', r.end === 3);
  $('sheet').innerHTML = `<div class="srow shead"><span></span><span style="grid-column:span ${r.end === 3 ? 7 : 6}">Arrows</span><span>E/T</span><span>Total</span></div>` + html;
  $('sheet').querySelectorAll('.cell').forEach(c => c.onclick = () => {
    const i = Number(c.dataset.i);
    if (r.scores[i] === null) return;
    sel = sel === i ? null : i; renderScore();
  });
  $('endInfo').textContent = sel !== null
    ? 'Correcting the highlighted arrow – tap a score (or Undo to cancel)'
    : next < 0 ? 'Round complete' : `End ${Math.floor(next / r.end) + 1} of ${ARROWS / r.end} · arrow ${next % r.end + 1}`;
  const cur = $('sheet').querySelector('.cur, .sel');
  cur?.scrollIntoView({block:'nearest'});
}

// ---------- summary + sheet image ----------
function renderSummary(){
  disarm($('delRound'));
  const s = stats(current);
  $('sTotal').textContent = s.total; $('sHits').textContent = s.hits; $('sTens').textContent = s.tens;
  $('sScorer').value = current.scorer || '';
  $('sheetImg').src = drawSheet(current).toDataURL('image/png');
}
$('sScorer').addEventListener('change', e => { current.scorer = e.target.value.trim(); saveRounds(); renderSummary(); });
$('editRound').onclick = () => { sel = null; show('score'); renderScore(); toast('Tap any arrow to correct it'); };
$('delRound').onclick = () => {
  if (!twoTap($('delRound'), 'Tap again to delete this round for good')) return;
  rounds = rounds.filter(r => r !== current); saveRounds(); current = null; $('backBtn').click();
};

const logoImg = new Image(); logoImg.src = 'logo.png';
function drawSheet(r){
  // header band with the club logo (if logo.png is present), then the sheet itself shifted down
  // drawn in 1000-px-wide layout units, rendered at SCALE× for a sharp picture
  const HEAD = 60, W = 1000, H = 560 + HEAD, SCALE = 3, c = document.createElement('canvas');
  c.width = W * SCALE; c.height = H * SCALE;
  const g = c.getContext('2d'), map = slotMap(r), s = stats(r);
  g.scale(SCALE, SCALE);
  g.fillStyle = '#fff'; g.fillRect(0,0,W,H);
  g.textBaseline = 'middle';
  let tx = 30;
  if (logoImg.complete && logoImg.naturalWidth){
    const lh = 104, lw = lh * logoImg.naturalWidth / logoImg.naturalHeight;
    g.drawImage(logoImg, 24, 8, lw, lh); tx = 24 + lw + 16;
  }
  g.fillStyle = '#111'; g.font = '700 24px system-ui, sans-serif'; g.fillText('THUMB SHOOTERS UK WINTER PORTSMOUTH POSTAL', tx, 46);
  g.fillStyle = '#555'; g.font = '15px system-ui, sans-serif'; g.fillText('Portsmouth score sheet  ·  60cm face at 20 yards', tx, 78);
  g.translate(0, HEAD);
  const d = new Date(r.date+'T12:00');
  const fields = [['NAME', r.name, 30], ['BOW', r.bow || '–', 380], ['DATE', `${d.getDate()} ${MONTHS[d.getMonth()]} ${d.getFullYear()}`, 700]];
  for (const [k,v,x] of fields){
    g.fillStyle = '#555'; g.font = '13px system-ui, sans-serif'; g.fillText(k, x, 80);
    g.fillStyle = '#111'; g.font = '600 18px system-ui, sans-serif'; g.fillText(v, x, 104);
    g.fillRect(x, 118, k === 'DATE' ? 270 : 320, 2);
  }
  g.fillStyle = '#555'; g.font = '13px system-ui, sans-serif';
  g.fillText(`ARROWS: ${r.arrows.toUpperCase()}   ·   ENDS OF ${r.end}`, 30, 146);

  // table
  const x0 = 30, y0 = 170, rh = 42, cw = 42, et = 50, colW = [64, ...Array(6).fill(cw), et, ...Array(6).fill(cw), et, 72, 58, 52, 74];
  const heads = ['DOZEN', ...Array(6).fill(''), 'E/T', ...Array(6).fill(''), 'E/T', 'SCORE', 'HITS', "10'S", 'TOTAL'];
  const xs = [x0]; colW.forEach(w => xs.push(xs[xs.length-1] + w));
  const ringFill = v => v >= 9 ? '#ffe552' : v >= 7 ? '#f65058' : v >= 5 ? '#00b4e4' : v >= 3 ? '#222' : v >= 1 ? '#fff' : '#fff';
  const ringInk = v => (v >= 3 && v <= 8) ? '#fff' : '#111';
  g.fillStyle = '#e4e4e4'; g.fillRect(x0, y0, xs[xs.length-1]-x0, 30);
  g.font = '600 13px system-ui, sans-serif'; g.textAlign = 'center'; g.fillStyle = '#111';
  heads.forEach((h,i) => g.fillText(h, (xs[i]+xs[i+1])/2, y0+15));
  // "ARROWS" centred over each block of six arrow cells
  g.fillText('ARROWS', (xs[1]+xs[7])/2, y0+15); g.fillText('ARROWS', (xs[8]+xs[14])/2, y0+15);
  let running = 0;
  for (let row = 0; row < 5; row++){
    const y = y0 + 30 + row*rh, cy = y + rh/2, vals = [];
    g.fillStyle = '#111'; g.font = '15px system-ui, sans-serif'; g.fillText(String(row+1), (xs[0]+xs[1])/2, cy);
    let col = 1;
    for (let half = 0; half < 2; half++){
      let sum = 0;
      for (let j = 0; j < 6; j++, col++){
        const v = r.scores[map[row*12+half*6+j]];
        vals.push(v);
        if (v !== null){
          sum += v;
          g.fillStyle = ringFill(v); g.fillRect(xs[col]+3, y+3, colW[col]-6, rh-6);
          if (v === 10){ g.lineWidth = 2.5; g.strokeStyle = '#111'; g.strokeRect(xs[col]+4.5, y+4.5, colW[col]-9, rh-9); }
          g.fillStyle = ringInk(v); g.font = '700 18px system-ui, sans-serif'; g.fillText(fmt(v), (xs[col]+xs[col+1])/2, cy+1);
        }
      }
      g.fillStyle = '#111'; g.font = '700 17px system-ui, sans-serif'; g.fillText(String(sum), (xs[col]+xs[col+1])/2, cy+1); col++;
    }
    const f = vals.filter(v => v !== null), sc = f.reduce((a,b)=>a+b,0);
    running += sc;
    [sc, f.filter(v=>v>0).length, f.filter(v=>v===10).length, running].forEach((v,k) =>
      g.fillText(String(v), (xs[col+k]+xs[col+k+1])/2, cy+1));
  }
  // grid lines
  const yEnd = y0 + 30 + 5*rh;
  g.strokeStyle = '#111';
  for (let i = 0; i <= 5; i++){ const y = i === 0 ? y0 : y0+30+i*rh; g.lineWidth = i === 0 || i === 5 ? 2.5 : 1; g.beginPath(); g.moveTo(x0, y); g.lineTo(xs[xs.length-1], y); g.stroke(); }
  g.beginPath(); g.moveTo(x0, y0+30); g.lineTo(xs[xs.length-1], y0+30); g.stroke();
  // lines between single arrow cells start below the header so "ARROWS" isn't crossed
  const inner = [2,3,4,5,6,9,10,11,12,13];
  xs.forEach((x,i) => { g.lineWidth = [0,7,8,14,15,xs.length-1].includes(i) ? 2.5 : 1; g.beginPath(); g.moveTo(x, inner.includes(i) ? y0+30 : y0); g.lineTo(x, yEnd); g.stroke(); });

  // footer totals
  const fx = xs[15], fy = yEnd + 24;
  g.lineWidth = 2; g.font = '600 13px system-ui, sans-serif';
  [['HITS', s.hits], ["10'S", s.tens], ['TOTAL', s.total]].forEach(([h,v],k) => {
    const a = xs[16+k], b = xs[17+k];
    g.strokeRect(a, fy, b-a, 28); g.strokeRect(a, fy+28, b-a, 44);
    g.fillStyle = '#111'; g.font = '600 13px system-ui, sans-serif'; g.fillText(h, (a+b)/2, fy+14);
    g.font = '800 22px system-ui, sans-serif'; g.fillText(String(v), (a+b)/2, fy+50);
  });
  g.textAlign = 'left'; g.font = '13px system-ui, sans-serif'; g.fillStyle = '#555';
  g.fillText("ARCHER", x0, fy+18); g.fillText("SCORER", x0, fy+56);
  g.fillStyle = '#111'; g.font = 'italic 18px Georgia, serif';
  g.fillText(r.name, x0+80, fy+16); g.fillText(r.scorer || '', x0+80, fy+54);
  g.fillRect(x0+76, fy+30, 400, 1.5); g.fillRect(x0+76, fy+68, 400, 1.5);
  return c;
}

const fileName = r => `winter-portsmouth-postal-${r.name.replace(/\W+/g,'_')}-${r.date}.png`;

const sheetBlob = r => new Promise(res => drawSheet(r).toBlob(res, 'image/png'));
// ---------- backup ----------
const BACKUP_APP = 'tsuk-portsmouth-scorer';
$('exportBtn').onclick = () => {
  const data = { app: BACKUP_APP, version: 1, exported: new Date().toISOString(), archers, rounds };
  downloadBlob(new Blob([JSON.stringify(data, null, 1)], {type:'application/json'}), `portsmouth-scorer-backup-${localDate()}.json`);
  toast('Backup saved to Downloads');
};
$('importBtn').onclick = () => $('importFile').click();
$('importFile').addEventListener('change', async e => {
  const f = e.target.files[0]; e.target.value = '';
  if (!f) return;
  let data;
  try{ data = JSON.parse(await f.text()); }catch(err){ toast('That file is not a backup'); return; }
  if (data?.app !== BACKUP_APP || !Array.isArray(data.archers) || !Array.isArray(data.rounds)){ toast('That file is not a Portsmouth Scorer backup'); return; }
  const str = v => typeof v === 'string' ? v : '';
  const validRound = r => r && typeof r.id === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(r.date) && (r.end === 3 || r.end === 6)
    && Array.isArray(r.scores) && r.scores.length === ARROWS && r.scores.every(v => v === null || (Number.isInteger(v) && v >= 0 && v <= 10));
  // merge: archers matched by id, then by name; rounds already on this device are skipped
  const idMap = {}; let addA = 0, addR = 0, skipR = 0, bad = 0;
  for (const a of data.archers){
    if (!a || typeof a.id !== 'string') continue;
    const name = str(a.name).trim();
    let mine = archers.find(x => x.id === a.id) || (name && archers.find(x => x.name.trim() === name));
    if (!mine){
      mine = Object.assign(newArcher(name), { id: a.id, bow: str(a.bow), arrows: str(a.arrows) || 'Wood', end: a.end === 6 ? 6 : 3,
        bows: Array.isArray(a.bows) ? a.bows.filter(b => typeof b === 'string') : [] });
      archers.push(mine); addA++;
    } else if (Array.isArray(a.bows)){
      for (const b of a.bows) if (typeof b === 'string' && !mine.bows.includes(b)) mine.bows.push(b);
    }
    idMap[a.id] = mine.id;
  }
  for (const r of data.rounds){
    if (!validRound(r) || !idMap[r.archer]){ bad++; continue; }
    if (rounds.some(x => x.id === r.id)){ skipR++; continue; }
    rounds.push({ id: r.id, created: Number(r.created) || 1, date: r.date, archer: idMap[r.archer], name: str(r.name),
      bow: str(r.bow), arrows: str(r.arrows) || 'Wood', end: r.end, scorer: str(r.scorer), scores: r.scores });
    addR++;
  }
  // an empty unnamed archer left over from first start isn't useful once real archers arrive
  if (addA && archers.length > 1){
    const blank = archers.filter(a => !a.name.trim() && !rounds.some(r => r.archer === a.id));
    archers = archers.filter(a => !blank.includes(a));
    if (!archers.includes(profile)) profile = archers[0];
  }
  saveArchers(); saveRounds(); renderHome();
  toast(`Restored ${addR} round${addR === 1 ? '' : 's'}${addA ? ` and ${addA} archer${addA === 1 ? '' : 's'}` : ''}`
    + (skipR ? ` · ${skipR} already here` : '') + (bad ? ` · ${bad} unreadable` : ''));
});

function downloadBlob(blob, name){
  const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; a.click();
  setTimeout(() => URL.revokeObjectURL(a.href), 2000);
}
$('saveImg').onclick = async () => {
  downloadBlob(await sheetBlob(current), fileName(current));
  toast('Picture saved to Downloads');
};

let tt;
function toast(msg){ const t = $('toast'); t.textContent = msg; t.classList.add('on'); clearTimeout(tt); tt = setTimeout(() => t.classList.remove('on'), 2200); }

// Android back button / gesture returns home instead of leaving the app
history.replaceState({v:'home'}, '');
const _show = show;
show = function(view){ _show(view); if (view !== 'home' && history.state?.v === 'home') history.pushState({v:view}, ''); };
window.addEventListener('popstate', () => {
  if ($('v-help').classList.contains('on')) closeHelp();
  else if (!$('v-home').classList.contains('on')) goHome();
});

if ('serviceWorker' in navigator && location.protocol !== 'file:') navigator.serviceWorker.register('sw.js').catch(()=>{});
show('home');
</script>
</body>
</html>