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>
This commit is contained in:
Me Here 2026-06-07 09:32:28 -05:00
parent 5b11363520
commit 812a69942f
2 changed files with 104 additions and 32 deletions

View file

@ -43,22 +43,39 @@
.icon{ flex:0 0 auto; width:40px; height:40px; border-radius:50%; display:flex; align-items:center; justify-content:center; .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); } font-size:17px; cursor:pointer; color:var(--txt); background:rgba(127,139,154,.14); border:1px solid var(--panel-bd); }
.summary{ color:var(--muted); font-size:13px; margin:2px 2px 14px; }
.card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; } .card{ background:var(--panel-bg); border:1px solid var(--panel-bd); border-radius:14px; padding:14px; margin-bottom:14px; }
.chead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; flex-wrap:wrap; } .seclabel{ font-size:11px; letter-spacing:.1em; text-transform:uppercase; color:var(--muted); margin:6px 2px 8px; }
.chead .when{ font-size:15px; font-weight:600; }
.chead .del{ background:transparent; border:1px solid var(--panel-bd); color:var(--muted); border-radius:9px; padding:6px 11px; font-size:12px; cursor:pointer; } /* current-track aggregate (compare across sessions) */
.chead .del:hover{ color:#ff7a7a; border-color:#ff7a7a; } #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{ font-size:13px; color:var(--muted); margin:4px 0 12px; }
.stat b{ color:var(--txt); } .stat b{ color:var(--txt); }
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; }
table{ width:100%; border-collapse:collapse; font-size:14px; } 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,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; } 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; } td.num,th.num{ text-align:right; font-variant-numeric:tabular-nums; }
tr:nth-child(even) td{ background:var(--row); } tr:nth-child(even) td{ background:var(--row); }
tfoot td{ font-weight:700; border-top:2px solid var(--panel-bd); border-bottom:none; } 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{ text-align:center; color:var(--muted); padding:60px 16px; }
.empty .big{ font-size:46px; opacity:.5; } .empty .big{ font-size:46px; opacity:.5; }
.foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; } .foot{ text-align:center; color:var(--muted); font-size:12px; margin-top:24px; }
@ -73,8 +90,17 @@
<h1>Practice sessions</h1> <h1>Practice sessions</h1>
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div> <div class="icon" id="themeBtn" title="Theme" aria-label="Theme"></div>
</header> </header>
<div class="summary" id="summary"></div>
<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 id="list"></div>
<div class="foot"> <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" /> <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> &nbsp;PolyMeter <span id="appVersion"></span>
@ -84,51 +110,93 @@
<script> <script>
const APP_VERSION = "v0.0.1-dev"; const APP_VERSION = "v0.0.1-dev";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const LS_SESSIONS = "metronome.sessions"; 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 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 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; 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"); } 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 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; }
/* aggregate a session's segments → one row per track */ /* per-track aggregate of one session's segments → one row per track */
function aggregate(seg){ function aggregate(seg){
const by={}; 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); }); (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); return Object.values(by).sort((x,y)=>y.sec-x.sec);
} }
function bpmRange(b){ if(!b.length) return "—"; const lo=Math.min(...b), hi=Math.max(...b); return lo===hi?String(lo):lo+""+hi; } /* 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)); }
function render(){ /* ----- 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 sessions=lsGet(LS_SESSIONS,[]);
const list=$("list"); list.innerHTML=""; const list=$("list"); list.innerHTML="";
if(!sessions.length){ if(!sessions.length){
$("summary").textContent=""; $("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>'; 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; return;
} }
const totalClock = sessions.reduce((a,s)=>a+(s.clockSec||0),0); $("listLabel").style.display="";
$("summary").innerHTML = sessions.length+" session"+(sessions.length>1?"s":"")+" · "+fmt(totalClock)+" total practice time";
sessions.forEach((s)=>{ sessions.forEach((s)=>{
const rows=aggregate(s.segments); const rows=aggregate(s.segments), practiced=rows.reduce((a,r)=>a+r.sec,0);
const practiced=rows.reduce((a,r)=>a+r.sec,0); const d=document.createElement("details"); d.className="sess";
const card=document.createElement("div"); card.className="card"; d.innerHTML =
const when=new Date(s.at).toLocaleString(); '<summary><span class="when">'+esc(whenLong(s.at))+'</span>'+
card.innerHTML = '<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="chead"><span class="when">'+esc(when)+'</span><button class="del">Delete</button></div>'+ '<div class="sbody">'+
'<div class="stat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</div>'+ '<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>'+ '<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>'+ '<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("")+ 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>'; '</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>';
card.querySelector(".note").addEventListener("input",(e)=>{ saveNote(s.at, e.target.value); }); d.querySelector(".note").addEventListener("input",(e)=>saveNote(s.at, e.target.value));
card.querySelector(".del").addEventListener("click",()=>{ d.querySelector(".del").addEventListener("click",()=>{
if(!confirm("Delete this practice session? This can't be undone.")) return; 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(); lsSet(LS_SESSIONS, lsGet(LS_SESSIONS,[]).filter((x)=>x.at!==s.at)); render();
}); });
list.appendChild(card); list.appendChild(d);
}); });
} }
let saveTimer=null; let saveTimer=null;
@ -137,6 +205,8 @@ function saveNote(at, text){
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); 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@*/ /*@BUILD:include:src/chrome.js@*/
render(); render();
</script> </script>

View file

@ -382,9 +382,11 @@ function buildDetail(){
if(ramp.on) add("ramp "+ramp.startBpm+"→ +"+ramp.amount+"/"+ramp.everyBars+"bar","r"); if(ramp.on) add("ramp "+ramp.startBpm+"→ +"+ramp.amount+"/"+ramp.everyBars+"bar","r");
if(trainer.on) add("gap "+trainer.playBars+"/"+trainer.muteBars+" play/mute","g"); if(trainer.on) add("gap "+trainer.playBars+"/"+trainer.muteBars+" play/mute","g");
} }
let lastCur=null;
function renderInfo(){ function renderInfo(){
if(!editingBpm) $("bpm").textContent=state.bpm; if(!editingBpm) $("bpm").textContent=state.bpm;
const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx); const ts=$("trkSel"); if(ts && ts.value!==String(idx)) ts.value=String(idx);
const nm=currentName(); if(nm!==lastCur){ lastCur=nm; lsSet("metronome.curtrack",nm); } // sessions page reads this
buildDetail(); updateStatus(); buildDetail(); updateStatus();
} }
function updateStatus(){ function updateStatus(){