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:
parent
5b11363520
commit
812a69942f
2 changed files with 104 additions and 32 deletions
|
|
@ -43,22 +43,39 @@
|
|||
.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); }
|
||||
|
||||
.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; }
|
||||
.chead{ display:flex; align-items:baseline; justify-content:space-between; gap:10px; flex-wrap:wrap; }
|
||||
.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; }
|
||||
.chead .del:hover{ color:#ff7a7a; border-color:#ff7a7a; }
|
||||
.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); }
|
||||
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; }
|
||||
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; }
|
||||
|
|
@ -73,8 +90,17 @@
|
|||
<h1>Practice sessions</h1>
|
||||
<div class="icon" id="themeBtn" title="Theme" aria-label="Theme">◐</div>
|
||||
</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 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>
|
||||
|
|
@ -84,51 +110,93 @@
|
|||
<script>
|
||||
const APP_VERSION = "v0.0.1-dev";
|
||||
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 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; }
|
||||
|
||||
/* aggregate a session's segments → one row per track */
|
||||
/* 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);
|
||||
}
|
||||
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 list=$("list"); list.innerHTML="";
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
const totalClock = sessions.reduce((a,s)=>a+(s.clockSec||0),0);
|
||||
$("summary").innerHTML = sessions.length+" session"+(sessions.length>1?"s":"")+" · "+fmt(totalClock)+" total practice time";
|
||||
|
||||
$("listLabel").style.display="";
|
||||
sessions.forEach((s)=>{
|
||||
const rows=aggregate(s.segments);
|
||||
const practiced=rows.reduce((a,r)=>a+r.sec,0);
|
||||
const card=document.createElement("div"); card.className="card";
|
||||
const when=new Date(s.at).toLocaleString();
|
||||
card.innerHTML =
|
||||
'<div class="chead"><span class="when">'+esc(when)+'</span><button class="del">Delete</button></div>'+
|
||||
'<div class="stat">Total <b>'+fmt(s.clockSec)+'</b> · practiced <b>'+fmt(practiced)+'</b> · '+rows.length+' track'+(rows.length===1?"":"s")+'</div>'+
|
||||
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>';
|
||||
|
||||
card.querySelector(".note").addEventListener("input",(e)=>{ saveNote(s.at, e.target.value); });
|
||||
card.querySelector(".del").addEventListener("click",()=>{
|
||||
'</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(card);
|
||||
list.appendChild(d);
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
function render(){ renderTrackAgg(); renderList(); }
|
||||
|
||||
/*@BUILD:include:src/chrome.js@*/
|
||||
render();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -382,9 +382,11 @@ function buildDetail(){
|
|||
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");
|
||||
}
|
||||
let lastCur=null;
|
||||
function renderInfo(){
|
||||
if(!editingBpm) $("bpm").textContent=state.bpm;
|
||||
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();
|
||||
}
|
||||
function updateStatus(){
|
||||
|
|
|
|||
Loading…
Reference in a new issue