Portsmouth Postal Scorer / index.html
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"" 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
