diff --git a/Resources/public/heatmap.css b/Resources/public/heatmap.css new file mode 100644 index 0000000..53d0542 --- /dev/null +++ b/Resources/public/heatmap.css @@ -0,0 +1,28 @@ +.heatmap-tooltip { + position: absolute; + padding: 6px 10px; + background: var(--tblr-bg-surface); + border: 1px solid var(--tblr-border-color); + border-radius: 4px; + font-size: 0.8125rem; + color: var(--tblr-body-color); + pointer-events: none; + z-index: 1000; + white-space: nowrap; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); +} + +.heatmap-cell { + rx: 2; + ry: 2; +} + +.heatmap-empty { + fill: var(--tblr-bg-surface-secondary); +} + +.heatmap-label { + fill: var(--tblr-body-color); + font-size: 10px; + font-family: var(--tblr-font-sans-serif); +} diff --git a/Resources/public/heatmap.js b/Resources/public/heatmap.js index eab8631..41aaa63 100644 --- a/Resources/public/heatmap.js +++ b/Resources/public/heatmap.js @@ -1 +1 @@ -"use strict";var KimaiHeatmap=(()=>{var e=Object.defineProperty;var a=Object.getOwnPropertyDescriptor;var c=Object.getOwnPropertyNames;var m=Object.prototype.hasOwnProperty;var p=(i,n)=>{for(var t in n)e(i,t,{get:n[t],enumerable:!0})},H=(i,n,t,l)=>{if(n&&typeof n=="object"||typeof n=="function")for(let o of c(n))!m.call(i,o)&&o!==t&&e(i,o,{get:()=>n[o],enumerable:!(l=a(n,o))||l.enumerable});return i};var d=i=>H(e({},"__esModule",{value:!0}),i);var g={};p(g,{init:()=>f});function f(i){console.log("Heatmap init",i)}return d(g);})(); +"use strict";var KimaiHeatmap=(()=>{var Pt=Object.defineProperty;var $r=Object.getOwnPropertyDescriptor;var Xr=Object.getOwnPropertyNames;var Gr=Object.prototype.hasOwnProperty;var Jr=(t,e)=>{for(var r in e)Pt(t,r,{get:e[r],enumerable:!0})},Kr=(t,e,r,n)=>{if(e&&typeof e=="object"||typeof e=="function")for(let o of Xr(e))!Gr.call(t,o)&&o!==r&&Pt(t,o,{get:()=>e[o],enumerable:!(n=$r(e,o))||n.enumerable});return t};var jr=t=>Kr(Pt({},"__esModule",{value:!0}),t);var Pa={};Jr(Pa,{init:()=>Wa,renderHeatmap:()=>Ir});var St="http://www.w3.org/1999/xhtml",Et={svg:"http://www.w3.org/2000/svg",xhtml:St,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function Tt(t){var e=t+="",r=e.indexOf(":");return r>=0&&(e=t.slice(0,r))!=="xmlns"&&(t=t.slice(r+1)),Et.hasOwnProperty(e)?{space:Et[e],local:t}:t}function tn(t){return function(){var e=this.ownerDocument,r=this.namespaceURI;return r===St&&e.documentElement.namespaceURI===St?e.createElement(t):e.createElementNS(r,t)}}function en(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Ct(t){var e=Tt(t);return(e.local?en:tn)(e)}function rn(){}function Dt(t){return t==null?rn:function(){return this.querySelector(t)}}function le(t){typeof t!="function"&&(t=Dt(t));for(var e=this._groups,r=e.length,n=new Array(r),o=0;o=b&&(b=N+1);!(q=W[b])&&++b=0;)(i=n[o])&&(a&&i.compareDocumentPosition(a)^4&&a.parentNode.insertBefore(i,a),a=i);return this}function Te(t){t||(t=yn);function e(p,v){return p&&v?t(p.__data__,v.__data__):!p-!v}for(var r=this._groups,n=r.length,o=new Array(n),a=0;ae?1:t>=e?0:NaN}function Ce(){var t=arguments[0];return arguments[0]=this,t.apply(null,arguments),this}function De(){return Array.from(this)}function be(){for(var t=this._groups,e=0,r=t.length;e1?this.each((e==null?Sn:typeof e=="function"?Cn:Tn)(t,e,r??"")):Dn(this.node(),t)}function Dn(t,e){return t.style.getPropertyValue(e)||Ft(t).getComputedStyle(t,null).getPropertyValue(e)}function bn(t){return function(){delete this[t]}}function Un(t,e){return function(){this[t]=e}}function Fn(t,e){return function(){var r=e.apply(this,arguments);r==null?delete this[t]:this[t]=r}}function Ne(t,e){return arguments.length>1?this.each((e==null?bn:typeof e=="function"?Fn:Un)(t,e)):this.node()[t]}function Ye(t){return t.trim().split(/^|\s+/)}function Rt(t){return t.classList||new He(t)}function He(t){this._node=t,this._names=Ye(t.getAttribute("class")||"")}He.prototype={add:function(t){var e=this._names.indexOf(t);e<0&&(this._names.push(t),this._node.setAttribute("class",this._names.join(" ")))},remove:function(t){var e=this._names.indexOf(t);e>=0&&(this._names.splice(e,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};function We(t,e){for(var r=Rt(t),n=-1,o=e.length;++n=0&&(r=e.slice(n+1),e=e.slice(0,n)),{type:e,name:r}})}function Qn(t){return function(){var e=this.__on;if(e){for(var r=0,n=-1,o=e.length,a;re?1:t>=e?0:NaN}function zt(t,e){return t==null||e==null?NaN:et?1:e>=t?0:NaN}function kt(t){let e,r,n;t.length!==2?(e=J,r=(u,f)=>J(t(u),f),n=(u,f)=>t(u)-f):(e=t===J||t===zt?t:jn,r=t,n=t);function o(u,f,s=0,m=u.length){if(s>>1;r(u[p],f)<0?s=p+1:m=p}while(s>>1;r(u[p],f)<=0?s=p+1:m=p}while(ss&&n(u[p-1],f)>-n(u[p],f)?p-1:p}return{left:o,center:i,right:a}}function jn(){return 0}function Vt(t){return t===null?NaN:+t}var Ke=kt(J),je=Ke.right,to=Ke.left,eo=kt(Vt).center,Bt=je;var ro=Math.sqrt(50),no=Math.sqrt(10),oo=Math.sqrt(2);function At(t,e,r){let n=(e-t)/Math.max(0,r),o=Math.floor(Math.log10(n)),a=n/Math.pow(10,o),i=a>=ro?10:a>=no?5:a>=oo?2:1,u,f,s;return o<0?(s=Math.pow(10,-o)/i,u=Math.round(t*s),f=Math.round(e*s),u/se&&--f,s=-s):(s=Math.pow(10,o)*i,u=Math.round(t/s),f=Math.round(e/s),u*se&&--f),f0))return[];if(t===e)return[t];let n=e=o))return[];let u=a-o+1,f=new Array(u);if(n)if(i<0)for(let s=0;s=n)&&(r=n);else{let n=-1;for(let o of t)(o=e(o,++n,t))!=null&&(r=o)&&(r=o)}return r}function tr(t,e){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(e).domain(t);break}return this}function er(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)}function K(t,e){if(!isFinite(t)||t===0)return null;var r=(t=e?t.toExponential(e-1):t.toExponential()).indexOf("e"),n=t.slice(0,r);return[n.length>1?n[0]+n.slice(2):n,+t.slice(r+1)]}function R(t){return t=K(Math.abs(t)),t?t[1]:NaN}function rr(t,e){return function(r,n){for(var o=r.length,a=[],i=0,u=t[0],f=0;o>0&&u>0&&(f+u+1>n&&(u=Math.max(1,n-f)),a.push(r.substring(o-=u,o+u)),!((f+=u+1)>n));)u=t[i=(i+1)%t.length];return a.reverse().join(e)}}function nr(t){return function(e){return e.replace(/[0-9]/g,function(r){return t[+r]})}}var ao=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Z(t){if(!(e=ao.exec(t)))throw new Error("invalid format: "+t);var e;return new Nt({fill:e[1],align:e[2],sign:e[3],symbol:e[4],zero:e[5],width:e[6],comma:e[7],precision:e[8]&&e[8].slice(1),trim:e[9],type:e[10]})}Z.prototype=Nt.prototype;function Nt(t){this.fill=t.fill===void 0?" ":t.fill+"",this.align=t.align===void 0?">":t.align+"",this.sign=t.sign===void 0?"-":t.sign+"",this.symbol=t.symbol===void 0?"":t.symbol+"",this.zero=!!t.zero,this.width=t.width===void 0?void 0:+t.width,this.comma=!!t.comma,this.precision=t.precision===void 0?void 0:+t.precision,this.trim=!!t.trim,this.type=t.type===void 0?"":t.type+""}Nt.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type};function or(t){t:for(var e=t.length,r=1,n=-1,o;r0&&(n=0);break}return n>0?t.slice(0,n)+t.slice(o+1):t}var pt;function ar(t,e){var r=K(t,e);if(!r)return pt=void 0,t.toPrecision(e);var n=r[0],o=r[1],a=o-(pt=Math.max(-8,Math.min(8,Math.floor(o/3)))*3)+1,i=n.length;return a===i?n:a>i?n+new Array(a-i+1).join("0"):a>0?n.slice(0,a)+"."+n.slice(a):"0."+new Array(1-a).join("0")+K(t,Math.max(0,e+a-1))[0]}function Qt(t,e){var r=K(t,e);if(!r)return t+"";var n=r[0],o=r[1];return o<0?"0."+new Array(-o).join("0")+n:n.length>o+1?n.slice(0,o+1)+"."+n.slice(o+1):n+new Array(o-n.length+2).join("0")}var $t={"%":(t,e)=>(t*100).toFixed(e),b:t=>Math.round(t).toString(2),c:t=>t+"",d:er,e:(t,e)=>t.toExponential(e),f:(t,e)=>t.toFixed(e),g:(t,e)=>t.toPrecision(e),o:t=>Math.round(t).toString(8),p:(t,e)=>Qt(t*100,e),r:Qt,s:ar,X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function Xt(t){return t}var ir=Array.prototype.map,ur=["y","z","a","f","p","n","\xB5","m","","k","M","G","T","P","E","Z","Y"];function sr(t){var e=t.grouping===void 0||t.thousands===void 0?Xt:rr(ir.call(t.grouping,Number),t.thousands+""),r=t.currency===void 0?"":t.currency[0]+"",n=t.currency===void 0?"":t.currency[1]+"",o=t.decimal===void 0?".":t.decimal+"",a=t.numerals===void 0?Xt:nr(ir.call(t.numerals,String)),i=t.percent===void 0?"%":t.percent+"",u=t.minus===void 0?"\u2212":t.minus+"",f=t.nan===void 0?"NaN":t.nan+"";function s(p,v){p=Z(p);var w=p.fill,D=p.align,T=p.sign,W=p.symbol,I=p.zero,N=p.width,b=p.comma,C=p.precision,q=p.trim,M=p.type;M==="n"?(b=!0,M="g"):$t[M]||(C===void 0&&(C=12),q=!0,M="g"),(I||w==="0"&&D==="=")&&(I=!0,w="0",D="=");var y=(v&&v.prefix!==void 0?v.prefix:"")+(W==="$"?r:W==="#"&&/[boxX]/.test(M)?"0"+M.toLowerCase():""),k=(W==="$"?n:/[%p]/.test(M)?i:"")+(v&&v.suffix!==void 0?v.suffix:""),rt=$t[M],ft=/[defgprs%]/.test(M);C=C===void 0?6:/[gprs]/.test(M)?Math.max(1,Math.min(21,C)):Math.max(0,Math.min(20,C));function X(h){var O=y,A=k,B,_t,nt;if(M==="c")A=rt(h)+A,h="";else{h=+h;var ot=h<0||1/h<0;if(h=isNaN(h)?f:rt(Math.abs(h),C),q&&(h=or(h)),ot&&+h==0&&T!=="+"&&(ot=!1),O=(ot?T==="("?T:u:T==="-"||T==="("?"":T)+O,A=(M==="s"&&!isNaN(h)&&pt!==void 0?ur[8+pt/3]:"")+A+(ot&&T==="("?")":""),ft){for(B=-1,_t=h.length;++B<_t;)if(nt=h.charCodeAt(B),48>nt||nt>57){A=(nt===46?o+h.slice(B+1):h.slice(B))+A,h=h.slice(0,B);break}}}b&&!I&&(h=e(h,1/0));var at=O.length+h.length+A.length,P=at>1)+O+h+A+P.slice(at);break;default:h=P+O+h+A;break}return a(h)}return X.toString=function(){return p+""},X}function m(p,v){var w=Math.max(-8,Math.min(8,Math.floor(R(v)/3)))*3,D=Math.pow(10,-w),T=s((p=Z(p),p.type="f",p),{suffix:ur[8+w/3]});return function(W){return T(D*W)}}return{format:s,formatPrefix:m}}var Yt,Ht,Wt;Gt({thousands:",",grouping:[3],currency:["$",""]});function Gt(t){return Yt=sr(t),Ht=Yt.format,Wt=Yt.formatPrefix,Yt}function Jt(t){return Math.max(0,-R(Math.abs(t)))}function Kt(t,e){return Math.max(0,Math.max(-8,Math.min(8,Math.floor(R(e)/3)))*3-R(Math.abs(t)))}function jt(t,e){return t=Math.abs(t),e=Math.abs(e)-t,Math.max(0,R(e)-R(t))+1}function te(t,e,r,n){var o=Zt(t,e,r),a;switch(n=Z(n??",f"),n.type){case"s":{var i=Math.max(Math.abs(t),Math.abs(e));return n.precision==null&&!isNaN(a=Kt(o,i))&&(n.precision=a),Wt(n,i)}case"":case"e":case"g":case"p":case"r":{n.precision==null&&!isNaN(a=jt(o,Math.max(Math.abs(t),Math.abs(e))))&&(n.precision=a-(n.type==="e"));break}case"f":case"%":{n.precision==null&&!isNaN(a=Jt(o))&&(n.precision=a-(n.type==="%")*2);break}}return Ht(n)}function fr(t){var e=t.domain;return t.ticks=function(r){var n=e();return Lt(n[0],n[n.length-1],r??10)},t.tickFormat=function(r,n){var o=e();return te(o[0],o[o.length-1],r??10,n)},t.nice=function(r){r==null&&(r=10);var n=e(),o=0,a=n.length-1,i=n[o],u=n[a],f,s,m=10;for(u0;){if(s=ct(i,u,r),s===f)return n[o]=i,n[a]=u,e(n);if(s>0)i=Math.floor(i/s)*s,u=Math.ceil(u/s)*s;else if(s<0)i=Math.ceil(i*s)/s,u=Math.floor(u*s)/s;else break;f=s}return t},t}function ht(){var t=0,e=1,r=1,n=[.5],o=[0,1],a;function i(f){return f!=null&&f<=f?o[Bt(n,f,0,r)]:a}function u(){var f=-1;for(n=new Array(r);++f=r?[n[r-1],e]:[n[s-1],n[s]]},i.unknown=function(f){return arguments.length&&(a=f),i},i.thresholds=function(){return n.slice()},i.copy=function(){return ht().domain([t,e]).range(o).unknown(a)},tr.apply(fr(i),arguments)}var ee=new Date,re=new Date;function F(t,e,r,n){function o(a){return t(a=arguments.length===0?new Date:new Date(+a)),a}return o.floor=a=>(t(a=new Date(+a)),a),o.ceil=a=>(t(a=new Date(a-1)),e(a,1),t(a),a),o.round=a=>{let i=o(a),u=o.ceil(a);return a-i(e(a=new Date(+a),i==null?1:Math.floor(i)),a),o.range=(a,i,u)=>{let f=[];if(a=o.ceil(a),u=u==null?1:Math.floor(u),!(a0))return f;let s;do f.push(s=new Date(+a)),e(a,u),t(a);while(sF(i=>{if(i>=i)for(;t(i),!a(i);)i.setTime(i-1)},(i,u)=>{if(i>=i)if(u<0)for(;++u<=0;)for(;e(i,-1),!a(i););else for(;--u>=0;)for(;e(i,1),!a(i););}),r&&(o.count=(a,i)=>(ee.setTime(+a),re.setTime(+i),t(ee),t(re),Math.floor(r(ee,re))),o.every=a=>(a=Math.floor(a),!isFinite(a)||!(a>0)?null:a>1?o.filter(n?i=>n(i)%a===0:i=>o.count(0,i)%a===0):o)),o}var j=F(t=>t.setHours(0,0,0,0),(t,e)=>t.setDate(t.getDate()+e),(t,e)=>(e-t-(e.getTimezoneOffset()-t.getTimezoneOffset())*6e4)/864e5,t=>t.getDate()-1),uo=j.range,dt=F(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>t.getUTCDate()-1),so=dt.range,lr=F(t=>{t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCDate(t.getUTCDate()+e)},(t,e)=>(e-t)/864e5,t=>Math.floor(t/864e5)),fo=lr.range;function tt(t){return F(e=>{e.setDate(e.getDate()-(e.getDay()+7-t)%7),e.setHours(0,0,0,0)},(e,r)=>{e.setDate(e.getDate()+r*7)},(e,r)=>(r-e-(r.getTimezoneOffset()-e.getTimezoneOffset())*6e4)/6048e5)}var yt=tt(0),E=tt(1),cr=tt(2),mr=tt(3),Q=tt(4),pr=tt(5),hr=tt(6),dr=yt.range,co=E.range,mo=cr.range,po=mr.range,ho=Q.range,yo=pr.range,go=hr.range;function et(t){return F(e=>{e.setUTCDate(e.getUTCDate()-(e.getUTCDay()+7-t)%7),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCDate(e.getUTCDate()+r*7)},(e,r)=>(r-e)/6048e5)}var gt=et(0),it=et(1),yr=et(2),gr=et(3),$=et(4),xr=et(5),Mr=et(6),vr=gt.range,xo=it.range,Mo=yr.range,vo=gr.range,wo=$.range,_o=xr.range,So=Mr.range;var xt=F(t=>{t.setDate(1),t.setHours(0,0,0,0)},(t,e)=>{t.setMonth(t.getMonth()+e)},(t,e)=>e.getMonth()-t.getMonth()+(e.getFullYear()-t.getFullYear())*12,t=>t.getMonth()),To=xt.range,wr=F(t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCMonth(t.getUTCMonth()+e)},(t,e)=>e.getUTCMonth()-t.getUTCMonth()+(e.getUTCFullYear()-t.getUTCFullYear())*12,t=>t.getUTCMonth()),Co=wr.range;var z=F(t=>{t.setMonth(0,1),t.setHours(0,0,0,0)},(t,e)=>{t.setFullYear(t.getFullYear()+e)},(t,e)=>e.getFullYear()-t.getFullYear(),t=>t.getFullYear());z.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:F(e=>{e.setFullYear(Math.floor(e.getFullYear()/t)*t),e.setMonth(0,1),e.setHours(0,0,0,0)},(e,r)=>{e.setFullYear(e.getFullYear()+r*t)});var Do=z.range,V=F(t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)},(t,e)=>{t.setUTCFullYear(t.getUTCFullYear()+e)},(t,e)=>e.getUTCFullYear()-t.getUTCFullYear(),t=>t.getUTCFullYear());V.every=t=>!isFinite(t=Math.floor(t))||!(t>0)?null:F(e=>{e.setUTCFullYear(Math.floor(e.getUTCFullYear()/t)*t),e.setUTCMonth(0,1),e.setUTCHours(0,0,0,0)},(e,r)=>{e.setUTCFullYear(e.getUTCFullYear()+r*t)});var bo=V.range;function oe(t){if(0<=t.y&&t.y<100){var e=new Date(-1,t.m,t.d,t.H,t.M,t.S,t.L);return e.setFullYear(t.y),e}return new Date(t.y,t.m,t.d,t.H,t.M,t.S,t.L)}function ae(t){if(0<=t.y&&t.y<100){var e=new Date(Date.UTC(-1,t.m,t.d,t.H,t.M,t.S,t.L));return e.setUTCFullYear(t.y),e}return new Date(Date.UTC(t.y,t.m,t.d,t.H,t.M,t.S,t.L))}function Mt(t,e,r){return{y:t,m:e,d:r,H:0,M:0,S:0,L:0}}function ie(t){var e=t.dateTime,r=t.date,n=t.time,o=t.periods,a=t.days,i=t.shortDays,u=t.months,f=t.shortMonths,s=vt(o),m=wt(o),p=vt(a),v=wt(a),w=vt(i),D=wt(i),T=vt(u),W=wt(u),I=vt(f),N=wt(f),b={a:nt,A:ot,b:at,B:P,c:null,d:br,e:br,f:Go,g:ia,G:sa,H:Qo,I:$o,j:Xo,L:Lr,m:Jo,M:Ko,p:Rr,q:qr,Q:kr,s:Ar,S:jo,u:ta,U:ea,V:ra,w:na,W:oa,x:null,X:null,y:aa,Y:ua,Z:fa,"%":Fr},C={a:Or,A:zr,b:Vr,B:Br,c:null,d:Ur,e:Ur,f:pa,g:Sa,G:Ca,H:la,I:ca,j:ma,L:Yr,m:ha,M:da,p:Zr,q:Qr,Q:kr,s:Ar,S:ya,u:ga,U:xa,V:Ma,w:va,W:wa,x:null,X:null,y:_a,Y:Ta,Z:Da,"%":Fr},q={a:ft,A:X,b:h,B:O,c:A,d:Cr,e:Cr,f:zo,g:Tr,G:Sr,H:Dr,I:Dr,j:Io,L:Oo,m:Eo,M:Ro,p:rt,q:Po,Q:Bo,s:Zo,S:qo,u:Lo,U:No,V:Yo,w:Ao,W:Ho,x:B,X:_t,y:Tr,Y:Sr,Z:Wo,"%":Vo};b.x=M(r,b),b.X=M(n,b),b.c=M(e,b),C.x=M(r,C),C.X=M(n,C),C.c=M(e,C);function M(c,d){return function(g){var l=[],L=-1,_=0,Y=c.length,H,G,fe;for(g instanceof Date||(g=new Date(+g));++L53)return null;"w"in l||(l.w=1),"Z"in l?(_=ae(Mt(l.y,0,1)),Y=_.getUTCDay(),_=Y>4||Y===0?it.ceil(_):it(_),_=dt.offset(_,(l.V-1)*7),l.y=_.getUTCFullYear(),l.m=_.getUTCMonth(),l.d=_.getUTCDate()+(l.w+6)%7):(_=oe(Mt(l.y,0,1)),Y=_.getDay(),_=Y>4||Y===0?E.ceil(_):E(_),_=j.offset(_,(l.V-1)*7),l.y=_.getFullYear(),l.m=_.getMonth(),l.d=_.getDate()+(l.w+6)%7)}else("W"in l||"U"in l)&&("w"in l||(l.w="u"in l?l.u%7:"W"in l?1:0),Y="Z"in l?ae(Mt(l.y,0,1)).getUTCDay():oe(Mt(l.y,0,1)).getDay(),l.m=0,l.d="W"in l?(l.w+6)%7+l.W*7-(Y+5)%7:l.w+l.U*7-(Y+6)%7);return"Z"in l?(l.H+=l.Z/100|0,l.M+=l.Z%100,ae(l)):oe(l)}}function k(c,d,g,l){for(var L=0,_=d.length,Y=g.length,H,G;L<_;){if(l>=Y)return-1;if(H=d.charCodeAt(L++),H===37){if(H=d.charAt(L++),G=q[H in _r?d.charAt(L++):H],!G||(l=G(c,g,l))<0)return-1}else if(H!=g.charCodeAt(l++))return-1}return l}function rt(c,d,g){var l=s.exec(d.slice(g));return l?(c.p=m.get(l[0].toLowerCase()),g+l[0].length):-1}function ft(c,d,g){var l=w.exec(d.slice(g));return l?(c.w=D.get(l[0].toLowerCase()),g+l[0].length):-1}function X(c,d,g){var l=p.exec(d.slice(g));return l?(c.w=v.get(l[0].toLowerCase()),g+l[0].length):-1}function h(c,d,g){var l=I.exec(d.slice(g));return l?(c.m=N.get(l[0].toLowerCase()),g+l[0].length):-1}function O(c,d,g){var l=T.exec(d.slice(g));return l?(c.m=W.get(l[0].toLowerCase()),g+l[0].length):-1}function A(c,d,g){return k(c,e,d,g)}function B(c,d,g){return k(c,r,d,g)}function _t(c,d,g){return k(c,n,d,g)}function nt(c){return i[c.getDay()]}function ot(c){return a[c.getDay()]}function at(c){return f[c.getMonth()]}function P(c){return u[c.getMonth()]}function Rr(c){return o[+(c.getHours()>=12)]}function qr(c){return 1+~~(c.getMonth()/3)}function Or(c){return i[c.getUTCDay()]}function zr(c){return a[c.getUTCDay()]}function Vr(c){return f[c.getUTCMonth()]}function Br(c){return u[c.getUTCMonth()]}function Zr(c){return o[+(c.getUTCHours()>=12)]}function Qr(c){return 1+~~(c.getUTCMonth()/3)}return{format:function(c){var d=M(c+="",b);return d.toString=function(){return c},d},parse:function(c){var d=y(c+="",!1);return d.toString=function(){return c},d},utcFormat:function(c){var d=M(c+="",C);return d.toString=function(){return c},d},utcParse:function(c){var d=y(c+="",!0);return d.toString=function(){return c},d}}}var _r={"-":"",_:" ",0:"0"},U=/^\s*\d+/,Uo=/^%/,Fo=/[\\^$*+?|[\]().{}]/g;function x(t,e,r){var n=t<0?"-":"",o=(n?-t:t)+"",a=o.length;return n+(a[e.toLowerCase(),r]))}function Ao(t,e,r){var n=U.exec(e.slice(r,r+1));return n?(t.w=+n[0],r+n[0].length):-1}function Lo(t,e,r){var n=U.exec(e.slice(r,r+1));return n?(t.u=+n[0],r+n[0].length):-1}function No(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.U=+n[0],r+n[0].length):-1}function Yo(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.V=+n[0],r+n[0].length):-1}function Ho(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.W=+n[0],r+n[0].length):-1}function Sr(t,e,r){var n=U.exec(e.slice(r,r+4));return n?(t.y=+n[0],r+n[0].length):-1}function Tr(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.y=+n[0]+(+n[0]>68?1900:2e3),r+n[0].length):-1}function Wo(t,e,r){var n=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(e.slice(r,r+6));return n?(t.Z=n[1]?0:-(n[2]+(n[3]||"00")),r+n[0].length):-1}function Po(t,e,r){var n=U.exec(e.slice(r,r+1));return n?(t.q=n[0]*3-3,r+n[0].length):-1}function Eo(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.m=n[0]-1,r+n[0].length):-1}function Cr(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.d=+n[0],r+n[0].length):-1}function Io(t,e,r){var n=U.exec(e.slice(r,r+3));return n?(t.m=0,t.d=+n[0],r+n[0].length):-1}function Dr(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.H=+n[0],r+n[0].length):-1}function Ro(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.M=+n[0],r+n[0].length):-1}function qo(t,e,r){var n=U.exec(e.slice(r,r+2));return n?(t.S=+n[0],r+n[0].length):-1}function Oo(t,e,r){var n=U.exec(e.slice(r,r+3));return n?(t.L=+n[0],r+n[0].length):-1}function zo(t,e,r){var n=U.exec(e.slice(r,r+6));return n?(t.L=Math.floor(n[0]/1e3),r+n[0].length):-1}function Vo(t,e,r){var n=Uo.exec(e.slice(r,r+1));return n?r+n[0].length:-1}function Bo(t,e,r){var n=U.exec(e.slice(r));return n?(t.Q=+n[0],r+n[0].length):-1}function Zo(t,e,r){var n=U.exec(e.slice(r));return n?(t.s=+n[0],r+n[0].length):-1}function br(t,e){return x(t.getDate(),e,2)}function Qo(t,e){return x(t.getHours(),e,2)}function $o(t,e){return x(t.getHours()%12||12,e,2)}function Xo(t,e){return x(1+j.count(z(t),t),e,3)}function Lr(t,e){return x(t.getMilliseconds(),e,3)}function Go(t,e){return Lr(t,e)+"000"}function Jo(t,e){return x(t.getMonth()+1,e,2)}function Ko(t,e){return x(t.getMinutes(),e,2)}function jo(t,e){return x(t.getSeconds(),e,2)}function ta(t){var e=t.getDay();return e===0?7:e}function ea(t,e){return x(yt.count(z(t)-1,t),e,2)}function Nr(t){var e=t.getDay();return e>=4||e===0?Q(t):Q.ceil(t)}function ra(t,e){return t=Nr(t),x(Q.count(z(t),t)+(z(t).getDay()===4),e,2)}function na(t){return t.getDay()}function oa(t,e){return x(E.count(z(t)-1,t),e,2)}function aa(t,e){return x(t.getFullYear()%100,e,2)}function ia(t,e){return t=Nr(t),x(t.getFullYear()%100,e,2)}function ua(t,e){return x(t.getFullYear()%1e4,e,4)}function sa(t,e){var r=t.getDay();return t=r>=4||r===0?Q(t):Q.ceil(t),x(t.getFullYear()%1e4,e,4)}function fa(t){var e=t.getTimezoneOffset();return(e>0?"-":(e*=-1,"+"))+x(e/60|0,"0",2)+x(e%60,"0",2)}function Ur(t,e){return x(t.getUTCDate(),e,2)}function la(t,e){return x(t.getUTCHours(),e,2)}function ca(t,e){return x(t.getUTCHours()%12||12,e,2)}function ma(t,e){return x(1+dt.count(V(t),t),e,3)}function Yr(t,e){return x(t.getUTCMilliseconds(),e,3)}function pa(t,e){return Yr(t,e)+"000"}function ha(t,e){return x(t.getUTCMonth()+1,e,2)}function da(t,e){return x(t.getUTCMinutes(),e,2)}function ya(t,e){return x(t.getUTCSeconds(),e,2)}function ga(t){var e=t.getUTCDay();return e===0?7:e}function xa(t,e){return x(gt.count(V(t)-1,t),e,2)}function Hr(t){var e=t.getUTCDay();return e>=4||e===0?$(t):$.ceil(t)}function Ma(t,e){return t=Hr(t),x($.count(V(t),t)+(V(t).getUTCDay()===4),e,2)}function va(t){return t.getUTCDay()}function wa(t,e){return x(it.count(V(t)-1,t),e,2)}function _a(t,e){return x(t.getUTCFullYear()%100,e,2)}function Sa(t,e){return t=Hr(t),x(t.getUTCFullYear()%100,e,2)}function Ta(t,e){return x(t.getUTCFullYear()%1e4,e,4)}function Ca(t,e){var r=t.getUTCDay();return t=r>=4||r===0?$(t):$.ceil(t),x(t.getUTCFullYear()%1e4,e,4)}function Da(){return"+0000"}function Fr(){return"%"}function kr(t){return+t}function Ar(t){return Math.floor(+t/1e3)}var ut,st,Wr,Pr,Er;ue({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});function ue(t){return ut=ie(t),st=ut.format,Wr=ut.parse,Pr=ut.utcFormat,Er=ut.utcParse,ut}var ba={cellSize:13,cellGap:2,marginTop:20,marginLeft:30,marginBottom:4},se=["#9be9a8","#40c463","#30a14e","#216e39"],Ua=["Mon","","Wed","","Fri","",""],Fa=st("%b"),ka=st("%Y-%m-%d"),Aa=st("%a, %b %-d, %Y");function La(t){try{return getComputedStyle(t).getPropertyValue("--tblr-bg-surface"),se}catch{return se}}function Na(t){let e=new Map;for(let r of t)e.set(r.date,r);return e}function Ya(t,e,r){let n=E.floor(t),o=[],a=new Date(t);for(;a<=e;){let i=ka(a),u=E.count(n,a),f=(a.getDay()+6)%7;o.push({date:new Date(a),dateStr:i,entry:r.get(i)||null,week:u,day:f}),a=j.offset(a,1)}return o}function Ha(){let t=document.createElement("div");return t.className="heatmap-tooltip",t.style.display="none",t}function Ir(t,e,r=ba){if(t.innerHTML="",!e.days||e.days.length===0){let y=document.createElement("div");y.textContent="No tracking data available",y.style.padding="1rem",y.style.color="var(--tblr-secondary, #6c757d)",t.appendChild(y);return}let n=Na(e.days),o=new Date(e.range.begin),a=new Date(e.range.end),i=Ya(o,a,n),u=mt(e.days,y=>y.hours)||1,f=La(t),s=ht().domain([0,u]).range(f),{cellSize:m,cellGap:p,marginTop:v,marginLeft:w,marginBottom:D}=r,T=m+p,W=(mt(i,y=>y.week)??0)+1,I=w+W*T,N=v+7*T+D,b=Ot(t).append("svg").attr("width",I).attr("height",N).attr("class","heatmap-svg"),C=[],q=E.floor(o);xt.range(xt.ceil(o),a).forEach(y=>{C.push({date:y,week:E.count(q,y)})}),b.selectAll(".month-label").data(C).join("text").attr("class","heatmap-label month-label").attr("x",y=>w+y.week*T).attr("y",v-6).text(y=>Fa(y.date)),b.selectAll(".day-label").data(Ua).join("text").attr("class","heatmap-label day-label").attr("x",w-6).attr("y",(y,k)=>v+k*T+m-2).attr("text-anchor","end").text(y=>y);let M=Ha();t.appendChild(M),b.selectAll(".heatmap-cell").data(i).join("rect").attr("class",y=>y.entry?"heatmap-cell":"heatmap-cell heatmap-empty").attr("x",y=>w+y.week*T).attr("y",y=>v+y.day*T).attr("width",m).attr("height",m).attr("fill",y=>y.entry?s(y.entry.hours):"").on("mouseenter",function(y,k){let rt=k.entry?k.entry.hours.toFixed(1):"0.0",ft=k.entry?k.entry.count:0;M.innerHTML=`${Aa(k.date)}
${rt}h (${ft} entries)`,M.style.display="block";let X=y.target.getBoundingClientRect(),h=t.getBoundingClientRect();M.style.left=`${X.left-h.left+m/2}px`,M.style.top=`${X.top-h.top-40}px`}).on("mouseleave",function(){M.style.display="none"})}function Wa(t){let e=t.getAttribute("data-url");if(!e){console.error("KimaiHeatmap: missing data-url attribute");return}fetch(e).then(r=>{if(!r.ok)throw new Error(`HTTP ${r.status}`);return r.json()}).then(r=>{Ir(t,r)}).catch(r=>{console.error("KimaiHeatmap: failed to load data",r),t.textContent="Failed to load heatmap data"})}return jr(Pa);})(); diff --git a/assets/src/heatmap.ts b/assets/src/heatmap.ts index 0da1820..5313e83 100644 --- a/assets/src/heatmap.ts +++ b/assets/src/heatmap.ts @@ -1,3 +1,206 @@ -export function init(container: HTMLElement): void { - console.log('Heatmap init', container); +import { select } from 'd3-selection'; +import { scaleQuantize } from 'd3-scale'; +import { timeMonday, timeDay, timeMonth } from 'd3-time'; +import { timeFormat } from 'd3-time-format'; +import { max } from 'd3-array'; +import type { DayEntry, HeatmapData, HeatmapConfig } from './types'; + +const DEFAULT_CONFIG: HeatmapConfig = { + cellSize: 13, + cellGap: 2, + marginTop: 20, + marginLeft: 30, + marginBottom: 4, +}; + +const FALLBACK_COLORS = ['#9be9a8', '#40c463', '#30a14e', '#216e39']; +const DAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', '']; +const MONTH_FORMAT = timeFormat('%b'); +const DATE_FORMAT = timeFormat('%Y-%m-%d'); +const DISPLAY_FORMAT = timeFormat('%a, %b %-d, %Y'); + +interface DayCell { + date: Date; + dateStr: string; + entry: DayEntry | null; + week: number; + day: number; +} + +function resolveColors(container: HTMLElement): string[] { + try { + const style = getComputedStyle(container); + const test = style.getPropertyValue('--tblr-bg-surface'); + if (!test) return FALLBACK_COLORS; + return FALLBACK_COLORS; // Use hardcoded greens — Tabler doesn't expose a green scale via CSS vars + } catch { + return FALLBACK_COLORS; + } +} + +function buildDateMap(days: DayEntry[]): Map { + const map = new Map(); + for (const d of days) { + map.set(d.date, d); + } + return map; +} + +function generateCells( + begin: Date, + end: Date, + dateMap: Map, +): DayCell[] { + const firstMonday = timeMonday.floor(begin); + const cells: DayCell[] = []; + let current = new Date(begin); + + while (current <= end) { + const dateStr = DATE_FORMAT(current); + const weeksSinceStart = timeMonday.count(firstMonday, current); + const dayOfWeek = (current.getDay() + 6) % 7; // Monday=0, Sunday=6 + + cells.push({ + date: new Date(current), + dateStr, + entry: dateMap.get(dateStr) || null, + week: weeksSinceStart, + day: dayOfWeek, + }); + + current = timeDay.offset(current, 1); + } + + return cells; +} + +function createTooltip(): HTMLDivElement { + const tip = document.createElement('div'); + tip.className = 'heatmap-tooltip'; + tip.style.display = 'none'; + return tip; +} + +export function renderHeatmap( + container: HTMLElement, + data: HeatmapData, + config: HeatmapConfig = DEFAULT_CONFIG, +): void { + container.innerHTML = ''; + + if (!data.days || data.days.length === 0) { + const msg = document.createElement('div'); + msg.textContent = 'No tracking data available'; + msg.style.padding = '1rem'; + msg.style.color = 'var(--tblr-secondary, #6c757d)'; + container.appendChild(msg); + return; + } + + const dateMap = buildDateMap(data.days); + const begin = new Date(data.range.begin); + const end = new Date(data.range.end); + const cells = generateCells(begin, end, dateMap); + + const maxHours = max(data.days, (d) => d.hours) || 1; + const colors = resolveColors(container); + + const colorScale = scaleQuantize() + .domain([0, maxHours]) + .range(colors); + + const { cellSize, cellGap, marginTop, marginLeft, marginBottom } = config; + const step = cellSize + cellGap; + const numWeeks = (max(cells, (c) => c.week) ?? 0) + 1; + const svgWidth = marginLeft + numWeeks * step; + const svgHeight = marginTop + 7 * step + marginBottom; + + const svg = select(container) + .append('svg') + .attr('width', svgWidth) + .attr('height', svgHeight) + .attr('class', 'heatmap-svg'); + + // Month labels + const months: { date: Date; week: number }[] = []; + const firstMonday = timeMonday.floor(begin); + timeMonth.range(timeMonth.ceil(begin), end).forEach((m) => { + months.push({ + date: m, + week: timeMonday.count(firstMonday, m), + }); + }); + + svg + .selectAll('.month-label') + .data(months) + .join('text') + .attr('class', 'heatmap-label month-label') + .attr('x', (d) => marginLeft + d.week * step) + .attr('y', marginTop - 6) + .text((d) => MONTH_FORMAT(d.date)); + + // Day labels (Mon, Wed, Fri) + svg + .selectAll('.day-label') + .data(DAY_LABELS) + .join('text') + .attr('class', 'heatmap-label day-label') + .attr('x', marginLeft - 6) + .attr('y', (_d, i) => marginTop + i * step + cellSize - 2) + .attr('text-anchor', 'end') + .text((d) => d); + + // Tooltip + const tooltip = createTooltip(); + container.appendChild(tooltip); + + // Cells + svg + .selectAll('.heatmap-cell') + .data(cells) + .join('rect') + .attr('class', (d) => + d.entry ? 'heatmap-cell' : 'heatmap-cell heatmap-empty', + ) + .attr('x', (d) => marginLeft + d.week * step) + .attr('y', (d) => marginTop + d.day * step) + .attr('width', cellSize) + .attr('height', cellSize) + .attr('fill', (d) => (d.entry ? colorScale(d.entry.hours) : '')) + .on('mouseenter', function (event: MouseEvent, d: DayCell) { + const hours = d.entry ? d.entry.hours.toFixed(1) : '0.0'; + const count = d.entry ? d.entry.count : 0; + tooltip.innerHTML = `${DISPLAY_FORMAT(d.date)}
${hours}h (${count} entries)`; + tooltip.style.display = 'block'; + + const rect = (event.target as SVGRectElement).getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + tooltip.style.left = `${rect.left - containerRect.left + cellSize / 2}px`; + tooltip.style.top = `${rect.top - containerRect.top - 40}px`; + }) + .on('mouseleave', function () { + tooltip.style.display = 'none'; + }); +} + +export function init(container: HTMLElement): void { + const url = container.getAttribute('data-url'); + if (!url) { + console.error('KimaiHeatmap: missing data-url attribute'); + return; + } + + fetch(url) + .then((res) => { + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json() as Promise; + }) + .then((data) => { + renderHeatmap(container, data); + }) + .catch((err) => { + console.error('KimaiHeatmap: failed to load data', err); + container.textContent = 'Failed to load heatmap data'; + }); } diff --git a/assets/src/types.ts b/assets/src/types.ts new file mode 100644 index 0000000..c8e563a --- /dev/null +++ b/assets/src/types.ts @@ -0,0 +1,21 @@ +export interface DayEntry { + date: string; // "YYYY-MM-DD" + hours: number; + count: number; +} + +export interface HeatmapData { + days: DayEntry[]; + range: { + begin: string; + end: string; + }; +} + +export interface HeatmapConfig { + cellSize: number; + cellGap: number; + marginTop: number; + marginLeft: number; + marginBottom: number; +} diff --git a/assets/test/heatmap.test.ts b/assets/test/heatmap.test.ts index e5b086c..2f7b1f0 100644 --- a/assets/test/heatmap.test.ts +++ b/assets/test/heatmap.test.ts @@ -1,7 +1,136 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHeatmap } from '../src/heatmap'; +import type { HeatmapData } from '../src/types'; -describe('heatmap', () => { - it('placeholder', () => { - expect(true).toBe(true); +function makeMockData(overrides: Partial = {}): HeatmapData { + return { + days: [ + { date: '2025-01-06', hours: 2.5, count: 3 }, + { date: '2025-01-07', hours: 5.0, count: 5 }, + { date: '2025-01-08', hours: 1.0, count: 1 }, + { date: '2025-01-13', hours: 8.0, count: 4 }, + { date: '2025-01-20', hours: 3.5, count: 2 }, + ], + range: { + begin: '2025-01-01', + end: '2025-01-31', + }, + ...overrides, + }; +} + +describe('renderHeatmap', () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('renders an SVG element', () => { + renderHeatmap(container, makeMockData()); + const svg = container.querySelector('svg'); + expect(svg).not.toBeNull(); + }); + + it('renders correct number of rect elements for date range', () => { + renderHeatmap(container, makeMockData()); + const rects = container.querySelectorAll('rect.heatmap-cell'); + // Jan 1 to Jan 31 = 31 days + expect(rects.length).toBe(31); + }); + + it('applies heatmap-empty class to cells with no data', () => { + renderHeatmap(container, makeMockData()); + const emptyRects = container.querySelectorAll('rect.heatmap-empty'); + // 31 days total, 5 with data = 26 empty + expect(emptyRects.length).toBe(26); + }); + + it('applies fill attribute to cells with data', () => { + renderHeatmap(container, makeMockData()); + const allRects = container.querySelectorAll('rect.heatmap-cell:not(.heatmap-empty)'); + expect(allRects.length).toBe(5); + allRects.forEach((rect) => { + expect(rect.getAttribute('fill')).toBeTruthy(); + }); + }); + + it('renders day labels (Mon, Wed, Fri)', () => { + renderHeatmap(container, makeMockData()); + const dayLabels = container.querySelectorAll('text.day-label'); + expect(dayLabels.length).toBe(7); // all 7 slots rendered + const texts = Array.from(dayLabels).map((el) => el.textContent); + expect(texts).toContain('Mon'); + expect(texts).toContain('Wed'); + expect(texts).toContain('Fri'); + }); + + it('renders month labels', () => { + // Use a range spanning two months + const data = makeMockData({ + range: { begin: '2025-01-01', end: '2025-02-28' }, + days: [ + { date: '2025-01-15', hours: 3.0, count: 2 }, + { date: '2025-02-10', hours: 4.0, count: 1 }, + ], + }); + renderHeatmap(container, data); + const monthLabels = container.querySelectorAll('text.month-label'); + expect(monthLabels.length).toBeGreaterThan(0); + const texts = Array.from(monthLabels).map((el) => el.textContent); + expect(texts).toContain('Feb'); + }); + + it('creates tooltip on mouseenter', () => { + renderHeatmap(container, makeMockData()); + const rect = container.querySelector( + 'rect.heatmap-cell:not(.heatmap-empty)', + ); + expect(rect).not.toBeNull(); + + // Simulate mouseenter + const event = new MouseEvent('mouseenter', { bubbles: true }); + // jsdom getBoundingClientRect returns zeros, which is fine for structure test + rect!.dispatchEvent(event); + + const tooltip = container.querySelector('.heatmap-tooltip') as HTMLDivElement; + expect(tooltip).not.toBeNull(); + expect(tooltip.style.display).toBe('block'); + expect(tooltip.innerHTML).toContain('h'); + expect(tooltip.innerHTML).toContain('entries'); + }); + + it('hides tooltip on mouseleave', () => { + renderHeatmap(container, makeMockData()); + const rect = container.querySelector( + 'rect.heatmap-cell:not(.heatmap-empty)', + ); + + rect!.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + rect!.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + + const tooltip = container.querySelector('.heatmap-tooltip') as HTMLDivElement; + expect(tooltip.style.display).toBe('none'); + }); + + it('handles empty days array gracefully', () => { + renderHeatmap(container, makeMockData({ days: [] })); + const svg = container.querySelector('svg'); + expect(svg).toBeNull(); + expect(container.textContent).toContain('No tracking data available'); + }); + + it('only renders cells within the begin/end range', () => { + const data: HeatmapData = { + days: [ + { date: '2025-03-01', hours: 2.0, count: 1 }, + { date: '2025-03-05', hours: 4.0, count: 2 }, + ], + range: { begin: '2025-03-01', end: '2025-03-07' }, + }; + renderHeatmap(container, data); + const rects = container.querySelectorAll('rect.heatmap-cell'); + expect(rects.length).toBe(7); }); });