{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"pomodoro-timer","type":"registry:component","title":"Pomodoro Focus Timer","description":"Work/break cycles with a progress ring, session counter and chime.","author":"Notion Arena","categories":["time"],"tags":["widget","free","timer","focus","pomodoro","productivity"],"meta":{"kind":"widget","tier":"free","version":"1.2.1","updatedAt":"2026-10-12","notionPlan":"Free","emoji":"🍅","delivery":["embed","html-block","download"],"capabilities":{"interactive":true,"offlineSafe":true,"persistsLocally":true,"writesToNotion":false,"needsNetwork":false},"embedUrl":"https://notionarena.vercel.app/embed/pomodoro-timer","fileUrl":"https://notionarena.vercel.app/widgets/pomodoro-timer.html","downloadUrl":"https://notionarena.vercel.app/api/widget/pomodoro-timer","height":260,"minHeight":200,"maxHeight":420,"params":[{"key":"work","label":"Work minutes","type":"number","default":25,"min":1,"max":120},{"key":"short","label":"Short break","type":"number","default":5,"min":1,"max":30},{"key":"long","label":"Long break","type":"number","default":15,"min":1,"max":60},{"key":"rounds","label":"Rounds before long break","type":"number","default":4,"min":2,"max":12},{"key":"label","label":"Session label","type":"text","default":"Deep work"},{"key":"accent","label":"Accent","type":"color","default":"#e03e3e"},{"key":"theme","label":"Theme","type":"select","default":"auto","options":[{"value":"auto","label":"Match Notion"},{"value":"light","label":"Light"},{"value":"dark","label":"Dark"}]},{"key":"chime","label":"Play a chime","type":"boolean","default":true}]},"files":[{"path":"widgets/pomodoro-timer.html","type":"registry:file","target":"notion/widgets/pomodoro-timer.html","content":"<!doctype html>\n<html lang=\"en\" data-theme=\"auto\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\" />\n<title>Pomodoro Focus Timer</title>\n<style>\n:root{\n  --bg:#ffffff;--fg:#37352f;--muted:#787774;--surface:#f7f7f5;--border:#e9e9e7;\n  --accent:#e03e3e;--track:#e9e9e7;--radius:10px;\n  --font:ui-sans-serif,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\";\n}\nhtml[data-theme=\"dark\"]{--bg:#191919;--fg:#e6e6e4;--muted:#9b9a97;--surface:#252525;--border:#2f2f2f;--track:#2f2f2f}\n@media (prefers-color-scheme:dark){html[data-theme=\"auto\"]{--bg:#191919;--fg:#e6e6e4;--muted:#9b9a97;--surface:#252525;--border:#2f2f2f;--track:#2f2f2f}}\n*{box-sizing:border-box}\nhtml,body{margin:0;height:100%}\nbody{background:var(--bg);color:var(--fg);font-family:var(--font);font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;overflow:hidden}\n.wrap{height:100%;display:flex;align-items:center;justify-content:center;gap:28px;padding:16px;flex-wrap:wrap}\n.dial{position:relative;width:170px;height:170px;flex:0 0 auto}\n.dial svg{width:100%;height:100%;transform:rotate(-90deg)}\n.dial .bg{fill:none;stroke:var(--track);stroke-width:9}\n.dial .fg{fill:none;stroke:var(--accent);stroke-width:9;stroke-linecap:round;transition:stroke-dashoffset .4s linear}\n.readout{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px}\n.time{font-size:34px;font-weight:600;letter-spacing:-.03em;font-variant-numeric:tabular-nums}\n.phase{font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:var(--muted);font-weight:600}\n.side{display:flex;flex-direction:column;gap:12px;min-width:190px}\n.label{font-size:15px;font-weight:600;letter-spacing:-.01em}\n.row{display:flex;gap:8px;flex-wrap:wrap}\nbutton{font:inherit;font-weight:500;border:1px solid var(--border);background:var(--surface);color:var(--fg);border-radius:8px;padding:6px 12px;cursor:pointer;transition:background .12s,border-color .12s}\nbutton:hover{background:color-mix(in oklab,var(--fg) 8%,var(--surface));border-color:color-mix(in oklab,var(--fg) 18%,var(--border))}\nbutton:active{transform:translateY(.5px)}\nbutton.primary{background:var(--accent);border-color:var(--accent);color:#fff}\nbutton.primary:hover{filter:brightness(1.06)}\n.stats{display:flex;gap:16px;border-top:1px solid var(--border);padding-top:10px}\n.stat b{display:block;font-size:17px;font-weight:600;font-variant-numeric:tabular-nums}\n.stat span{font-size:11px;color:var(--muted)}\n.dots{display:flex;gap:5px;align-items:center}\n.dot{width:8px;height:8px;border-radius:50%;background:var(--track);transition:background .2s}\n.dot.on{background:var(--accent)}\n.hint{font-size:11px;color:var(--muted)}\n@media (max-width:520px){.wrap{gap:16px}.dial{width:140px;height:140px}.time{font-size:28px}}\n@media (prefers-reduced-motion:reduce){.dial .fg{transition:none}}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n  <div class=\"dial\">\n    <svg viewBox=\"0 0 120 120\" aria-hidden=\"true\">\n      <circle class=\"bg\" cx=\"60\" cy=\"60\" r=\"52\"></circle>\n      <circle class=\"fg\" id=\"ring\" cx=\"60\" cy=\"60\" r=\"52\" stroke-dasharray=\"326.7\" stroke-dashoffset=\"0\"></circle>\n    </svg>\n    <div class=\"readout\">\n      <div class=\"time\" id=\"time\">25:00</div>\n      <div class=\"phase\" id=\"phase\">Focus</div>\n    </div>\n  </div>\n  <div class=\"side\">\n    <div class=\"label\" id=\"label\">Deep work</div>\n    <div class=\"dots\" id=\"dots\"></div>\n    <div class=\"row\">\n      <button class=\"primary\" id=\"toggle\">Start</button>\n      <button id=\"reset\">Reset</button>\n      <button id=\"skip\" title=\"Skip to the next phase\">Skip</button>\n      <button id=\"add\" title=\"Add one minute\">+1m</button>\n    </div>\n    <div class=\"stats\">\n      <div class=\"stat\"><b id=\"today\">0</b><span>sessions today</span></div>\n      <div class=\"stat\"><b id=\"focused\">0m</b><span>focused today</span></div>\n    </div>\n    <div class=\"hint\">Space start/pause · R reset · + add a minute</div>\n  </div>\n</div>\n<script>\n(function(){\n  var cfgEl=document.getElementById('bk-config'),base={};\n  try{base=cfgEl?JSON.parse(cfgEl.textContent||'{}'):{};}catch(e){}\n  var q=new URLSearchParams(location.search);\n  var c={};for(var k in base)c[k]=base[k];\n  q.forEach(function(v,k){c[k]=v;});\n  function num(k,d){var v=parseFloat(c[k]);return isFinite(v)?v:d;}\n  function bool(k,d){var v=c[k];if(v===undefined)return d;return v===true||v==='true'||v==='1';}\n\n  var WORK=num('work',25)*60, SHORT=num('short',5)*60, LONG=num('long',15)*60, ROUNDS=Math.max(2,Math.round(num('rounds',4)));\n  var LABEL=c.label||'Deep work', ACCENT=c.accent||'#e03e3e', CHIME=bool('chime',true);\n  if(c.theme){document.documentElement.setAttribute('data-theme',c.theme);}\n  document.documentElement.style.setProperty('--accent',ACCENT);\n  document.getElementById('label').textContent=LABEL;\n\n  var KEY='notion-arena.pomodoro.v1';\n  var store={};\n  try{store=JSON.parse(localStorage.getItem(KEY)||'{}');}catch(e){}\n  var todayKey=new Date().toDateString();\n  if(store.day!==todayKey){store={day:todayKey,sessions:0,minutes:0};}\n\n  // Timing is wall-clock based: while running, the current phase has a fixed\n  // end timestamp and every frame derives what is left from Date.now(). A\n  // counter that merely decrements would drift, and it would freeze outright\n  // whenever the browser throttles the interval, which is exactly what Notion's\n  // iframe does when the tab is in the background.\n  var TICK=250, CIRC=2*Math.PI*52;\n  var phase='work', round=0, longBreak=false, running=false;\n  var total=WORK, remaining=WORK, endsAt=0, tick=null;\n  var elTime=document.getElementById('time'),elPhase=document.getElementById('phase'),\n      elRing=document.getElementById('ring'),elToggle=document.getElementById('toggle'),\n      elDots=document.getElementById('dots'),elToday=document.getElementById('today'),\n      elFocused=document.getElementById('focused');\n  elRing.setAttribute('stroke-dasharray',String(CIRC));\n\n  function save(){try{localStorage.setItem(KEY,JSON.stringify(store));}catch(e){}}\n  function fmt(s){s=Math.max(0,Math.round(s));var m=Math.floor(s/60),x=s%60;return (m<10?'0':'')+m+':'+(x<10?'0':'')+x;}\n  function left(){return running?Math.max(0,(endsAt-Date.now())/1000):remaining;}\n  function phaseLabel(){return phase==='work'?'Focus':(longBreak?'Long break':'Break');}\n  function dots(){\n    if(elDots.children.length!==ROUNDS){\n      elDots.innerHTML='';\n      for(var i=0;i<ROUNDS;i++)elDots.appendChild(document.createElement('span'));\n    }\n    for(var j=0;j<ROUNDS;j++){\n      var d=elDots.children[j],cls='dot'+(j<round?' on':'');\n      if(d.className!==cls)d.className=cls;\n      var bg=(j===round&&phase==='work')?'color-mix(in oklab,'+ACCENT+' 45%, var(--track))':'';\n      if(d.style.background!==bg)d.style.background=bg;\n    }\n  }\n  function paint(){\n    var rem=Math.max(0,left()),secs=Math.max(0,Math.ceil(rem));\n    var time=fmt(secs),label=phaseLabel(),title=time+' · '+(phase==='work'?'Focus':'Break');\n    if(elTime.textContent!==time)elTime.textContent=time;\n    if(elPhase.textContent!==label)elPhase.textContent=label;\n    // The ring holds the whole phase and drains to empty, so what is left is\n    // readable at a glance.\n    var gone=total>0?Math.min(1,Math.max(0,(total-rem)/total)):0;\n    elRing.setAttribute('stroke-dashoffset',(CIRC*gone).toFixed(2));\n    var stroke=phase==='work'?ACCENT:'color-mix(in oklab,'+ACCENT+' 55%, var(--muted))';\n    if(elRing.style.stroke!==stroke)elRing.style.stroke=stroke;\n    var btn=running?'Pause':'Start';\n    if(elToggle.textContent!==btn)elToggle.textContent=btn;\n    if(elToday.textContent!==String(store.sessions||0))elToday.textContent=String(store.sessions||0);\n    if(elFocused.textContent!==(store.minutes||0)+'m')elFocused.textContent=(store.minutes||0)+'m';\n    if(document.title!==title)document.title=title;\n    dots();\n  }\n  function beep(){\n    if(!CHIME)return;\n    try{\n      var AC=window.AudioContext||window.webkitAudioContext;if(!AC)return;\n      var ctx=new AC(),o=ctx.createOscillator(),g=ctx.createGain();\n      o.type='sine';o.frequency.value=660;g.gain.value=0.0001;\n      o.connect(g);g.connect(ctx.destination);\n      var t=ctx.currentTime;\n      g.gain.exponentialRampToValueAtTime(0.16,t+0.02);\n      g.gain.exponentialRampToValueAtTime(0.0001,t+0.55);\n      o.start(t);o.stop(t+0.6);\n      setTimeout(function(){try{ctx.close();}catch(e){}},900);\n    }catch(e){}\n  }\n  function setPhase(next,isLong,at){\n    phase=next;longBreak=!!isLong;\n    total=phase==='work'?WORK:(longBreak?LONG:SHORT);\n    remaining=total;\n    if(running)endsAt=(at||Date.now())+remaining*1000;\n    paint();\n  }\n  // `at` is the moment the previous phase actually ended, so a block that was\n  // asleep for a while lands on the phase it should really be in, not one that\n  // starts over from now.\n  function advance(at){\n    var from=at||Date.now();\n    if(phase==='work'){\n      round++;store.sessions=(store.sessions||0)+1;store.minutes=(store.minutes||0)+Math.round(WORK/60);save();\n      setPhase('break',round%ROUNDS===0,from);\n    }else{\n      if(longBreak)round=0; // a long break closes the cycle, so the dots restart\n      setPhase('work',false,from);\n    }\n  }\n  function step(){\n    if(!running)return;\n    var now=Date.now();\n    if(now<endsAt){paint();return;}\n    // Catch up in one pass if the block was throttled or asleep. Bounded so a\n    // very short phase can never spin forever.\n    var guard=0;\n    while(running&&now>=endsAt&&guard<100){\n      var end=endsAt;\n      if(!guard)beep();\n      advance(end);guard++;\n    }\n    paint();\n  }\n  function start(){\n    if(running)return;\n    running=true;endsAt=Date.now()+remaining*1000;\n    if(tick)clearInterval(tick);\n    tick=setInterval(step,TICK);\n    paint();\n  }\n  function pause(){\n    if(!running)return;\n    remaining=Math.max(0,(endsAt-Date.now())/1000);\n    running=false;\n    if(tick){clearInterval(tick);tick=null;}\n    paint();\n  }\n  function toggle(){running?pause():start();}\n  function addMinute(){total+=60;remaining+=60;if(running)endsAt+=60000;paint();}\n\n  elToggle.addEventListener('click',toggle);\n  document.getElementById('reset').addEventListener('click',function(){pause();setPhase(phase,longBreak);});\n  document.getElementById('skip').addEventListener('click',function(){advance();});\n  document.getElementById('add').addEventListener('click',addMinute);\n  document.addEventListener('keydown',function(e){\n    if(e.target&&/INPUT|TEXTAREA/.test(e.target.tagName))return;\n    if(e.code==='Space'){e.preventDefault();toggle();}\n    else if(e.key==='r'||e.key==='R'){pause();setPhase(phase,longBreak);}\n    else if(e.key==='+'||e.key==='='){addMinute();}\n  });\n  // A background tab leaves the block on a throttled clock, and a throttled\n  // interval can still be a minute away from its next fire. Run the same\n  // catch-up a tick would, the moment the block is visible again.\n  document.addEventListener('visibilitychange',function(){if(!document.hidden)step();});\n  paint();\n})();\n</script>\n</body>\n</html>\n"}]}