metronome/mobile-sessions.html
Me Here 812a69942f pm-mobile: sessions page — per-track comparison + collapsible sessions
- Top section aggregates the CURRENT track across all sessions (track picker
  defaults to the metronome's current track, persisted via metronome.curtrack):
  total time / plays / bpm range, plus a per-session comparison table so you can
  watch a single track progress across days.
- Each session is now a collapsible <details>: the summary shows a friendly
  timestamp ("Fri Jun 16 at 2:46 PM") with total/practiced/track-count; the note
  + per-track aggregate table + delete live in the expanded body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 09:32:28 -05:00

214 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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" />
&nbsp;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)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[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>