{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"habit-grid","type":"registry:component","title":"Habit Heatmap Grid","description":"GitHub-style contribution grid for up to six habits, with streaks.","author":"Notion Arena","categories":["trackers"],"tags":["widget","free","habit","streak","heatmap","tracker"],"meta":{"kind":"widget","tier":"free","version":"1.4.1","updatedAt":"2026-10-01","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/habit-grid","fileUrl":"https://notionarena.vercel.app/widgets/habit-grid.html","downloadUrl":"https://notionarena.vercel.app/api/widget/habit-grid","height":320,"minHeight":240,"maxHeight":620,"params":[{"key":"habits","label":"Habits","type":"text","default":"Read,Move,Write,No screens,Sleep 8h"},{"key":"weeks","label":"Weeks shown","type":"number","default":20,"min":4,"max":52},{"key":"accent","label":"Accent","type":"color","default":"#0f7b6c"},{"key":"theme","label":"Theme","type":"select","default":"auto","options":[{"value":"auto","label":"Match Notion"},{"value":"light","label":"Light"},{"value":"dark","label":"Dark"}]}]},"files":[{"path":"widgets/habit-grid.html","type":"registry:file","target":"notion/widgets/habit-grid.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>Habit Heatmap</title>\n<style>\n:root{--bg:#ffffff;--fg:#37352f;--muted:#787774;--surface:#f7f7f5;--border:#e9e9e7;--accent:#0f7b6c;--cell:#ebedef;\n--font:ui-sans-serif,-apple-system,BlinkMacSystemFont,\"Segoe UI\",Helvetica,Arial,sans-serif}\nhtml[data-theme=\"dark\"]{--bg:#191919;--fg:#e6e6e4;--muted:#9b9a97;--surface:#252525;--border:#2f2f2f;--cell:#2a2a2a}\n@media (prefers-color-scheme:dark){html[data-theme=\"auto\"]{--bg:#191919;--fg:#e6e6e4;--muted:#9b9a97;--surface:#252525;--border:#2f2f2f;--cell:#2a2a2a}}\n*{box-sizing:border-box}html,body{margin:0;height:100%}\nbody{background:var(--bg);color:var(--fg);font-family:var(--font);font-size:13px;-webkit-font-smoothing:antialiased;overflow:hidden}\n.wrap{height:100%;display:flex;flex-direction:column;gap:8px;padding:12px 14px;overflow:auto}\n.bar{display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap}\n.title{font-size:13px;font-weight:600;letter-spacing:-.01em}\n.tools{display:flex;gap:6px;align-items:center}\nbutton{font:inherit;font-size:11.5px;font-weight:500;border:1px solid var(--border);background:var(--surface);color:var(--muted);\nborder-radius:7px;padding:3px 8px;cursor:pointer}\nbutton:hover{color:var(--fg);border-color:color-mix(in oklab,var(--fg) 20%,var(--border))}\n.block{border:1px solid var(--border);border-radius:10px;background:var(--surface);padding:9px 10px}\n.hrow{display:flex;align-items:center;gap:8px;margin-bottom:6px}\n.hname{font-size:12.5px;font-weight:600;flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.pill{font-size:10.5px;font-weight:600;color:var(--muted);border:1px solid var(--border);border-radius:99px;padding:1px 7px;background:var(--bg);white-space:nowrap}\n.pill.hot{color:var(--accent);border-color:color-mix(in oklab,var(--accent) 40%,var(--border))}\n.grid{display:flex;gap:2px;overflow-x:auto;padding-bottom:2px}\n.col{display:flex;flex-direction:column;gap:2px;flex:0 0 auto}\n.cell{width:11px;height:11px;border-radius:2.5px;background:var(--cell);cursor:pointer;transition:transform .08s}\n.cell:hover{transform:scale(1.22)}\n.cell.l1{background:color-mix(in oklab,var(--accent) 30%,var(--cell))}\n.cell.l2{background:color-mix(in oklab,var(--accent) 55%,var(--cell))}\n.cell.l3{background:var(--accent)}\n.cell.future{opacity:.35;cursor:default}\n.months{display:flex;gap:2px;margin-bottom:4px}\n.months span{font-size:9.5px;color:var(--muted);flex:0 0 auto}\n.legend{display:flex;align-items:center;gap:5px;font-size:10.5px;color:var(--muted);margin-top:2px}\n.legend i{width:9px;height:9px;border-radius:2px;display:inline-block}\n.empty{color:var(--muted);font-size:12px;padding:20px;text-align:center}\n@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}\n</style>\n</head>\n<body>\n<div class=\"wrap\">\n  <div class=\"bar\">\n    <div class=\"title\" id=\"title\">Habits</div>\n    <div class=\"tools\">\n      <button id=\"export\" title=\"Copy your grid as JSON\">Export</button>\n      <button id=\"clear\" title=\"Erase all logged days\">Clear</button>\n    </div>\n  </div>\n  <div id=\"body\"></div>\n  <div class=\"legend\">\n    <span>less</span>\n    <i style=\"background:var(--cell)\"></i>\n    <i style=\"background:color-mix(in oklab,var(--accent) 30%,var(--cell))\"></i>\n    <i style=\"background:color-mix(in oklab,var(--accent) 55%,var(--cell))\"></i>\n    <i style=\"background:var(--accent)\"></i>\n    <span>more</span>\n    <span style=\"margin-left:8px\">· click a cell to log, click again to raise intensity</span>\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),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=parseInt(c[k],10);return isFinite(v)?v:d;}\n\n  var ACCENT=c.accent||'#0f7b6c';\n  if(c.theme)document.documentElement.setAttribute('data-theme',c.theme);\n  document.documentElement.style.setProperty('--accent',ACCENT);\n\n  var habits=(c.habits||'Read,Move,Write,No screens,Sleep 8h').split(',').map(function(s){return s.trim();}).filter(Boolean).slice(0,6);\n  var WEEKS=Math.max(4,Math.min(52,num('weeks',20)));\n  var KEY='notion-arena.habits.v1';\n  var data={};\n  try{data=JSON.parse(localStorage.getItem(KEY)||'{}')||{};}catch(e){data={};}\n\n  var body=document.getElementById('body');\n  if(!habits.length){body.innerHTML='<div class=\"empty\">No habits configured.</div>';return;}\n  document.getElementById('title').textContent=c.title||'Habits · last '+WEEKS+' weeks';\n\n  function iso(d){return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');}\n  function startOfDay(d){return new Date(d.getFullYear(),d.getMonth(),d.getDate());}\n  var today=startOfDay(new Date());\n  var end=today;\n  while(end.getDay()!==6)end=new Date(end.getTime()+864e5);\n  var start=new Date(end.getTime()-(WEEKS*7-1)*864e5);\n\n  function save(){try{localStorage.setItem(KEY,JSON.stringify(data));}catch(e){}}\n  function streaks(h){\n    var cur=0,best=0,total=0,run=0;\n    var d=new Date(start);\n    while(d<=end){\n      var k=iso(d),v=(data[h]||{})[k]||0;\n      if(v>0){run++;total++;if(run>best)best=run;}else{run=0;}\n      if(d<=today){}\n      d=new Date(d.getTime()+864e5);\n    }\n    var t=new Date(today);\n    var guard=0;\n    while(guard<400){\n      var kk=iso(t),vv=(data[h]||{})[kk]||0;\n      if(vv>0){cur++;}else{if(guard>0||vv===0){break;}}\n      t=new Date(t.getTime()-864e5);guard++;\n      if(cur===0&&guard>1)break;\n    }\n    return {cur:cur,best:best,rate:Math.round((total/(WEEKS*7))*100)};\n  }\n  function render(){\n    body.innerHTML='';\n    habits.forEach(function(h){\n      var st=streaks(h);\n      var blk=document.createElement('div');blk.className='block';\n      var row=document.createElement('div');row.className='hrow';\n      row.innerHTML='<span class=\"hname\">'+h.replace(/[<>&]/g,'')+'</span>'+\n        '<span class=\"pill'+(st.cur>=3?' hot':'')+'\">🔥 '+st.cur+'d</span>'+\n        '<span class=\"pill\">best '+st.best+'d</span>'+\n        '<span class=\"pill\">'+st.rate+'%</span>';\n      blk.appendChild(row);\n      var months=document.createElement('div');months.className='months';\n      var grid=document.createElement('div');grid.className='grid';\n      var rec=data[h]||(data[h]={});\n      var d=new Date(start),lastMonth=-1,weekCount=0;\n      while(d<=end){\n        var col=document.createElement('div');col.className='col';\n        var lbl=document.createElement('span');\n        var m=d.getMonth();\n        lbl.textContent=(m!==lastMonth&&d.getDate()<=7)?d.toLocaleDateString(undefined,{month:'short'}):'';\n        lastMonth=m;\n        months.appendChild(lbl);\n        for(var i=0;i<7;i++){\n          var cd=new Date(d.getTime()+i*864e5);\n          var cell=document.createElement('div');\n          var future=cd>today;\n          cell.className='cell'+(future?' future':'');\n          var key=iso(cd),v=rec[key]||0;\n          if(v>0)cell.classList.add('l'+Math.min(3,v));\n          cell.title=cd.toLocaleDateString(undefined,{month:'short',day:'numeric'})+' · '+(v?v+'/3':'not logged');\n          if(!future){\n            (function(hh,kk,el){\n              el.addEventListener('click',function(){\n                var cur=(data[hh]||{})[kk]||0;\n                var nx=cur>=3?0:cur+1;\n                if(!data[hh])data[hh]={};\n                if(nx===0)delete data[hh][kk];else data[hh][kk]=nx;\n                save();render();\n              });\n            })(h,key,cell);\n          }\n          col.appendChild(cell);\n        }\n        grid.appendChild(col);weekCount++;\n        d=new Date(d.getTime()+7*864e5);\n      }\n      blk.appendChild(months);blk.appendChild(grid);body.appendChild(blk);\n    });\n  }\n  document.getElementById('export').addEventListener('click',function(e){\n    var txt=JSON.stringify(data,null,2);\n    var btn=e.currentTarget;\n    function done(t){btn.textContent=t;setTimeout(function(){btn.textContent='Export';},1400);}\n    try{navigator.clipboard.writeText(txt).then(function(){done('Copied ✓');},function(){done('See console');});}catch(err){done('See console');}\n    try{console.log(txt);}catch(err){}\n  });\n  document.getElementById('clear').addEventListener('click',function(){\n    if(!confirm('Erase every logged day on this device?'))return;\n    data={};save();render();\n  });\n  render();\n})();\n</script>\n</body>\n</html>\n"}]}