Clean, dependency-light front page. Only three things ship here: - index.html — two-button landing: Mobile -> mobile.html, Desktop -> pm_e-2.html - mobile.html — touch-first PWA (+ mobile-sessions.html practice journal) - pm_e-2.html — engraved-notation editor build.sh/deploy.sh trimmed to just these; deploy mirrors dist/ to the web root with rsync --delete. README/CLAUDE.md rewritten for the slim scope. The full project (PM_E-1 editor, embeddable widget, all hardware form-factor pages, Pico firmware editions, the Rust port, and the KiCad/SPICE hardware design) is preserved on the `concepts` branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
214 lines
13 KiB
HTML
214 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||
<title>VARASYS PolyMeter — Practice sessions</title>
|
||
<link rel="manifest" href="/manifest.webmanifest" />
|
||
<meta name="theme-color" content="#eef3f9" media="(prefers-color-scheme: light)" />
|
||
<meta name="theme-color" content="#05070a" media="(prefers-color-scheme: dark)" />
|
||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||
<meta name="apple-mobile-web-app-title" content="PolyMeter" />
|
||
<link rel="apple-touch-icon" href="/icon-180.png" />
|
||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,@BUILD:favicon@" />
|
||
<script>
|
||
(function(){ try{
|
||
var p = localStorage.getItem("metronome.theme");
|
||
if (p!=="light" && p!=="dark" && p!=="system") p = "system";
|
||
document.documentElement.dataset.theme = p==="system" ? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
|
||
} catch(e){ document.documentElement.dataset.theme = "dark"; } })();
|
||
</script>
|
||
<style>
|
||
/*@BUILD:include:src/base.css@*/
|
||
:root{
|
||
--bg1:#12151c; --bg2:#05070a; --txt:#e7edf5; --muted:#8b96a5; --link:#6cb6ff;
|
||
--panel-bg:#161b22; --panel-bd:#2a313c; --field-bg:#0e1116; --field-bd:#222a36;
|
||
--cyan:#0AB3F7; --amber:#ffd166; --row:#0e1218;
|
||
}
|
||
:root[data-theme="light"]{
|
||
--bg1:#eef3f9; --bg2:#cfd9e6; --txt:#10202f; --muted:#5c6776; --link:#1769c4;
|
||
--panel-bg:#ffffff; --panel-bd:#d2dae4; --field-bg:#f1f4f8; --field-bd:#cdd6e0; --row:#f4f7fb;
|
||
}
|
||
html,body{ height:100%; }
|
||
body{ margin:0; min-height:100%; color:var(--txt); background:radial-gradient(circle at 50% -10%, var(--bg1), var(--bg2));
|
||
font-family:"Segoe UI", Roboto, Helvetica, Arial, sans-serif; -webkit-text-size-adjust:100%; overscroll-behavior-y:none; }
|
||
.wrap{ max-width:760px; margin:0 auto;
|
||
padding:max(10px,env(safe-area-inset-top)) max(14px,env(safe-area-inset-right)) max(28px,env(safe-area-inset-bottom)) max(14px,env(safe-area-inset-left)); }
|
||
header{ display:flex; align-items:center; gap:12px; position:sticky; top:0; z-index:5; padding:8px 0 10px;
|
||
background:linear-gradient(180deg, var(--bg1) 70%, transparent); }
|
||
.back{ flex:0 0 auto; display:flex; align-items:center; gap:6px; text-decoration:none; color:var(--txt);
|
||
background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); border-radius:10px; padding:9px 13px; font-size:15px; }
|
||
header h1{ flex:1 1 auto; font-size:18px; margin:0; }
|
||
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center;
|
||
font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
|
||
|
||
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
|
||
.seclabel{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:6px 2px 8px; }
|
||
|
||
/* current-track aggregate (compare across sessions) */
|
||
#trackAgg .ta-head{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:4px; }
|
||
#taSel{ flex:1 1 auto; min-width:0; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd);
|
||
border-radius:10px; padding:9px 8px; font-size:16px; font-weight:600; }
|
||
.stat{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
|
||
.stat b{ color:var(--txt); }
|
||
|
||
table{ width:100%; border-collapse:collapse; font-size:14px; }
|
||
th,td{ text-align:left; padding:8px 8px; border-bottom:1px solid var(--panel-bd); }
|
||
th{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--muted); font-weight:600; }
|
||
td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
|
||
tr:nth-child(even) td{ background:var(--row); }
|
||
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; }
|
||
|
||
/* session list — collapsible */
|
||
details.sess{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; margin-bottom:12px; overflow:hidden; }
|
||
details.sess > summary{ list-style:none; cursor:pointer; padding:13px 14px; display:flex; flex-direction:column; gap:3px; }
|
||
details.sess > summary::-webkit-details-marker{ display:none; }
|
||
summary .when{ font-size:15px; font-weight:600; display:flex; align-items:center; gap:9px; }
|
||
summary .when::before{ content:"▸"; color:var(--muted); font-size:13px; transition:transform .15s; }
|
||
details[open] > summary .when::before{ transform:rotate(90deg); }
|
||
summary .sstat{ font-size:12px; color:var(--muted); padding-left:22px; }
|
||
summary .sstat b{ color:var(--txt); }
|
||
.sbody{ padding:2px 14px 14px; }
|
||
.sbody .brow{ display:flex; justify-content:flex-end; margin-bottom:10px; }
|
||
.del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; }
|
||
.del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
|
||
textarea.note{ width:100%; background:var(--field-bg); color:var(--txt); border:1px solid var(--field-bd); border-radius:10px;
|
||
padding:10px; font-family:inherit; font-size:14px; resize:vertical; min-height:46px; margin-bottom:12px; }
|
||
|
||
.empty{ text-align:center; color:var(--muted); padding:60px 16px; }
|
||
.empty .big{ font-size:46px; opacity:.5; }
|
||
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
|
||
.foot img{ height:16px; vertical-align:middle; opacity:.85; }
|
||
[data-theme="light"] .logo-dark{ display:none; } [data-theme="dark"] .logo-light{ display:none; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<a class="back" href="/mobile.html">‹ Metronome</a>
|
||
<h1>Practice sessions</h1>
|
||
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme">◐</div>
|
||
</header>
|
||
|
||
<div id="trackAgg" class="card" style="display:none">
|
||
<div class="seclabel">This track — across all sessions</div>
|
||
<div class="ta-head"><select id="taSel"></select></div>
|
||
<div class="stat" id="taStat"></div>
|
||
<table id="taTable"></table>
|
||
</div>
|
||
|
||
<div class="seclabel" id="listLabel" style="display:none">Sessions</div>
|
||
<div id="list"></div>
|
||
|
||
<div class="foot">
|
||
<img class="logo-dark" src="data:image/png;base64,@BUILD:logo-dark@" alt="VARASYS" /><img class="logo-light" src="data:image/png;base64,@BUILD:logo-light@" alt="VARASYS" />
|
||
PolyMeter <span id="appVersion"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const APP_VERSION = "v0.0.1-dev";
|
||
const $ = (id) => document.getElementById(id);
|
||
const LS_SESSIONS = "metronome.sessions", LS_CURTRACK = "metronome.curtrack";
|
||
function lsGet(k,fb){ try{ const v=localStorage.getItem(k); return v?JSON.parse(v):fb; }catch(e){ return fb; } }
|
||
function lsSet(k,v){ try{ localStorage.setItem(k,JSON.stringify(v)); }catch(e){} }
|
||
function lsGetRaw(k){ try{ return localStorage.getItem(k)||""; }catch(e){ return ""; } }
|
||
function fmt(sec){ sec=Math.max(0,Math.round(sec)); const h=Math.floor(sec/3600), m=Math.floor((sec%3600)/60), s=sec%60;
|
||
return (h?h+":"+String(m).padStart(2,"0"):m)+":"+String(s).padStart(2,"0"); }
|
||
function esc(s){ return String(s).replace(/[&<>"]/g,(c)=>({"&":"&","<":"<",">":">",'"':"""}[c])); }
|
||
function whenLong(ms){ const d=new Date(ms);
|
||
const wd=d.toLocaleDateString(undefined,{weekday:"short"}), mo=d.toLocaleDateString(undefined,{month:"short"});
|
||
const t=d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"});
|
||
return wd+" "+mo+" "+d.getDate()+" at "+t; } // "Fri Jun 16 at 2:46 PM"
|
||
function whenShort(ms){ const d=new Date(ms);
|
||
return d.toLocaleDateString(undefined,{month:"short",day:"numeric"})+" · "+d.toLocaleTimeString(undefined,{hour:"numeric",minute:"2-digit"}); }
|
||
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+"–"+hi; }
|
||
|
||
/* per-track aggregate of one session's segments → one row per track */
|
||
function aggregate(seg){
|
||
const by={};
|
||
(seg||[]).forEach((s)=>{ const k=s.name||"(unnamed)"; const a=by[k]||(by[k]={name:k,sec:0,plays:0,bpms:[]}); a.sec+=s.sec; a.plays++; if(s.bpm) a.bpms.push(s.bpm); });
|
||
return Object.values(by).sort((x,y)=>y.sec-x.sec);
|
||
}
|
||
/* one track across all sessions → one row per session that included it (newest first) */
|
||
function trackRows(name){
|
||
const out=[];
|
||
lsGet(LS_SESSIONS,[]).forEach((s)=>{
|
||
let sec=0, plays=0, bpms=[];
|
||
(s.segments||[]).forEach((g)=>{ if((g.name||"(unnamed)")===name){ sec+=g.sec; plays++; if(g.bpm) bpms.push(g.bpm); } });
|
||
if(sec>0) out.push({ at:s.at, sec, plays, bpms });
|
||
});
|
||
return out;
|
||
}
|
||
function allTrackNames(){ const set=new Set(); lsGet(LS_SESSIONS,[]).forEach((s)=>(s.segments||[]).forEach((g)=>set.add(g.name||"(unnamed)"))); return [...set].sort((a,b)=>a.localeCompare(b)); }
|
||
|
||
/* ----- top: current-track comparison across sessions ----- */
|
||
let selTrack=null;
|
||
function renderTrackAgg(){
|
||
const names=allTrackNames();
|
||
if(!names.length){ $("trackAgg").style.display="none"; return; }
|
||
$("trackAgg").style.display="";
|
||
const cur=lsGetRaw(LS_CURTRACK).replace(/^"|"$/g,""); // stored JSON-encoded string
|
||
if(selTrack===null) selTrack = names.includes(cur) ? cur : names[0];
|
||
if(!names.includes(selTrack)) selTrack=names[0];
|
||
// selector
|
||
const sel=$("taSel"); sel.innerHTML="";
|
||
names.forEach((n)=>{ const o=document.createElement("option"); o.value=n; o.textContent=n+(n===cur?" (current)":""); sel.appendChild(o); });
|
||
sel.value=selTrack;
|
||
// stats + per-session table
|
||
const rows=trackRows(selTrack);
|
||
const totSec=rows.reduce((a,r)=>a+r.sec,0), totPlays=rows.reduce((a,r)=>a+r.plays,0), allb=rows.flatMap((r)=>r.bpms);
|
||
$("taStat").innerHTML = rows.length+" session"+(rows.length===1?"":"s")+" · total <b>"+fmt(totSec)+"</b> · "+totPlays+" play"+(totPlays===1?"":"s")+" · "+bpmRange(allb)+" bpm";
|
||
$("taTable").innerHTML =
|
||
'<thead><tr><th>Session</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||
(rows.length ? rows.map((r)=>'<tr><td>'+esc(whenShort(r.at))+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")
|
||
: '<tr><td colspan="4" style="color:var(--muted)">No sessions for this track yet.</td></tr>')+
|
||
'</tbody>';
|
||
}
|
||
$("taSel").addEventListener("change",(e)=>{ selTrack=e.target.value; renderTrackAgg(); });
|
||
|
||
/* ----- session list (collapsible) ----- */
|
||
function renderList(){
|
||
const sessions=lsGet(LS_SESSIONS,[]);
|
||
const list=$("list"); list.innerHTML="";
|
||
if(!sessions.length){
|
||
$("listLabel").style.display="none";
|
||
list.innerHTML='<div class="empty"><div class="big">𝄞</div><p>No practice sessions yet.<br>On the metronome, press <b>Practice</b> to start a session, then <b>Stop</b> when you\'re done — it\'ll be saved here.</p></div>';
|
||
return;
|
||
}
|
||
$("listLabel").style.display="";
|
||
sessions.forEach((s)=>{
|
||
const rows=aggregate(s.segments), practiced=rows.reduce((a,r)=>a+r.sec,0);
|
||
const d=document.createElement("details"); d.className="sess";
|
||
d.innerHTML =
|
||
'<summary><span class="when">'+esc(whenLong(s.at))+'</span>'+
|
||
'<span class="sstat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</span></summary>'+
|
||
'<div class="sbody">'+
|
||
'<div class="brow"><button class="del">Delete session</button></div>'+
|
||
'<textarea class="note" placeholder="Add a note about this session — what you worked on, how it felt…">'+esc(s.note||"")+'</textarea>'+
|
||
'<table><thead><tr><th>Track</th><th class="num">Time</th><th class="num">Plays</th><th class="num">BPM</th></tr></thead><tbody>'+
|
||
rows.map((r)=>'<tr><td>'+esc(r.name)+'</td><td class="num">'+fmt(r.sec)+'</td><td class="num">'+r.plays+'</td><td class="num">'+bpmRange(r.bpms)+'</td></tr>').join("")+
|
||
'</tbody><tfoot><tr><td>Total</td><td class="num">'+fmt(practiced)+'</td><td class="num">'+rows.reduce((a,r)=>a+r.plays,0)+'</td><td class="num"></td></tr></tfoot></table>'+
|
||
'</div>';
|
||
d.querySelector(".note").addEventListener("input",(e)=>saveNote(s.at, e.target.value));
|
||
d.querySelector(".del").addEventListener("click",()=>{
|
||
if(!confirm("Delete this practice session? This can't be undone.")) return;
|
||
lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
|
||
});
|
||
list.appendChild(d);
|
||
});
|
||
}
|
||
let saveTimer=null;
|
||
function saveNote(at, text){
|
||
clearTimeout(saveTimer);
|
||
saveTimer=setTimeout(()=>{ const arr=lsGet(LS_SESSIONS,[]); const it=arr.find((x)=>x.at===at); if(it){ it.note=text; lsSet(LS_SESSIONS,arr); } }, 300);
|
||
}
|
||
|
||
function render(){ renderTrackAgg(); renderList(); }
|
||
|
||
/*@BUILD:include:src/chrome.js@*/
|
||
render();
|
||
</script>
|
||
</body>
|
||
</html>
|