diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-05 07:21:07 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-05 07:21:07 -0500 |
| commit | 721185f10d1e389ae3816734b7b8174d33900314 (patch) | |
| tree | 0e2a09a50e74e17200efc287e841d9e2bee8d8a3 /docs/prototypes/2026-07-02-timer-panel-prototype-2.html | |
| parent | 0af88d35a6f22c11e346970bec356bae0b74d4a2 (diff) | |
| download | archsetup-721185f10d1e389ae3816734b7b8174d33900314.tar.gz archsetup-721185f10d1e389ae3816734b7b8174d33900314.zip | |
docs(timer): record the shipped redesign and flip spec to IMPLEMENTED
The timer-panel UI/UX redesign built and shipped to dotfiles across five phased commits. This captures the archsetup-side records.
The three design prototypes (the three-directions study, the hero-rack iteration, and the final) land under docs/prototypes, which the spec's Prototype iterations section links. The spec flips DOING to IMPLEMENTED with a history line summarizing the build. The manual-testing checklist is rebuilt around the redesigned panel (repeat timers, recurring alarms with snooze and a ringing state, the configurable pomodoro cycle, the stopwatch sweep dial, locked presets, and bar-tooltip parity), and the two obsolete fuzzel-dialog tests are marked superseded. A dated entry under the closed feature task records the redesign.
Diffstat (limited to 'docs/prototypes/2026-07-02-timer-panel-prototype-2.html')
| -rw-r--r-- | docs/prototypes/2026-07-02-timer-panel-prototype-2.html | 553 |
1 files changed, 553 insertions, 0 deletions
diff --git a/docs/prototypes/2026-07-02-timer-panel-prototype-2.html b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html new file mode 100644 index 0000000..ffd4521 --- /dev/null +++ b/docs/prototypes/2026-07-02-timer-panel-prototype-2.html @@ -0,0 +1,553 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Timer panel — hero + rack (iteration 2) · dupre instrument console</title> +<style> +:root{ + --ground:#151311; --panel:#100f0f; --well:#0a0c0d; --raise:#1a1917; + --gold:#dab53d; --gold-hi:#ffd75f; --silver:#bfc4d0; --cream:#f3e7c5; + --steel:#969385; --dim:#7c838a; --slate:#424f5e; --slate-hi:#54677d; + --wash:#2c2f32; --pass:#74932f; --fail:#cb6b4d; + --mono:"BerkeleyMono Nerd Font","Berkeley Mono","Symbols Nerd Font",monospace; +} +*{box-sizing:border-box;margin:0;padding:0} +html{background:var(--ground)} +body{font-family:var(--mono);color:var(--silver);padding:2.4rem 2rem 5rem;line-height:1.45; + background:radial-gradient(1200px 600px at 70% -10%,#1c1915 0%,transparent 60%),var(--ground)} +.wrap{max-width:1100px;margin:0 auto} +.eyebrow{color:var(--steel);font-size:.72rem;letter-spacing:.28em;text-transform:uppercase} +h1{color:var(--gold);font-size:1.5rem;margin:.35rem 0 .4rem} +.masthead p{color:var(--dim);font-size:.86rem;max-width:92ch} +.masthead p b{color:var(--silver)} +.cols{display:flex;gap:34px;align-items:flex-start;margin-top:1.6rem;flex-wrap:wrap} +.side{flex:1;min-width:300px} +.side h2{color:var(--steel);font-size:.74rem;letter-spacing:.22em;text-transform:uppercase;margin:0 0 .5rem; + display:flex;align-items:center;gap:10px} +.side h2::after{content:"";height:1px;background:var(--wash);flex:1} +.side ul{list-style:none;font-size:.8rem;color:var(--dim);display:flex;flex-direction:column;gap:7px} +.side li{display:flex;gap:9px} +.side li::before{content:"›";color:var(--gold);flex:none} +.side li b{color:var(--silver);font-weight:400} + +/* ---------- faceplate ---------- */ +.panel{width:420px;flex:none;background:linear-gradient(180deg,var(--raise),var(--panel));border:1px solid #262320; + border-radius:14px;padding:15px;position:relative; + box-shadow:inset 0 1px 0 rgba(255,255,255,.05),0 14px 34px rgba(0,0,0,.55)} +.phead{display:flex;align-items:center;gap:10px;margin-bottom:12px} +.phead .brand{color:var(--gold);font-size:.72rem;letter-spacing:.24em;text-transform:uppercase} +.phead .pcount{margin-left:auto;color:var(--dim);font-size:.66rem;letter-spacing:.14em} +.phead .pcount b{color:var(--cream)} + +/* ---------- primitives ---------- */ +.lamp{width:9px;height:9px;border-radius:50%;background:var(--pass);box-shadow:0 0 6px 1px rgba(116,147,47,.55);flex:none} +.lamp.gold{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.lamp.red{background:var(--fail);box-shadow:0 0 6px 1px rgba(203,107,77,.55)} +.lamp.off{background:var(--wash);box-shadow:none} +.lamp.busy{background:var(--gold);animation:pulse .7s ease-in-out infinite} +@keyframes pulse{50%{opacity:.25}} + +.key{font:inherit;font-size:11.5px;letter-spacing:.05em;color:var(--silver);cursor:pointer; + background:linear-gradient(180deg,#23211e,#191715);border:1px solid #33302b;border-bottom-color:#0c0b0a; + border-radius:8px;padding:7px 11px;box-shadow:inset 0 1px 0 rgba(255,255,255,.04),0 2px 3px rgba(0,0,0,.4)} +.key:hover{color:var(--gold);border-color:var(--gold)} +.key:active{transform:translateY(1px)} +.key:disabled{opacity:.4;cursor:default} +.key.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.key.red{color:var(--cream);background:linear-gradient(180deg,#d98a6f,var(--fail));border-color:var(--fail)} +.key.sm{padding:5px 8px;font-size:10.5px;border-radius:7px} +.key.icon{padding:6px 9px;font-size:14px;line-height:1} + +.chip{color:var(--dim);cursor:pointer;border:1px solid #2a2723;background:#141210; + border-radius:14px;font-size:11.5px;padding:4px 10px;letter-spacing:.02em;display:inline-flex;align-items:center} +.chip:hover{color:var(--silver);border-color:var(--slate)} +.chip.on{color:var(--panel);background:linear-gradient(180deg,#f0d879,var(--gold));border-color:var(--gold-hi);font-weight:700} +.chip.lock{padding-right:10px} +.chip .x{color:inherit;opacity:.5;margin-left:6px;font-size:13px} +.chip .x:hover{opacity:1;color:var(--fail)} + +.badge{font-size:.6rem;letter-spacing:.16em;color:var(--panel);background:var(--gold);border-radius:4px;padding:1px 6px;text-transform:uppercase} +.badge.red{background:var(--fail);color:var(--cream)} +.badge.dim{background:var(--wash);color:var(--steel)} +.badge.ghost{background:transparent;border:1px solid var(--slate);color:var(--silver)} + +.seg{display:flex;border:1px solid #33302b;border-radius:8px;overflow:hidden} +.seg button{font:inherit;font-size:11px;color:var(--silver);background:#191715;border:0;border-right:1px solid #33302b; + padding:7px 0;cursor:pointer;flex:1;letter-spacing:.02em} +.seg button:last-child{border-right:0} +.seg button.on{background:linear-gradient(180deg,#f0d879,var(--gold));color:var(--panel);font-weight:700} + +.switch{width:38px;height:20px;border-radius:11px;background:var(--wash);border:1px solid var(--slate);position:relative;cursor:pointer;flex:none} +.switch::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;background:var(--dim);transition:left .15s} +.switch.on{background:var(--slate);border-color:var(--gold)} +.switch.on::after{left:20px;background:var(--gold)} + +.engrave{color:var(--steel);font-size:.58rem;letter-spacing:.26em;text-transform:uppercase; + display:flex;align-items:center;gap:9px;margin:2px 0} +.engrave::before,.engrave::after{content:"";height:1px;background:var(--wash);flex:1} +.engrave::before{max-width:10px} +.engrave .cnt{color:var(--dim);letter-spacing:.1em;text-transform:none} + +.tin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:7px; + padding:7px 9px;width:100%;outline:none} +.tin:focus{border-color:var(--gold)} +.tin::placeholder{color:var(--dim)} +.tin.bad{border-color:var(--fail);color:var(--fail)} +.tin:disabled{opacity:.45} +.numin{font:inherit;font-size:12px;color:var(--cream);background:#0d0f10;border:1px solid #231f18;border-radius:6px; + padding:5px 4px;width:46px;text-align:center;outline:none;font-variant-numeric:tabular-nums} +.numin:focus{border-color:var(--gold)} + +.arm{font:inherit;font-size:11px;color:var(--silver);cursor:pointer;background:#191715;border:1px solid #33302b; + border-radius:7px;padding:6px 9px} +.arm.armed{background:rgba(203,107,77,.14);border-color:var(--fail);color:var(--fail)} + +.ring{border-radius:50%;background:conic-gradient(var(--gold) calc(var(--p)*1%),var(--wash) 0);display:grid;place-items:center;position:relative} +.ring.warn{background:conic-gradient(var(--fail) calc(var(--p)*1%),var(--wash) 0)} +.ring::before{content:"";position:absolute;inset:7px;border-radius:50%;background:var(--well)} +.ring b{position:relative;z-index:1;text-align:center} + +.dots{display:flex;gap:4px;align-items:center} +.dots i{width:7px;height:7px;border-radius:50%;background:var(--wash);flex:none} +.dots i.on{background:var(--steel)} +.dots i.now{background:var(--gold);box-shadow:0 0 6px 1px rgba(218,181,61,.6)} +.dots i.long{outline:1px solid var(--gold);outline-offset:1px} + +/* ---------- HERO (top) ---------- */ +.hero{background:var(--well);border:1px solid #201d17;border-radius:11px;padding:15px;display:flex;gap:15px;align-items:center;margin-bottom:12px} +.hero.fire{animation:firef .6s ease-in-out 3} +@keyframes firef{50%{background:rgba(203,107,77,.22)}} +.hero .rhs{min-width:0;flex:1;display:flex;flex-direction:column;gap:5px} +.hero .htype{display:flex;align-items:center;gap:8px;flex-wrap:wrap} +.hero .htype .g{color:var(--gold);font-size:17px} +.hero .hlabel{color:var(--cream);font-size:15px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.hero .hbig{color:var(--cream);font-size:38px;line-height:1;font-weight:700;font-variant-numeric:tabular-nums} +.hero.paused .hbig{color:var(--steel)} +.hero .hsub{color:var(--dim);font-size:11px;letter-spacing:.05em} +.transport{display:flex;gap:7px;margin-top:3px;flex-wrap:wrap} + +/* ---------- CREATE (middle) ---------- */ +.create{background:var(--well);border:1px solid #201d17;border-radius:10px;padding:11px;margin-bottom:12px} +.create .row{display:flex;gap:7px;align-items:center;margin-top:9px;flex-wrap:wrap} +.chips{display:flex;gap:6px;flex-wrap:wrap;margin-top:9px} +.cfg{margin-top:9px;display:flex;flex-direction:column;gap:7px} +.cfg .crow{display:flex;align-items:center;gap:8px} +.cfg .crow .lbl{width:58px;color:var(--steel);font-size:.58rem;letter-spacing:.14em;text-transform:uppercase;flex:none} +.cfg .crow .u{color:var(--dim);font-size:10px} +.cfg .crow .sl{color:var(--steel);font-size:.58rem;letter-spacing:.1em;text-transform:uppercase;width:9px} + +/* ---------- LIST (bottom) ---------- */ +.qlist{display:flex;flex-direction:column;gap:8px} +.qrow{display:flex;align-items:center;gap:10px;background:#141210;border:1px solid #201d17;border-radius:9px;padding:8px 10px} +.qrow.fire{animation:firef .6s ease-in-out 3} +.qrow .g{color:var(--gold);font-size:16px;width:19px;text-align:center;flex:none} +.qrow .meta{min-width:0;display:flex;flex-direction:column;gap:2px} +.qrow .meta b{color:var(--cream);font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:110px} +.qrow .meta .ty{color:var(--dim);font-size:.56rem;letter-spacing:.12em;text-transform:uppercase} +.qrow .rd{margin-left:auto;font-size:18px;color:var(--cream);font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap} +.qrow.paused .rd{color:var(--steel)} +.qrow .ctrls{display:flex;gap:5px;flex:none} +.empty{color:var(--dim);font-size:12px;text-align:center;padding:14px 6px} + +/* toast */ +.toasts{position:absolute;left:12px;right:12px;bottom:10px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:5} +.toast{font-size:11px;color:var(--cream);background:var(--slate);border-radius:7px;padding:6px 10px;box-shadow:0 4px 12px rgba(0,0,0,.5);animation:tin .2s ease} +.toast.red{background:linear-gradient(180deg,#b25c43,#8f3f2c)} +.toast.gold{background:linear-gradient(180deg,#b79a34,#8a7524);color:var(--panel)} +@keyframes tin{from{opacity:0;transform:translateY(6px)}} + +@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} +</style> +</head> +<body> +<div class="wrap"> +<header class="masthead"> + <div class="eyebrow">archsetup · dupre panel family · timer · iteration 2</div> + <h1>Timer panel — hero + rack</h1> + <p>The rack unit reshaped: the <b>hero</b> from the transport deck rides on top (the primary / bar-slot item, big), + the <b>create strip</b> sits under it, and the <b>queue list</b> runs below. Pomodoro is now a real configurable cycle — + work and rest each with a short and a long duration, a long break every N cycles, auto-advance, and progress dots — + with its default preset locked so it can't be deleted. Everything is live: add, count down, fire + notify, pause, promote, + lap / stop-save. Ideas pulled from Pomofocus, Todoist, and the classic technique (see the notes column).</p> +</header> + +<div class="cols"> + <div class="panel" id="panel"></div> + <div class="side"> + <h2>What changed this pass</h2> + <ul> + <li><b>Layout flipped:</b> hero on top → create strip → list (was list → create).</li> + <li><b>Pomodoro is configurable:</b> Work short/long, Rest short/long, long break every N, auto-advance toggle.</li> + <li><b>Deep cycle:</b> every Nth pomodoro uses the long work + long rest; the rest fill mark the long dots.</li> + <li><b>Default cycle is locked</b> — shipped presets have no ×; only chips you add are deletable.</li> + <li><b>Cycle dots</b> in the hero + row show where you are in the set.</li> + </ul> + <h2 style="margin-top:1.6rem">Borrowed from good pomodoro apps</h2> + <ul> + <li><b>Pomofocus:</b> separate work / short-break / long-break lengths + long-break interval.</li> + <li><b>Auto-start next</b> (Pomofocus, Pomodo): auto-advance rolls into the next phase; off = wait and press start.</li> + <li><b>Todoist / the technique:</b> long break of 15–30m after 4 pomodoros; all durations adjustable.</li> + <li><b>Preset cycles:</b> Classic 25/5/15, Deep 50/10/30, Sprint 15/3/10 — one tap loads the fields.</li> + <li><b>Task label</b> on every item; cycle progress shown as dots.</li> + </ul> + </div> +</div> + +</div> + +<script> +"use strict"; +const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; +const el=(t,c,h)=>{const n=document.createElement(t);if(c)n.className=c;if(h!=null)n.innerHTML=h;return n;}; + +const GL={ timer:'\u{F051B}', alarm:'\u{F0020}', stopwatch:'\u{F13AB}', + pomo_work:'\u{F051C}', pomo_break:'\u{F0176}', paused:'\u{F03E4}', + play:'\u{F040A}', promote:'\u{F0143}', cancel:'\u{F0156}', add:'\u{F0415}', clear:'\u{F0A79}' }; + +/* ---- parsers ---- */ +function parseDuration(v){ + if(v==null) return null; v=String(v).trim().toLowerCase(); if(v==='') return null; + if(/^\d+$/.test(v)) return parseInt(v,10)*60; + if(!/^(\s*\d+\s*[hms])+$/.test(v)) return null; + let m,tot=0; const re=/(\d+)\s*([hms])/g; + while((m=re.exec(v))) tot+= m[2]==='h'?+m[1]*3600 : m[2]==='m'?+m[1]*60 : +m[1]; + return tot>0?tot:null; +} +function resolveAlarm(v,now){ + v=String(v||'').trim().toLowerCase(); + if(v.startsWith('+')){const s=parseDuration(v.slice(1));return s==null?null:now+s;} + if(v==='@hour'||v==='top of hour'){const d=new Date(now*1000);d.setMinutes(0,0,0);d.setHours(d.getHours()+1);return d.getTime()/1000;} + const t=v.match(/^(\d{1,2}):(\d{2})$/); + if(t){const hh=+t[1],mm=+t[2];if(hh>23||mm>59)return null;const d=new Date(now*1000);d.setHours(hh,mm,0,0);let e=d.getTime()/1000;if(e<=now)e+=86400;return e;} + return null; +} +const fmtTime=s=>{s=Math.max(0,Math.floor(s));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60),x=s%60; + return h?`${h}:${String(m).padStart(2,'0')}:${String(x).padStart(2,'0')}`:`${m}:${String(x).padStart(2,'0')}`;}; +const fmtClock=e=>{const d=new Date(e*1000);return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;}; + +/* ---- presets: shipped defaults are locked (no delete) ---- */ +const DEFAULT_PRESETS=()=>({ + timer:[{label:'5m',value:'5m',locked:true},{label:'25m',value:'25m',locked:true},{label:'10m',value:'10m',locked:true}, + {label:'15m',value:'15m',locked:true},{label:'30m',value:'30m',locked:true},{label:'60m',value:'60m',locked:true},{label:'2h',value:'2h',locked:true}], + alarm:[{label:'+30m',value:'+30m',locked:true},{label:'top of hour',value:'@hour',locked:true},{label:'07:00',value:'07:00',locked:true}], + stopwatch:[] +}); +/* pomodoro preset = a full config (minutes). "Classic" is the default cycle — locked. */ +const POMO_PRESETS=[ + {label:'Classic', ws:25, wl:50, rs:5, rl:15, iv:4, locked:true}, + {label:'Deep', ws:50, wl:50, rs:10, rl:30, iv:3, locked:true}, + {label:'Sprint', ws:15, wl:25, rs:3, rl:10, iv:4, locked:true}, +]; +const POMO_DEFAULT={ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}; + +const TYPES=['timer','alarm','stopwatch','pomodoro']; +const COUNTDOWN=['timer','alarm','pomodoro']; +const MAX=10; + +class Engine{ + constructor(){ this.items=[]; this.seq=0; this.primary=null; + this.presets=DEFAULT_PRESETS(); this.pomoPresets=POMO_PRESETS.map(p=>({...p})); } + now(){return Date.now()/1000;} + count(){return this.items.length;} + full(){return this.items.length>=MAX;} + add(type,value,label,cfg){ + if(this.full()) return {ok:false,reason:`queue full (${MAX}/${MAX})`}; + const now=this.now(); this.seq++; const id='t'+this.seq; const it={id,type,label:label||''}; + if(type==='timer'){const s=parseDuration(value);if(s==null)return {ok:false,reason:`bad duration: “${value}”`};it.target=now+s;it.total=s;} + else if(type==='alarm'){const e=resolveAlarm(value,now);if(e==null)return {ok:false,reason:`bad time: “${value}”`};it.target=e;it.total=Math.max(1,e-now);} + else if(type==='pomodoro'){ + const c=cfg||POMO_DEFAULT; + it.cfg={ws:c.ws*60,wl:c.wl*60,rs:c.rs*60,rl:c.rl*60,iv:Math.max(1,c.iv),auto:c.auto!==false}; + it.phase='work'; it.cycle=1; const deep=(1%it.cfg.iv===0); + const len=deep?it.cfg.wl:it.cfg.ws; it.target=now+len; it.total=len; + } + else if(type==='stopwatch'){it.start=now;it.laps=[];} + else return {ok:false,reason:`unknown type: ${type}`}; + this.items.push(it); if(!this.primary) this.primary=id; return {ok:true,id}; + } + find(id){return this.items.find(i=>i.id===id);} + isPaused(it){return it.type==='stopwatch'?it.paused_elapsed!=null:it.paused_left!=null;} + remaining(it,ref){ref=ref==null?this.now():ref; + if(it.type==='stopwatch')return this.isPaused(it)?it.paused_elapsed:ref-it.start; + return this.isPaused(it)?it.paused_left:it.target-ref;} + toggle(id){const it=this.find(id);if(!it)return;const now=this.now(); + if(it.type==='stopwatch'){ if(this.isPaused(it)){it.start=now-it.paused_elapsed;it.paused_elapsed=null;} else it.paused_elapsed=now-it.start; } + else { if(this.isPaused(it)){it.target=now+it.paused_left;it.paused_left=null;it.awaiting=false;} else it.paused_left=it.target-now; } } + cancel(id){const i=this.items.findIndex(x=>x.id===id);if(i<0)return;this.items.splice(i,1);if(this.primary===id)this.primary=null;} + cancelAll(){this.items=[];this.primary=null;} + promote(id){if(this.find(id))this.primary=id;} + cycle(dir){const ids=this.items.map(i=>i.id);if(!ids.length)return;let c=ids.indexOf(this.effectivePrimary());c=c<0?0:c; + this.primary=ids[dir==='prev'?(c-1+ids.length)%ids.length:(c+1)%ids.length];} + lap(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return;it.laps.push({t:this.remaining(it)});} + stopSave(id){const it=this.find(id);if(!it||it.type!=='stopwatch')return null;const run={label:it.label||'run',total:this.remaining(it),laps:it.laps.slice()};this.cancel(id);return run;} + effectivePrimary(){const items=this.items;if(!items.length)return null;const ids=items.map(i=>i.id); + if(ids.includes(this.primary))return this.primary;const now=this.now(); + const acd=items.filter(i=>COUNTDOWN.includes(i.type)&&!this.isPaused(i)); + if(acd.length)return acd.reduce((a,b)=>this.remaining(a,now)<=this.remaining(b,now)?a:b).id; + const asw=items.filter(i=>i.type==='stopwatch'&&!this.isPaused(i));if(asw.length)return asw[0].id;return ids[0];} + sortKey(it){const now=this.now(),p=this.isPaused(it),sw=it.type==='stopwatch',r=this.remaining(it,now); + return sw?[p?3:2,-r,+it.id.slice(1)]:[p?1:0,r,+it.id.slice(1)];} + rows(){const prim=this.effectivePrimary(),now=this.now(); + return this.items.slice().sort((a,b)=>{const ka=this.sortKey(a),kb=this.sortKey(b); + for(let i=0;i<ka.length;i++){if(ka[i]<kb[i])return -1;if(ka[i]>kb[i])return 1;}return 0;}).map(it=>this.row(it,prim,now));} + row(it,prim,now){ + const rem=this.remaining(it,now),paused=this.isPaused(it); + let disp,sub='',warn=false,prog=null,glyph,pomo=null; + if(it.type==='alarm'){disp=fmtClock(it.target);sub='fires '+fmtClock(it.target);prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.alarm;} + else if(it.type==='pomodoro'){ + disp=fmtTime(rem); prog=Math.max(0,Math.min(1,rem/it.total)); + const deep=(it.cycle%it.cfg.iv===0); + const phLabel = it.phase==='work' ? (deep?'long work':'work') + : it.phase==='rest' ? ((it.cycle%it.cfg.iv===0)?'long break':'short break') : it.phase; + sub = it.awaiting ? `ready · start ${it.phase==='work'?'work':'break'}` : `${phLabel} · cycle ${it.cycle}/${it.cfg.iv}`; + glyph = it.phase==='work' ? GL.pomo_work : GL.pomo_break; + pomo={cycle:it.cycle, iv:it.cfg.iv, phase:it.phase, awaiting:!!it.awaiting, deep}; + } + else if(it.type==='stopwatch'){disp=fmtTime(rem);sub=it.laps.length?`${it.laps.length} lap${it.laps.length>1?'s':''}`:'running';glyph=GL.stopwatch;} + else {disp=fmtTime(rem);sub='timer';prog=Math.max(0,Math.min(1,rem/it.total));glyph=GL.timer;} + if(prog!=null && rem<=Math.min(30,it.total*0.15)) warn=true; + if(paused) glyph=GL.paused; + return {id:it.id,type:it.type,glyph,label:it.label||({timer:'Timer',alarm:'Alarm',stopwatch:'Stopwatch',pomodoro:'Pomodoro'})[it.type], + typeLabel:it.type,disp,sub,paused,primary:it.id===prim,prog,warn,pomo,laps:it.laps?it.laps.length:0}; + } + tick(){ + const now=this.now(),fired=[]; + for(const it of this.items.slice()){ + if(!COUNTDOWN.includes(it.type)||this.isPaused(it))continue; + if(it.target-now>0)continue; + if(it.type==='pomodoro'){ + const c=it.cfg; + if(it.phase==='work'){ + const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:`Pomodoro · ${deep?'long':'short'} break`,body:it.label||`cycle ${it.cycle}`}); + it.phase='rest'; const len=deep?c.rl:c.rs; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } else { // rest over → next work + it.cycle+=1; const deep=(it.cycle%c.iv===0); + fired.push({id:it.id,kind:'pomo',title:'Pomodoro · back to work',body:it.label||`cycle ${it.cycle}`}); + it.phase='work'; const len=deep?c.wl:c.ws; it.total=len; + if(c.auto){ it.target=now+len; } else { it.paused_left=len; it.awaiting=true; } + } + } else { + fired.push({id:it.id,kind:'done',title:(it.type==='alarm'?'Alarm':'Timer')+' · '+(it.label||(it.type==='alarm'?fmtClock(it.target):'done')),body:'time’s up'}); + this.cancel(it.id); + } + } + return fired; + } + presetsFor(t){return (this.presets[t]||[]).map(p=>({...p}));} + addPreset(t,label,value){if(!TYPES.includes(t)||t==='pomodoro'||t==='stopwatch')return {ok:false,reason:'no custom chip here'}; + if(t==='timer'&&parseDuration(value)==null)return {ok:false,reason:'bad duration'}; + (this.presets[t]||(this.presets[t]=[])).push({label,value,locked:false});return {ok:true};} + deletePreset(t,label){const a=this.presets[t]||[];const i=a.findIndex(p=>p.label===label); + if(i<0)return {ok:false,reason:'not found'}; if(a[i].locked)return {ok:false,reason:'default — locked'}; + a.splice(i,1);return {ok:true};} +} + +/* ---- notifications ---- */ +let notifPerm=(typeof Notification!=='undefined')?Notification.permission:'denied'; +function tryNotify(title,body){ if(typeof Notification==='undefined')return; + if(notifPerm==='granted'){try{new Notification(title,{body});}catch(e){}} + else if(notifPerm==='default'){Notification.requestPermission().then(p=>notifPerm=p);} } +function toaster(host){const wrap=el('div','toasts');host.appendChild(wrap); + return (msg,kind)=>{const t=el('div','toast'+(kind?' '+kind:''),msg);wrap.appendChild(t); + setTimeout(()=>{t.style.transition='opacity .3s';t.style.opacity='0';setTimeout(()=>t.remove(),300);},2600);};} + +function dotsHTML(p){ if(!p)return ''; let h='<span class="dots">'; const pos=(p.cycle-1)%p.iv; + for(let i=0;i<p.iv;i++){ const isLong=(i===p.iv-1); let cls=''; if(i<pos)cls='on'; if(i===pos)cls='now'; if(isLong)cls+=' long'; + h+=`<i class="${cls.trim()}"></i>`; } return h+'</span>'; } + +/* =================================================================== */ +/* THE PANEL: hero (top) · create (middle) · list (bottom) */ +/* =================================================================== */ +function mount(host, engine){ + const toast=toaster(host); + const head=el('div','phead',`<span class="brand">Timer</span><span class="pcount">queue <b class="cnt">0</b>/${MAX}</span>`); + const clear=el('button','key sm',GL.clear+' CLEAR ALL'); clear.style.marginLeft='8px'; + clear.addEventListener('click',()=>{if(!engine.count())return;engine.cancelAll();toast('cleared all');render();}); + head.appendChild(clear); + const hero=el('div','hero'); + const create=buildCreate(); + const list=el('div','qlist'); + host.append(head,hero,create.box,list); + + const flashing=new Set(); + + /* ---- hero + list interactions ---- */ + hero.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='cancel'){engine.cancel(id);toast('cancelled');} + else if(a==='cycle')engine.cycle(b.dataset.dir); else if(a==='lap'){engine.lap(id);toast('lap');} + else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + render();}); + list.addEventListener('click',e=>{const b=e.target.closest('[data-act]');if(!b)return;const id=b.dataset.id,a=b.dataset.act; + if(a==='toggle')engine.toggle(id); else if(a==='promote'){engine.promote(id);toast('to bar slot');} + else if(a==='lap'){engine.lap(id);toast('lap recorded');} else if(a==='stop'){const r=engine.stopSave(id);if(r)toast(`saved “${r.label}” · ${r.laps.length} laps → org`,'gold');} + else if(a==='cancel'){ if(b.dataset.armed){engine.cancel(id);toast('cancelled');} + else{b.dataset.armed='1';b.classList.add('armed');b.textContent='sure?';setTimeout(()=>{if(b.isConnected){b.textContent='×';b.classList.remove('armed');delete b.dataset.armed;}},2000);return;} } + render();}); + + /* ---- create strip (swaps body by type) ---- */ + function buildCreate(){ + const box=el('div','create'); + const seg=el('div','seg'); + TYPES.forEach(t=>{const b=el('button',t==='timer'?'on':'',t[0].toUpperCase()+t.slice(1));b.dataset.t=t;seg.appendChild(b);}); + const body=el('div'); + box.append(seg,body); + let selType='timer'; + // shared value/label/add controls (rebuilt per type) + seg.addEventListener('click',e=>{const b=e.target.closest('button');if(!b)return; + selType=b.dataset.t;[...seg.children].forEach(x=>x.classList.toggle('on',x===b));paintBody();}); + function labelAdd(hasVal){ + const row=el('div','row'); + let val=null; + if(hasVal){ val=el('input','tin'); val.placeholder='5m · 1h30m · 90s'; val.style.flex='2'; } + const lab=el('input','tin'); lab.placeholder='label (optional)'; lab.style.flex='2'; + const addk=el('button','key on',GL.add+' ADD'); addk.style.flex='1'; + if(val) row.append(val); row.append(lab,addk); return {row,val,lab,addk}; + } + function paintBody(){ + body.innerHTML=''; + if(selType==='timer'||selType==='alarm'){ + const chips=el('div','chips'); + engine.presetsFor(selType).forEach(p=>{ + const c=el('span','chip'+(p.locked?' lock':''), p.label+(p.locked?'':` <span class="x" data-del="${encodeURIComponent(p.label)}">×</span>`)); + c.dataset.val=p.value; chips.appendChild(c); + }); + const addch=el('span','chip','+ chip'); addch.dataset.newchip='1'; addch.style.opacity='.7'; chips.appendChild(addch); + const {row,val,lab,addk}=labelAdd(true); + val.placeholder = selType==='alarm' ? 'HH:MM · +30m · @hour' : '5m · 1h30m · 90s'; + body.append(chips,row); + chips.addEventListener('click',e=>{ + const del=e.target.closest('[data-del]'); + if(del){const r=engine.deletePreset(selType,decodeURIComponent(del.dataset.del));toast(r.ok?'chip removed':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + if(e.target.closest('[data-newchip]')){const lb=prompt('Chip label:');if(!lb)return; + const vv=prompt('Value for “'+lb+'”:',lb)||lb;const r=engine.addPreset(selType,lb,vv);toast(r.ok?'chip added':('chip: '+r.reason),r.ok?'gold':'red');paintBody();return;} + const c=e.target.closest('.chip');if(!c||c.dataset.val==null)return;val.value=c.dataset.val;doAdd(selType,val,lab); + }); + addk.addEventListener('click',()=>doAdd(selType,val,lab)); + [val,lab].forEach(x=>x.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd(selType,val,lab);})); + } + else if(selType==='stopwatch'){ + const {row,lab,addk}=labelAdd(false); // no time entry — stopwatches count up from zero + body.append(row); + addk.addEventListener('click',()=>doAdd('stopwatch',null,lab)); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')doAdd('stopwatch',null,lab);}); + } + else { // pomodoro config + const chips=el('div','chips'); + engine.pomoPresets.forEach(p=>{const c=el('span','chip lock',p.label);c.dataset.pp=p.label;chips.appendChild(c);}); + const cfg=el('div','cfg'); + const mk=(v)=>{const i=el('input','numin');i.value=v;i.inputMode='numeric';return i;}; + const ws=mk(POMO_DEFAULT.ws),wl=mk(POMO_DEFAULT.wl),rs=mk(POMO_DEFAULT.rs),rl=mk(POMO_DEFAULT.rl),iv=mk(POMO_DEFAULT.iv); + const auto=el('span','switch on'); auto.dataset.on='1'; + const rW=el('div','crow'); rW.append(el('span','lbl','Work'), el('span','sl','S'), ws, el('span','sl','L'), wl, el('span','u','min')); + const rR=el('div','crow'); rR.append(el('span','lbl','Rest'), el('span','sl','S'), rs, el('span','sl','L'), rl, el('span','u','min')); + const rI=el('div','crow'); rI.append(el('span','lbl','Long ev.'), iv, el('span','u','cycles → long work + long break')); + const rA=el('div','crow'); rA.append(el('span','lbl','Auto'), auto, el('span','u','advance into the next phase')); + cfg.append(rW,rR,rI,rA); + const {row,lab,addk}=labelAdd(false); lab.placeholder='label (optional)'; // pomodoro has no single value entry — config fields above + addk.innerHTML=GL.add+' ADD CYCLE'; + body.append(chips,cfg,row); + auto.addEventListener('click',()=>{auto.classList.toggle('on');auto.dataset.on=auto.classList.contains('on')?'1':'';}); + chips.addEventListener('click',e=>{const c=e.target.closest('[data-pp]');if(!c)return; + const p=engine.pomoPresets.find(x=>x.label===c.dataset.pp);if(!p)return; + ws.value=p.ws;wl.value=p.wl;rs.value=p.rs;rl.value=p.rl;iv.value=p.iv; + [...chips.children].forEach(x=>x.classList.toggle('on',x===c));toast('loaded “'+p.label+'”','gold');}); + function pnum(inp,d){const n=parseInt(inp.value,10);return (isNaN(n)||n<1)?d:n;} + addk.addEventListener('click',()=>{ + const cfgv={ws:pnum(ws,25),wl:pnum(wl,50),rs:pnum(rs,5),rl:pnum(rl,15),iv:pnum(iv,4),auto:!!auto.dataset.on}; + const r=engine.add('pomodoro','',lab.value.trim(),cfgv); + if(!r.ok){toast(r.reason,'red');return;} lab.value=''; toast('pomodoro added','gold'); render(); + }); + lab.addEventListener('keydown',e=>{if(e.key==='Enter')addk.click();}); + } + } + function doAdd(type,val,lab){ + const r=engine.add(type,val?val.value:'',lab.value.trim()); + if(!r.ok){if(val)val.classList.add('bad');toast(r.reason,'red');return;} + if(val){val.classList.remove('bad'); if(!val.disabled)val.value='';} lab.value=''; + toast('added '+type,'gold'); render(); + } + paintBody(); + return {box}; + } + + function render(){ + head.querySelector('.cnt').textContent=engine.count(); + const rows=engine.rows(), primId=engine.effectivePrimary(); + // HERO = primary + const h=rows.find(r=>r.id===primId); + hero.className='hero'+(h&&h.paused?' paused':'')+(h&&flashing.has(h.id)?' fire':''); + if(!h){ hero.innerHTML='<div class="empty" style="width:100%">No timers running — add one below.</div>'; } + else { + const ringP=h.prog!=null?Math.round(h.prog*100):(h.type==='stopwatch'?100:0); + const inner=h.type==='stopwatch'?`<b style="color:var(--slate-hi);font-size:11px">SW</b>` + :`<b style="color:var(--cream);font-size:14px">${ringP}<small style="font-size:9px;color:var(--dim)">%</small></b>`; + const startLabel = h.pomo&&h.pomo.awaiting ? (GL.play+' START '+(h.pomo.phase==='work'?'WORK':'BREAK')) : (h.paused?GL.play+' RESUME':GL.paused+' PAUSE'); + const transport = h.type==='stopwatch' + ? `<button class="key" data-act="toggle" data-id="${h.id}">${h.paused?GL.play+' RESUME':GL.paused+' PAUSE'}</button> + <button class="key" data-act="lap" data-id="${h.id}">LAP</button> + <button class="key red" data-act="stop" data-id="${h.id}">STOP · SAVE</button>` + : `<button class="key icon" data-act="cycle" data-dir="prev" title="prev primary">‹</button> + <button class="key" data-act="toggle" data-id="${h.id}">${startLabel}</button> + <button class="key red icon" data-act="cancel" data-id="${h.id}" title="cancel">${GL.cancel}</button> + <button class="key icon" data-act="cycle" data-dir="next" title="next primary">›</button>`; + hero.innerHTML= + `<div><span class="ring${h.warn?' warn':''}" style="--p:${ringP};width:86px;height:86px">${inner}</span></div> + <div class="rhs"> + <div class="htype"><span class="g">${h.glyph}</span><span class="badge ${h.paused?'dim':''}">${h.typeLabel}</span> + <span class="badge">BAR SLOT</span>${h.pomo?dotsHTML(h.pomo):''}</div> + <div class="hlabel">${h.label}</div> + <div class="hbig">${h.disp}</div> + <div class="hsub">${h.sub}</div> + <div class="transport">${transport}</div> + </div>`; + } + // LIST = the rest + list.innerHTML=''; + const rest=rows.filter(r=>r.id!==primId); + if(!rest.length){ list.appendChild(el('div','empty', h?'Only the hero is queued — add more below.':'')); } + list.appendChild(el('div','engrave','queue <span class="cnt">· '+rest.length+'</span>')); + rest.forEach(r=>{ + const row=el('div','qrow'+(r.paused?' paused':'')+(flashing.has(r.id)?' fire':'')); + const ctrls = r.type==='stopwatch' + ? `<button class="key sm" data-act="lap" data-id="${r.id}">LAP</button> + <button class="key sm red" data-act="stop" data-id="${r.id}">STOP</button>` + : `<button class="key icon" data-act="toggle" data-id="${r.id}" title="pause/resume">${r.paused?GL.play:GL.paused}</button>`; + row.innerHTML= + `<span class="lamp ${r.paused?'off':(r.warn?'red':'')}"></span> + <span class="g">${r.glyph}</span> + <span class="meta"><b>${r.label}</b><span class="ty">${r.sub}${r.pomo?' ':''}</span></span> + <span class="rd">${r.disp}</span> + <span class="ctrls">${ctrls} + <button class="key icon" data-act="promote" data-id="${r.id}" title="to bar slot">${GL.promote}</button> + <button class="arm" data-act="cancel" data-id="${r.id}" title="cancel">×</button></span>`; + list.appendChild(row); + }); + } + engine._render=render; engine._flash=id=>{flashing.add(id);setTimeout(()=>flashing.delete(id),1800);}; engine._toast=toast; + render(); +} + +/* ---- seed ---- */ +const engine=new Engine(); +const pomo=engine.add('pomodoro','', 'Deep work', {ws:25,wl:50,rs:5,rl:15,iv:4,auto:true}); +engine.add('timer','45s','Egg'); +engine.add('timer','5m','Tea'); +const sw=engine.add('stopwatch','','Debug run'); engine.lap(sw.id); +engine.add('alarm','@hour','Standup'); +engine.promote(pomo.id); // show the pomodoro in the hero +mount(document.getElementById('panel'), engine); + +/* ---- global tick ---- */ +function loop(){ + const fired=engine.tick(); + for(const f of fired){ engine._flash(f.id); + engine._toast((f.kind==='done'?GL.alarm+' ':'')+f.title, f.kind==='done'?'red':'gold'); tryNotify(f.title,f.body); } + engine._render(); +} +setInterval(loop, reduced?1000:250); +</script> +</body> +</html> |
