s||a=b.length;return k.i=b.length-1,furthestChar:b[k.i]}},k}},{"./chunker":36}],38:[function(a,b,c){var d=a("../less-error"),e=a("../tree"),f=a("../visitors"),g=a("./parser-input"),h=a("../utils"),i=function j(a,b,c){function i(a,e){throw new d({index:o.i,filename:c.filename,type:e||"Syntax",message:a},b)}function k(a,b,c){var d=a instanceof Function?a.call(n):o.$re(a);return d?d:void i(b||("string"==typeof a?"expected '"+a+"' got '"+o.currentChar()+"'":"unexpected token"))}function l(a,b){return o.$char(a)?a:void i(b||"expected '"+a+"' got '"+o.currentChar()+"'")}function m(a){var b=c.filename;return{lineNumber:h.getLocation(a,o.getInput()).line+1,fileName:b}}var n,o=g();return{parse:function(g,h,i){var k,l,m,n,p=null,q="";if(l=i&&i.globalVars?j.serializeVars(i.globalVars)+"\n":"",m=i&&i.modifyVars?"\n"+j.serializeVars(i.modifyVars):"",a.pluginManager)for(var r=a.pluginManager.getPreProcessors(),s=0;s1&&(b=new e.Value(g)),d.push(b),g=[])}return o.forget(),a?d:f},literal:function(){return this.dimension()||this.color()||this.quoted()||this.unicodeDescriptor()},assignment:function(){var a,b;return o.save(),(a=o.$re(/^\w+(?=\s?=)/i))&&o.$char("=")&&(b=n.entity())?(o.forget(),new e.Assignment(a,b)):void o.restore()},url:function(){var a,b=o.i;return o.autoCommentAbsorb=!1,o.$str("url(")?(a=this.quoted()||this.variable()||o.$re(/^(?:(?:\\[\(\)'"])|[^\(\)'"])+/)||"",o.autoCommentAbsorb=!0,l(")"),new e.URL(null!=a.value||a instanceof e.Variable?a:new e.Anonymous(a),b,c)):void(o.autoCommentAbsorb=!0)},variable:function(){var a,b=o.i;if("@"===o.currentChar()&&(a=o.$re(/^@@?[\w-]+/)))return new e.Variable(a,b,c)},variableCurly:function(){var a,b=o.i;if("@"===o.currentChar()&&(a=o.$re(/^@\{([\w-]+)\}/)))return new e.Variable("@"+a[1],b,c)},color:function(){var a;if("#"===o.currentChar()&&(a=o.$re(/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})/))){var b=a.input.match(/^#([\w]+).*/);return b=b[1],b.match(/^[A-Fa-f0-9]+$/)||i("Invalid HEX color code"),new e.Color(a[1],(void 0),"#"+b)}},colorKeyword:function(){o.save();var a=o.autoCommentAbsorb;o.autoCommentAbsorb=!1;var b=o.$re(/^[_A-Za-z-][_A-Za-z0-9-]+/);if(o.autoCommentAbsorb=a,!b)return void o.forget();o.restore();var c=e.Color.fromKeyword(b);return c?(o.$str(b),c):void 0},dimension:function(){if(!o.peekNotNumeric()){var a=o.$re(/^([+-]?\d*\.?\d+)(%|[a-z_]+)?/i);return a?new e.Dimension(a[1],a[2]):void 0}},unicodeDescriptor:function(){var a;if(a=o.$re(/^U\+[0-9a-fA-F?]+(\-[0-9a-fA-F?]+)?/))return new e.UnicodeDescriptor(a[0])},javascript:function(){var a,b=o.i;o.save();var d=o.$char("~"),f=o.$char("`");return f?(a=o.$re(/^[^`]*`/))?(o.forget(),new e.JavaScript(a.substr(0,a.length-1),Boolean(d),b,c)):void o.restore("invalid javascript definition"):void o.restore()}},variable:function(){var a;if("@"===o.currentChar()&&(a=o.$re(/^(@[\w-]+)\s*:/)))return a[1]},rulesetCall:function(){var a;if("@"===o.currentChar()&&(a=o.$re(/^(@[\w-]+)\(\s*\)\s*;/)))return new e.RulesetCall(a[1])},extend:function(a){var b,d,f,g,h,j=o.i;if(o.$str(a?"&:extend(":":extend(")){do{for(f=null,b=null;!(f=o.$re(/^(all)(?=\s*(\)|,))/))&&(d=this.element());)b?b.push(d):b=[d];f=f&&f[1],b||i("Missing target selector for :extend()."),h=new e.Extend(new e.Selector(b),f,j,c),g?g.push(h):g=[h]}while(o.$char(","));return k(/^\)/),a&&k(/^;/),g}},extendRule:function(){return this.extend(!0)},mixin:{call:function(){var a,b,d,f,g,h,i=o.currentChar(),j=!1,k=o.i;if("."===i||"#"===i){for(o.save();;){if(a=o.i,f=o.$re(/^[#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/),!f)break;d=new e.Element(g,f,a,c),b?b.push(d):b=[d],g=o.$char(">")}return b&&(o.$char("(")&&(h=this.args(!0).args,l(")")),n.important()&&(j=!0),n.end())?(o.forget(),new e.mixin.Call(b,h,k,c,j)):void o.restore()}},args:function(a){var b,c,d,f,g,h,j,k=n.entities,l={args:null,variadic:!1},m=[],p=[],q=[];for(o.save();;){if(a)h=n.detachedRuleset()||n.expression();else{if(o.commentStore.length=0,o.$str("...")){l.variadic=!0,o.$char(";")&&!b&&(b=!0),(b?p:q).push({variadic:!0});break}h=k.variable()||k.literal()||k.keyword()}if(!h)break;f=null,h.throwAwayComments&&h.throwAwayComments(),g=h;var r=null;if(a?h.value&&1==h.value.length&&(r=h.value[0]):r=h,r&&r instanceof e.Variable)if(o.$char(":")){if(m.length>0&&(b&&i("Cannot mix ; and , as delimiter types"),c=!0),g=n.detachedRuleset()||n.expression(),!g){if(!a)return o.restore(),l.args=[],l;i("could not understand value for named argument")}f=d=r.name}else if(o.$str("...")){if(!a){l.variadic=!0,o.$char(";")&&!b&&(b=!0),(b?p:q).push({name:h.name,variadic:!0});break}j=!0}else a||(d=f=r.name,g=null);g&&m.push(g),q.push({name:f,value:g,expand:j}),o.$char(",")||(o.$char(";")||b)&&(c&&i("Cannot mix ; and , as delimiter types"),b=!0,m.length>1&&(g=new e.Value(m)),p.push({name:d,value:g,expand:j}),d=null,m=[],c=!1)}return o.forget(),l.args=b?p:q,l},definition:function(){var a,b,c,d,f=[],g=!1;if(!("."!==o.currentChar()&&"#"!==o.currentChar()||o.peek(/^[^{]*\}/)))if(o.save(),b=o.$re(/^([#.](?:[\w-]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+)\s*\(/)){a=b[1];var h=this.args(!1);if(f=h.args,g=h.variadic,!o.$char(")"))return void o.restore("Missing closing ')'");if(o.commentStore.length=0,o.$str("when")&&(d=k(n.conditions,"expected condition")),c=n.block())return o.forget(),new e.mixin.Definition(a,f,c,d,g);o.restore()}else o.forget()}},entity:function(){var a=this.entities;return this.comment()||a.literal()||a.variable()||a.url()||a.call()||a.keyword()||a.javascript()},end:function(){return o.$char(";")||o.peek("}")},alpha:function(){var a;if(o.$re(/^opacity=/i))return a=o.$re(/^\d+/),a||(a=k(this.entities.variable,"Could not parse alpha")),l(")"),new e.Alpha(a)},element:function(){var a,b,d,f=o.i;if(b=this.combinator(),a=o.$re(/^(?:\d+\.\d+|\d+)%/)||o.$re(/^(?:[.#]?|:*)(?:[\w-]|[^\x00-\x9f]|\\(?:[A-Fa-f0-9]{1,6} ?|[^A-Fa-f0-9]))+/)||o.$char("*")||o.$char("&")||this.attribute()||o.$re(/^\([^&()@]+\)/)||o.$re(/^[\.#:](?=@)/)||this.entities.variableCurly(),a||(o.save(),o.$char("(")?(d=this.selector())&&o.$char(")")?(a=new e.Paren(d),o.forget()):o.restore("Missing closing ')'"):o.forget()),a)return new e.Element(b,a,f,c)},combinator:function(){var a=o.currentChar();if("/"===a){o.save();var b=o.$re(/^\/[a-z]+\//i);if(b)return o.forget(),new e.Combinator(b);o.restore()}if(">"===a||"+"===a||"~"===a||"|"===a||"^"===a){for(o.i++,"^"===a&&"^"===o.currentChar()&&(a="^^",o.i++);o.isWhitespace();)o.i++;return new e.Combinator(a)}return new e.Combinator(o.isWhitespace(-1)?" ":null)},lessSelector:function(){return this.selector(!0)},selector:function(a){for(var b,d,f,g,h,j,l,m=o.i;(a&&(d=this.extend())||a&&(j=o.$str("when"))||(g=this.element()))&&(j?l=k(this.conditions,"expected condition"):l?i("CSS guard can only be used at the end of selector"):d?h=h?h.concat(d):d:(h&&i("Extend can only be used at the end of selector"),f=o.currentChar(),b?b.push(g):b=[g],g=null),"{"!==f&&"}"!==f&&";"!==f&&","!==f&&")"!==f););return b?new e.Selector(b,h,l,m,c):void(h&&i("Extend must be used to extend a selector, it cannot be used on its own"))},attribute:function(){if(o.$char("[")){var a,b,c,d=this.entities;return(a=d.variableCurly())||(a=k(/^(?:[_A-Za-z0-9-\*]*\|)?(?:[_A-Za-z0-9-]|\\.)+/)),c=o.$re(/^[|~*$^]?=/),c&&(b=d.quoted()||o.$re(/^[0-9]+%/)||o.$re(/^[\w-]+/)||d.variableCurly()),l("]"),new e.Attribute(a,c,b)}},block:function(){var a;if(o.$char("{")&&(a=this.primary())&&o.$char("}"))return a},blockRuleset:function(){var a=this.block();return a&&(a=new e.Ruleset(null,a)),a},detachedRuleset:function(){var a=this.blockRuleset();if(a)return new e.DetachedRuleset(a)},ruleset:function(){var b,c,d,f;for(o.save(),a.dumpLineNumbers&&(f=m(o.i));;){if(c=this.lessSelector(),!c)break;if(b?b.push(c):b=[c],o.commentStore.length=0,c.condition&&b.length>1&&i("Guards are only currently allowed on a single selector."),!o.$char(","))break;c.condition&&i("Guards are only currently allowed on a single selector."),o.commentStore.length=0}if(b&&(d=this.block())){o.forget();var g=new e.Ruleset(b,d,a.strictImports);return a.dumpLineNumbers&&(g.debugInfo=f),g}o.restore()},rule:function(b){var d,f,g,h,i,j=o.i,k=o.currentChar();if("."!==k&&"#"!==k&&"&"!==k&&":"!==k)if(o.save(),d=this.variable()||this.ruleProperty()){if(i="string"==typeof d,i&&(f=this.detachedRuleset()),o.commentStore.length=0,!f){h=!i&&d.length>1&&d.pop().value;var l=!b&&(a.compress||i);if(l&&(f=this.value()),!f&&(f=this.anonymousValue()))return o.forget(),new e.Rule(d,f,(!1),h,j,c);l||f||(f=this.value()),g=this.important()}if(f&&this.end())return o.forget(),new e.Rule(d,f,g,h,j,c);if(o.restore(),f&&!b)return this.rule(!0)}else o.forget()},anonymousValue:function(){var a=o.$re(/^([^@+\/'"*`(;{}-]*);/);if(a)return new e.Anonymous(a[1])},"import":function(){var a,b,d=o.i,f=o.$re(/^@import?\s+/);if(f){var g=(f?this.importOptions():null)||{};if(a=this.entities.quoted()||this.entities.url())return b=this.mediaFeatures(),o.$char(";")||(o.i=d,i("missing semi-colon or unrecognised media features on import")),b=b&&new e.Value(b),new e.Import(a,b,g,d,c);o.i=d,i("malformed import statement")}},importOptions:function(){var a,b,c,d={};if(!o.$char("("))return null;do if(a=this.importOption()){switch(b=a,c=!0,b){case"css":b="less",c=!1;break;case"once":b="multiple",c=!1}if(d[b]=c,!o.$char(","))break}while(a);return l(")"),d},importOption:function(){var a=o.$re(/^(less|css|multiple|once|inline|reference|optional)/);if(a)return a[1]},mediaFeature:function(){var a,b,d=this.entities,f=[];o.save();do a=d.keyword()||d.variable(),a?f.push(a):o.$char("(")&&(b=this.property(),a=this.value(),o.$char(")")?b&&a?f.push(new e.Paren(new e.Rule(b,a,null,null,o.i,c,(!0)))):a?f.push(new e.Paren(a)):i("badly formed media feature definition"):i("Missing closing ')'","Parse"));while(a);if(o.forget(),f.length>0)return new e.Expression(f)},mediaFeatures:function(){var a,b=this.entities,c=[];do if(a=this.mediaFeature()){if(c.push(a),!o.$char(","))break}else if(a=b.variable(),a&&(c.push(a),!o.$char(",")))break;while(a);return c.length>0?c:null},media:function(){var b,d,f,g,h=o.i;return a.dumpLineNumbers&&(g=m(h)),o.save(),o.$str("@media")?(b=this.mediaFeatures(),d=this.block(),d||i("media definitions require block statements after any features"),o.forget(),f=new e.Media(d,b,h,c),a.dumpLineNumbers&&(f.debugInfo=g),f):void o.restore()},plugin:function(){var a,b=o.i,d=o.$re(/^@plugin?\s+/);if(d){var f={plugin:!0};if(a=this.entities.quoted()||this.entities.url())return o.$char(";")||(o.i=b,i("missing semi-colon on plugin")),new e.Import(a,null,f,b,c);o.i=b,i("malformed plugin statement")}},directive:function(){var b,d,f,g,h,j,k,l=o.i,n=!0,p=!0;if("@"===o.currentChar()){if(d=this["import"]()||this.plugin()||this.media())return d;if(o.save(),b=o.$re(/^@[a-z-]+/)){switch(g=b,"-"==b.charAt(1)&&b.indexOf("-",2)>0&&(g="@"+b.slice(b.indexOf("-",2)+1)),g){case"@charset":h=!0,n=!1;break;case"@namespace":j=!0,n=!1;break;case"@keyframes":case"@counter-style":h=!0;break;case"@document":case"@supports":k=!0,p=!1;break;default:k=!0}return o.commentStore.length=0,h?(d=this.entity(),d||i("expected "+b+" identifier")):j?(d=this.expression(),d||i("expected "+b+" expression")):k&&(d=(o.$re(/^[^{;]+/)||"").trim(),n="{"==o.currentChar(),d&&(d=new e.Anonymous(d))),n&&(f=this.blockRuleset()),f||!n&&d&&o.$char(";")?(o.forget(),new e.Directive(b,d,f,l,c,a.dumpLineNumbers?m(l):null,p)):void o.restore("directive options not recognised")}}},value:function(){var a,b=[];do if(a=this.expression(),a&&(b.push(a),!o.$char(",")))break;while(a);if(b.length>0)return new e.Value(b)},important:function(){if("!"===o.currentChar())return o.$re(/^! *important/)},sub:function(){var a,b;return o.save(),o.$char("(")?(a=this.addition(),a&&o.$char(")")?(o.forget(),b=new e.Expression([a]),b.parens=!0,b):void o.restore("Expected ')'")):void o.restore()},multiplication:function(){var a,b,c,d,f;if(a=this.operand()){for(f=o.isWhitespace(-1);;){if(o.peek(/^\/[*\/]/))break;if(o.save(),c=o.$char("/")||o.$char("*"),!c){o.forget();break}if(b=this.operand(),!b){o.restore();break}o.forget(),a.parensInOp=!0,b.parensInOp=!0,d=new e.Operation(c,[d||a,b],f),f=o.isWhitespace(-1)}return d||a}},addition:function(){var a,b,c,d,f;if(a=this.multiplication()){for(f=o.isWhitespace(-1);;){if(c=o.$re(/^[-+]\s+/)||!f&&(o.$char("+")||o.$char("-")),!c)break;if(b=this.multiplication(),!b)break;a.parensInOp=!0,b.parensInOp=!0,d=new e.Operation(c,[d||a,b],f),f=o.isWhitespace(-1)}return d||a}},conditions:function(){var a,b,c,d=o.i;if(a=this.condition()){for(;;){if(!o.peek(/^,\s*(not\s*)?\(/)||!o.$char(","))break;if(b=this.condition(),!b)break;c=new e.Condition("or",c||a,b,d)}return c||a}},condition:function(){function a(){return o.$str("or")}var b,c,d;if(b=this.conditionAnd(this)){if(c=a()){if(d=this.condition(),!d)return;b=new e.Condition(c,b,d)}return b}},conditionAnd:function(){function a(a){return a.negatedCondition()||a.parenthesisCondition()}function b(){return o.$str("and")}var c,d,f;if(c=a(this)){if(d=b()){if(f=this.conditionAnd(),!f)return;c=new e.Condition(d,c,f)}return c}},negatedCondition:function(){if(o.$str("not")){var a=this.parenthesisCondition();return a&&(a.negate=!a.negate),a}},parenthesisCondition:function(){function a(a){var b;return o.save(),(b=a.condition())&&o.$char(")")?(o.forget(),b):void o.restore()}var b;return o.save(),o.$str("(")?(b=a(this))?(o.forget(),b):(b=this.atomicCondition())?o.$char(")")?(o.forget(),b):void o.restore("expected ')' got '"+o.currentChar()+"'"):void o.restore():void o.restore()},atomicCondition:function(){var a,b,c,d,f=this.entities,g=o.i;if(a=this.addition()||f.keyword()||f.quoted())return o.$char(">")?d=o.$char("=")?">=":">":o.$char("<")?d=o.$char("=")?"<=":"<":o.$char("=")&&(d=o.$char(">")?"=>":o.$char("<")?"=<":"="),d?(b=this.addition()||f.keyword()||f.quoted(),b?c=new e.Condition(d,a,b,g,(!1)):i("expected expression")):c=new e.Condition("=",a,new e.Keyword("true"),g,(!1)),c},operand:function(){var a,b=this.entities;o.peek(/^-[@\(]/)&&(a=o.$char("-"));var c=this.sub()||b.dimension()||b.color()||b.variable()||b.call()||b.colorKeyword();return a&&(c.parensInOp=!0,c=new e.Negative(c)),c},expression:function(){var a,b,c=[];do a=this.comment(),a?c.push(a):(a=this.addition()||this.entity(),a&&(c.push(a),o.peek(/^\/[\/*]/)||(b=o.$char("/"),b&&c.push(new e.Anonymous(b)))));while(a);if(c.length>0)return new e.Expression(c)},property:function(){var a=o.$re(/^(\*?-?[_a-zA-Z0-9-]+)\s*:/);if(a)return a[1]},ruleProperty:function(){function a(a){var b=o.i,c=o.$re(a);if(c)return g.push(b),f.push(c[1])}var b,d,f=[],g=[];o.save();var h=o.$re(/^([_a-zA-Z0-9-]+)\s*:/);if(h)return f=[new e.Keyword(h[1])],o.forget(),f;for(a(/^(\*?)/);;)if(!a(/^((?:[\w-]+)|(?:@\{[\w-]+\}))/))break;if(f.length>1&&a(/^((?:\+_|\+)?)\s*:/)){for(o.forget(),""===f[0]&&(f.shift(),g.shift()),d=0;d=b);c++);this.preProcessors.splice(c,0,{preProcessor:a,priority:b})},d.prototype.addPostProcessor=function(a,b){var c;for(c=0;c=b);c++);this.postProcessors.splice(c,0,{postProcessor:a,priority:b})},d.prototype.addFileManager=function(a){this.fileManagers.push(a)},d.prototype.getPreProcessors=function(){for(var a=[],b=0;b0){var d,e=JSON.stringify(this._sourceMapGenerator.toJSON());this.sourceMapURL?d=this.sourceMapURL:this._sourceMapFilename&&(d=this._sourceMapFilename),this.sourceMapURL=d,this.sourceMap=e}return this._css.join("")},b}},{}],44:[function(a,b,c){var d=a("./contexts"),e=a("./visitors"),f=a("./tree");b.exports=function(a,b){b=b||{};var c,g=b.variables,h=new d.Eval(b);"object"!=typeof g||Array.isArray(g)||(g=Object.keys(g).map(function(a){var b=g[a];return b instanceof f.Value||(b instanceof f.Expression||(b=new f.Expression([b])),b=new f.Value([b])),new f.Rule("@"+a,b,(!1),null,0)}),h.frames=[new f.Ruleset(null,g)]);var i,j=[],k=[new e.JoinSelectorVisitor,new e.MarkVisibleSelectorsVisitor((!0)),new e.ExtendVisitor,new e.ToCSSVisitor({compress:Boolean(b.compress)})];if(b.pluginManager){var l=b.pluginManager.getVisitors();for(i=0;i.5?j/(2-g-h):j/(g+h),g){case c:a=(d-e)/j+(d="===a||"=<"===a||"<="===a;case 1:return">"===a||">="===a;default:return!1}}}(this.op,this.lvalue.eval(a),this.rvalue.eval(a));return this.negate?!b:b},b.exports=e},{"./node":70}],54:[function(a,b,c){var d=function(a,b,c){var e="";if(a.dumpLineNumbers&&!a.compress)switch(a.dumpLineNumbers){case"comments":e=d.asComment(b);break;case"mediaquery":e=d.asMediaQuery(b);break;case"all":e=d.asComment(b)+(c||"")+d.asMediaQuery(b)}return e};d.asComment=function(a){return"/* line "+a.debugInfo.lineNumber+", "+a.debugInfo.fileName+" */\n"},d.asMediaQuery=function(a){var b=a.debugInfo.fileName;return/^[a-z]+:\/\//i.test(b)||(b="file://"+b),"@media -sass-debug-info{filename{font-family:"+b.replace(/([.:\/\\])/g,function(a){return"\\"==a&&(a="/"),"\\"+a})+"}line{font-family:\\00003"+a.debugInfo.lineNumber+"}}\n"},b.exports=d},{}],55:[function(a,b,c){var d=a("./node"),e=a("../contexts"),f=function(a,b){this.ruleset=a,this.frames=b};f.prototype=new d,f.prototype.type="DetachedRuleset",f.prototype.evalFirst=!0,f.prototype.accept=function(a){this.ruleset=a.visit(this.ruleset)},f.prototype.eval=function(a){var b=this.frames||a.frames.slice(0);return new f(this.ruleset,b)},f.prototype.callEval=function(a){return this.ruleset.eval(this.frames?new e.Eval(a,this.frames.concat(a.frames)):a)},b.exports=f},{"../contexts":11,"./node":70}],56:[function(a,b,c){var d=a("./node"),e=a("../data/unit-conversions"),f=a("./unit"),g=a("./color"),h=function(a,b){this.value=parseFloat(a),this.unit=b&&b instanceof f?b:new f(b?[b]:void 0)};h.prototype=new d,h.prototype.type="Dimension",h.prototype.accept=function(a){this.unit=a.visit(this.unit)},h.prototype.eval=function(a){return this},h.prototype.toColor=function(){return new g([this.value,this.value,this.value])},h.prototype.genCSS=function(a,b){if(a&&a.strictUnits&&!this.unit.isSingular())throw new Error("Multiple units in dimension. Correct the units or use the unit function. Bad unit: "+this.unit.toString());var c=this.fround(a,this.value),d=String(c);if(0!==c&&c<1e-6&&c>-1e-6&&(d=c.toFixed(20).replace(/0+$/,"")),a&&a.compress){if(0===c&&this.unit.isLength())return void b.add(d);c>0&&c<1&&(d=d.substr(1))}b.add(d),this.unit.genCSS(a,b)},h.prototype.operate=function(a,b,c){var d=this._operate(a,b,this.value,c.value),e=this.unit.clone();if("+"===b||"-"===b)if(0===e.numerator.length&&0===e.denominator.length)e=c.unit.clone(),this.unit.backupUnit&&(e.backupUnit=this.unit.backupUnit);else if(0===c.unit.numerator.length&&0===e.denominator.length);else{if(c=c.convertTo(this.unit.usedUnits()),a.strictUnits&&c.unit.toString()!==e.toString())throw new Error("Incompatible units. Change the units or use the unit function. Bad units: '"+e.toString()+"' and '"+c.unit.toString()+"'.");d=this._operate(a,b,this.value,c.value)}else"*"===b?(e.numerator=e.numerator.concat(c.unit.numerator).sort(),e.denominator=e.denominator.concat(c.unit.denominator).sort(),e.cancel()):"/"===b&&(e.numerator=e.numerator.concat(c.unit.denominator).sort(),e.denominator=e.denominator.concat(c.unit.numerator).sort(),e.cancel());return new h(d,e)},h.prototype.compare=function(a){var b,c;if(a instanceof h){if(this.unit.isEmpty()||a.unit.isEmpty())b=this,c=a;else if(b=this.unify(),c=a.unify(),0!==b.unit.compare(c.unit))return;return d.numericCompare(b.value,c.value)}},h.prototype.unify=function(){return this.convertTo({length:"px",duration:"s",angle:"rad"})},h.prototype.convertTo=function(a){var b,c,d,f,g,i=this.value,j=this.unit.clone(),k={};if("string"==typeof a){for(b in e)e[b].hasOwnProperty(a)&&(k={},k[b]=a);a=k}g=function(a,b){return d.hasOwnProperty(a)?(b?i/=d[a]/d[f]:i*=d[a]/d[f],f):a};for(c in a)a.hasOwnProperty(c)&&(f=a[c],d=e[c],j.map(g));return j.cancel(),new h(i,j)},b.exports=h},{"../data/unit-conversions":14,"./color":50,"./node":70,"./unit":79}],57:[function(a,b,c){var d=a("./node"),e=a("./selector"),f=a("./ruleset"),g=function(a,b,c,d,f,g,h,i){var j;if(this.name=a,this.value=b,c)for(Array.isArray(c)?this.rules=c:(this.rules=[c],this.rules[0].selectors=new e([],null,null,this.index,f).createEmptySelectors()),j=0;j1?b=new g(this.value.map(function(b){return b.eval(a)})):1===this.value.length?(this.value[0].parens&&!this.value[0].parensInOp&&(d=!0),b=this.value[0].eval(a)):b=this,c&&a.outOfParenthesis(),this.parens&&this.parensInOp&&!a.isMathOn()&&!d&&(b=new e(b)),b},g.prototype.genCSS=function(a,b){for(var c=0;c0&&c.length&&""===c[0].combinator.value&&(c[0].combinator.value=" "),d=d.concat(a[b].elements);this.selfSelectors=[new e(d)],this.selfSelectors[0].copyVisibilityInfo(this.visibilityInfo())},b.exports=f},{"./node":70,"./selector":77}],61:[function(a,b,c){var d=a("./node"),e=a("./media"),f=a("./url"),g=a("./quoted"),h=a("./ruleset"),i=a("./anonymous"),j=function(a,b,c,d,e,f){if(this.options=c,this.index=d,this.path=a,this.features=b,this.currentFileInfo=e,this.allowRoot=!0,void 0!==this.options.less||this.options.inline)this.css=!this.options.less||this.options.inline;else{var g=this.getPath();g&&/[#\.\&\?\/]css([\?;].*)?$/.test(g)&&(this.css=!0)}this.copyVisibilityInfo(f)};j.prototype=new d,j.prototype.type="Import",j.prototype.accept=function(a){this.features&&(this.features=a.visit(this.features)),this.path=a.visit(this.path),this.options.plugin||this.options.inline||!this.root||(this.root=a.visit(this.root))},j.prototype.genCSS=function(a,b){this.css&&void 0===this.path.currentFileInfo.reference&&(b.add("@import ",this.currentFileInfo,this.index),this.path.genCSS(a,b),this.features&&(b.add(" "),this.features.genCSS(a,b)),b.add(";"))},j.prototype.getPath=function(){return this.path instanceof f?this.path.value.value:this.path.value},j.prototype.isVariableImport=function(){var a=this.path;return a instanceof f&&(a=a.value),!(a instanceof g)||a.containsVariables()},j.prototype.evalForImport=function(a){var b=this.path;return b instanceof f&&(b=b.value),new j(b.eval(a),this.features,this.options,this.index,this.currentFileInfo,this.visibilityInfo())},j.prototype.evalPath=function(a){var b=this.path.eval(a),c=this.currentFileInfo&&this.currentFileInfo.rootpath;if(!(b instanceof f)){if(c){var d=b.value;d&&a.isPathRelative(d)&&(b.value=c+d)}b.value=a.normalizePath(b.value)}return b},j.prototype.eval=function(a){var b=this.doEval(a);return(this.options.reference||this.blocksVisibility())&&(b.length||0===b.length?b.forEach(function(a){a.addVisibilityBlock()}):b.addVisibilityBlock()),b},j.prototype.doEval=function(a){var b,c,d=this.features&&this.features.eval(a);if(this.options.plugin)return c=a.frames[0]&&a.frames[0].functionRegistry,c&&this.root&&this.root.functions&&c.addMultiple(this.root.functions),[];if(this.skip&&("function"==typeof this.skip&&(this.skip=this.skip()),this.skip))return[];if(this.options.inline){var f=new i(this.root,0,{filename:this.importedFilename,reference:this.path.currentFileInfo&&this.path.currentFileInfo.reference},(!0),(!0));return this.features?new e([f],this.features.value):[f]}if(this.css){var g=new j(this.evalPath(a),d,this.options,this.index);if(!g.css&&this.error)throw this.error;return g}return b=new h(null,this.root.rules.slice(0)),b.evalImports(a),this.features?new e(b.rules,this.features.value):b.rules},b.exports=j},{"./anonymous":46,"./media":66,"./node":70,"./quoted":73,"./ruleset":76,"./url":80}],62:[function(a,b,c){var d={};d.Node=a("./node"),d.Alpha=a("./alpha"),d.Color=a("./color"),d.Directive=a("./directive"),d.DetachedRuleset=a("./detached-ruleset"),d.Operation=a("./operation"),d.Dimension=a("./dimension"),d.Unit=a("./unit"),d.Keyword=a("./keyword"),d.Variable=a("./variable"),d.Ruleset=a("./ruleset"),d.Element=a("./element"),d.Attribute=a("./attribute"),d.Combinator=a("./combinator"),d.Selector=a("./selector"),d.Quoted=a("./quoted"),d.Expression=a("./expression"),d.Rule=a("./rule"),d.Call=a("./call"),d.URL=a("./url"),d.Import=a("./import"),d.mixin={Call:a("./mixin-call"),Definition:a("./mixin-definition")},d.Comment=a("./comment"),d.Anonymous=a("./anonymous"),d.Value=a("./value"),d.JavaScript=a("./javascript"),d.Assignment=a("./assignment"),d.Condition=a("./condition"),d.Paren=a("./paren"),d.Media=a("./media"),d.UnicodeDescriptor=a("./unicode-descriptor"),d.Negative=a("./negative"),d.Extend=a("./extend"),d.RulesetCall=a("./ruleset-call"),b.exports=d},{"./alpha":45,"./anonymous":46,"./assignment":47,"./attribute":48,"./call":49,"./color":50,"./combinator":51,"./comment":52,"./condition":53,"./detached-ruleset":55,"./dimension":56,"./directive":57,"./element":58,"./expression":59,"./extend":60,"./import":61,"./javascript":63,"./keyword":65,"./media":66,"./mixin-call":67,"./mixin-definition":68,"./negative":69,"./node":70,"./operation":71,"./paren":72,"./quoted":73,"./rule":74,"./ruleset":76,"./ruleset-call":75,"./selector":77,"./unicode-descriptor":78,"./unit":79,"./url":80,"./value":81,"./variable":82}],63:[function(a,b,c){var d=a("./js-eval-node"),e=a("./dimension"),f=a("./quoted"),g=a("./anonymous"),h=function(a,b,c,d){this.escaped=b,this.expression=a,this.index=c,this.currentFileInfo=d};h.prototype=new d,h.prototype.type="JavaScript",h.prototype.eval=function(a){var b=this.evaluateJavaScript(this.expression,a);return"number"==typeof b?new e(b):"string"==typeof b?new f('"'+b+'"',b,this.escaped,this.index):new g(Array.isArray(b)?b.join(", "):b)},b.exports=h},{"./anonymous":46,"./dimension":56,"./js-eval-node":64,"./quoted":73}],64:[function(a,b,c){var d=a("./node"),e=a("./variable"),f=function(){};f.prototype=new d,f.prototype.evaluateJavaScript=function(a,b){var c,d=this,f={};if(void 0!==b.javascriptEnabled&&!b.javascriptEnabled)throw{message:"You are using JavaScript, which has been disabled.",filename:this.currentFileInfo.filename,index:this.index};a=a.replace(/@\{([\w-]+)\}/g,function(a,c){return d.jsify(new e("@"+c,d.index,d.currentFileInfo).eval(b))});try{a=new Function("return ("+a+")")}catch(g){throw{message:"JavaScript evaluation error: "+g.message+" from `"+a+"`",filename:this.currentFileInfo.filename,index:this.index}}var h=b.frames[0].variables();for(var i in h)h.hasOwnProperty(i)&&(f[i.slice(1)]={value:h[i].value,toJS:function(){return this.value.eval(b).toCSS()}});try{c=a.call(f)}catch(g){throw{message:"JavaScript evaluation error: '"+g.name+": "+g.message.replace(/["]/g,"'")+"'",filename:this.currentFileInfo.filename,index:this.index}}return c},f.prototype.jsify=function(a){return Array.isArray(a.value)&&a.value.length>1?"["+a.value.map(function(a){return a.toCSS()}).join(", ")+"]":a.toCSS()},b.exports=f},{"./node":70,"./variable":82}],65:[function(a,b,c){var d=a("./node"),e=function(a){this.value=a};e.prototype=new d,e.prototype.type="Keyword",e.prototype.genCSS=function(a,b){if("%"===this.value)throw{type:"Syntax",message:"Invalid % without number"};b.add(this.value)},e.True=new e("true"),e.False=new e("false"),b.exports=e},{"./node":70}],66:[function(a,b,c){var d=a("./ruleset"),e=a("./value"),f=a("./selector"),g=a("./anonymous"),h=a("./expression"),i=a("./directive"),j=function(a,b,c,g,h){this.index=c,this.currentFileInfo=g;var i=new f([],null,null,this.index,this.currentFileInfo).createEmptySelectors();this.features=new e(b),this.rules=[new d(i,a)],this.rules[0].allowImports=!0,this.copyVisibilityInfo(h),this.allowRoot=!0};j.prototype=new i,j.prototype.type="Media",j.prototype.isRulesetLike=!0,j.prototype.accept=function(a){this.features&&(this.features=a.visit(this.features)),this.rules&&(this.rules=a.visitArray(this.rules))},j.prototype.genCSS=function(a,b){b.add("@media ",this.currentFileInfo,this.index),this.features.genCSS(a,b),this.outputRuleset(a,b,this.rules)},j.prototype.eval=function(a){a.mediaBlocks||(a.mediaBlocks=[],a.mediaPath=[]);var b=new j(null,[],this.index,this.currentFileInfo,this.visibilityInfo());this.debugInfo&&(this.rules[0].debugInfo=this.debugInfo,b.debugInfo=this.debugInfo);var c=!1;a.strictMath||(c=!0,a.strictMath=!0);try{b.features=this.features.eval(a)}finally{c&&(a.strictMath=!1)}return a.mediaPath.push(b),a.mediaBlocks.push(b),this.rules[0].functionRegistry=a.frames[0].functionRegistry.inherit(),a.frames.unshift(this.rules[0]),b.rules=[this.rules[0].eval(a)],a.frames.shift(),a.mediaPath.pop(),0===a.mediaPath.length?b.evalTop(a):b.evalNested(a)},j.prototype.evalTop=function(a){var b=this;if(a.mediaBlocks.length>1){var c=new f([],null,null,this.index,this.currentFileInfo).createEmptySelectors();b=new d(c,a.mediaBlocks),b.multiMedia=!0,b.copyVisibilityInfo(this.visibilityInfo())}return delete a.mediaBlocks,delete a.mediaPath,b},j.prototype.evalNested=function(a){var b,c,f=a.mediaPath.concat([this]);for(b=0;b0;b--)a.splice(b,0,new g("and"));return new h(a)})),new d([],[])},j.prototype.permute=function(a){if(0===a.length)return[];if(1===a.length)return a[0];for(var b=[],c=this.permute(a.slice(1)),d=0;d0){for(n=!0,k=0;k0)p=B;else if(p=A,q[A]+q[B]>1)throw{type:"Runtime",message:"Ambiguous use of `default()` found when matching for `"+this.format(t)+"`",index:this.index,filename:this.currentFileInfo.filename};for(k=0;kthis.params.length)return!1}c=Math.min(f,this.arity);for(var g=0;gb?1:void 0},d.prototype.blocksVisibility=function(){return null==this.visibilityBlocks&&(this.visibilityBlocks=0),0!==this.visibilityBlocks},d.prototype.addVisibilityBlock=function(){null==this.visibilityBlocks&&(this.visibilityBlocks=0),this.visibilityBlocks=this.visibilityBlocks+1},d.prototype.removeVisibilityBlock=function(){null==this.visibilityBlocks&&(this.visibilityBlocks=0),this.visibilityBlocks=this.visibilityBlocks-1},d.prototype.ensureVisibility=function(){this.nodeVisible=!0},d.prototype.ensureInvisibility=function(){this.nodeVisible=!1},d.prototype.isVisible=function(){return this.nodeVisible},d.prototype.visibilityInfo=function(){return{visibilityBlocks:this.visibilityBlocks,nodeVisible:this.nodeVisible}},d.prototype.copyVisibilityInfo=function(a){a&&(this.visibilityBlocks=a.visibilityBlocks,this.nodeVisible=a.nodeVisible)},b.exports=d},{}],71:[function(a,b,c){var d=a("./node"),e=a("./color"),f=a("./dimension"),g=function(a,b,c){this.op=a.trim(),this.operands=b,this.isSpaced=c};g.prototype=new d,g.prototype.type="Operation",g.prototype.accept=function(a){this.operands=a.visit(this.operands)},g.prototype.eval=function(a){var b=this.operands[0].eval(a),c=this.operands[1].eval(a);if(a.isMathOn()){if(b instanceof f&&c instanceof e&&(b=b.toColor()),c instanceof f&&b instanceof e&&(c=c.toColor()),!b.operate)throw{type:"Operation",message:"Operation on an invalid type"};return b.operate(a,this.op,c)}return new g(this.op,[b,c],this.isSpaced)},g.prototype.genCSS=function(a,b){this.operands[0].genCSS(a,b),this.isSpaced&&b.add(" "),b.add(this.op),this.isSpaced&&b.add(" "),this.operands[1].genCSS(a,b)},b.exports=g},{"./color":50,"./dimension":56,"./node":70}],72:[function(a,b,c){var d=a("./node"),e=function(a){this.value=a};e.prototype=new d,e.prototype.type="Paren",e.prototype.genCSS=function(a,b){b.add("("),this.value.genCSS(a,b),b.add(")")},e.prototype.eval=function(a){return new e(this.value.eval(a))},b.exports=e},{"./node":70}],73:[function(a,b,c){var d=a("./node"),e=a("./js-eval-node"),f=a("./variable"),g=function(a,b,c,d,e){this.escaped=null==c||c,this.value=b||"",this.quote=a.charAt(0),this.index=d,this.currentFileInfo=e};g.prototype=new e,g.prototype.type="Quoted",g.prototype.genCSS=function(a,b){this.escaped||b.add(this.quote,this.currentFileInfo,this.index),b.add(this.value),this.escaped||b.add(this.quote)},g.prototype.containsVariables=function(){return this.value.match(/(`([^`]+)`)|@\{([\w-]+)\}/)},g.prototype.eval=function(a){function b(a,b,c){var d=a;do a=d,d=a.replace(b,c);while(a!==d);return d}var c=this,d=this.value,e=function(b,d){return String(c.evaluateJavaScript(d,a))},h=function(b,d){var e=new f("@"+d,c.index,c.currentFileInfo).eval(a,!0);return e instanceof g?e.value:e.toCSS()};return d=b(d,/`([^`]+)`/g,e),d=b(d,/@\{([\w-]+)\}/g,h),new g(this.quote+d+this.quote,d,this.escaped,this.index,this.currentFileInfo)},g.prototype.compare=function(a){return"Quoted"!==a.type||this.escaped||a.escaped?a.toCSS&&this.toCSS()===a.toCSS()?0:void 0:d.numericCompare(this.value,a.value)},b.exports=g},{"./js-eval-node":64,"./node":70,"./variable":82}],74:[function(a,b,c){function d(a,b){var c,d="",e=b.length,f={add:function(a){d+=a}};for(c=0;cd){if(!c||c(h)){e=h.find(new f(a.elements.slice(d)),b,c);for(var j=0;j0&&b.add(k),a.firstSelector=!0,h[0].genCSS(a,b),a.firstSelector=!1,e=1;e0?(e=a.slice(0),f=e.pop(),h=d.createDerived(f.elements.slice(0))):h=d.createDerived([]),b.length>0){var i=c.combinator,j=b[0].elements[0];i.emptyOrWhitespace&&!j.combinator.emptyOrWhitespace&&(i=j.combinator),h.elements.push(new g(i,j.value,c.index,c.currentFileInfo)),h.elements=h.elements.concat(b[0].elements.slice(1))}if(0!==h.elements.length&&e.push(h),b.length>1){var k=b.slice(1);k=k.map(function(a){return a.createDerived(a.elements,[])}),e=e.concat(k)}return e}function j(a,b,c,d,e){var f;for(f=0;f0?d[d.length-1]=d[d.length-1].createDerived(d[d.length-1].elements.concat(a)):d.push(new f(a))}}function l(a,b,c){function f(a){var b;return"Paren"!==a.value.type?null:(b=a.value.value,"Selector"!==b.type?null:b)}var h,m,n,o,p,q,r,s,t,u,v=!1;for(o=[],p=[[]],h=0;h0&&r[0].elements.push(new g(s.combinator,"",s.index,s.currentFileInfo)),q.push(r);else for(n=0;n0&&(a.push(p[h]),u=p[h][t-1],p[h][t-1]=u.createDerived(u.elements,c.extendList));return v}function m(a,b){var c=b.createDerived(b.elements,b.extendList,b.evaldCondition);return c.copyVisibilityInfo(a),c}var n,o,p;if(o=[],p=l(o,b,c),!p)if(b.length>0)for(o=[],n=0;n0)for(b=0;b=0&&"\n"!==b.charAt(c);)e++;return"number"==typeof a&&(d=(b.slice(0,a).match(/\n/g)||"").length),{line:d,column:e}}}},{}],84:[function(a,b,c){var d=a("../tree"),e=a("./visitor"),f=a("../logger"),g=function(){this._visitor=new e(this),this.contexts=[],this.allExtendsStack=[[]]};g.prototype={run:function(a){return a=this._visitor.visit(a),a.allExtends=this.allExtendsStack[0],a},visitRule:function(a,b){b.visitDeeper=!1},visitMixinDefinition:function(a,b){b.visitDeeper=!1},visitRuleset:function(a,b){if(!a.root){var c,e,f,g,h=[],i=a.rules,j=i?i.length:0;for(c=0;c=0||(i=[k.selfSelectors[0]],g=n.findMatch(j,i),g.length&&(j.hasFoundMatches=!0,j.selfSelectors.forEach(function(a){var b=k.visibilityInfo();h=n.extendSelector(g,i,a,j.isVisible()),l=new d.Extend(k.selector,k.option,0,k.currentFileInfo,b),l.selfSelectors=h,h[h.length-1].extendList=[l],m.push(l),l.ruleset=k.ruleset,l.parent_ids=l.parent_ids.concat(k.parent_ids,j.parent_ids),k.firstExtendOnThisSelectorPath&&(l.firstExtendOnThisSelectorPath=!0,k.ruleset.paths.push(h))})));if(m.length){if(this.extendChainCount++,c>100){var o="{unable to calculate}",p="{unable to calculate}";try{o=m[0].selfSelectors[0].toCSS(),p=m[0].selector.toCSS()}catch(q){}throw{message:"extend circular reference detected. One of the circular extends is currently:"+o+":extend("+p+")"}}return m.concat(n.doExtendChaining(m,b,c+1))}return m},visitRule:function(a,b){b.visitDeeper=!1},visitMixinDefinition:function(a,b){b.visitDeeper=!1},visitSelector:function(a,b){b.visitDeeper=!1},visitRuleset:function(a,b){if(!a.root){var c,d,e,f,g=this.allExtendsStack[this.allExtendsStack.length-1],h=[],i=this;for(e=0;e0&&k[i.matched].combinator.value!==g?i=null:i.matched++,i&&(i.finished=i.matched===k.length,i.finished&&!a.allowAfter&&(e+1k&&l>0&&(m[m.length-1].elements=m[m.length-1].elements.concat(b[k].elements.slice(l)),l=0,k++),j=g.elements.slice(l,i.index).concat([h]).concat(c.elements.slice(1)),k===i.pathIndex&&f>0?m[m.length-1].elements=m[m.length-1].elements.concat(j):(m=m.concat(b.slice(k,i.pathIndex)),m.push(new d.Selector(j))),k=i.endPathIndex,l=i.endPathElementIndex,l>=b[k].elements.length&&(l=0,k++);return k0&&(m[m.length-1].elements=m[m.length-1].elements.concat(b[k].elements.slice(l)),k++),m=m.concat(b.slice(k,b.length)),m=m.map(function(a){var b=a.createDerived(a.elements);return e?b.ensureVisibility():b.ensureInvisibility(),b})},visitMedia:function(a,b){var c=a.allExtends.concat(this.allExtendsStack[this.allExtendsStack.length-1]);c=c.concat(this.doExtendChaining(c,a.allExtends)),this.allExtendsStack.push(c)},visitMediaOut:function(a){var b=this.allExtendsStack.length-1;this.allExtendsStack.length=b},visitDirective:function(a,b){var c=a.allExtends.concat(this.allExtendsStack[this.allExtendsStack.length-1]);c=c.concat(this.doExtendChaining(c,a.allExtends)),this.allExtendsStack.push(c)},visitDirectiveOut:function(a){var b=this.allExtendsStack.length-1;this.allExtendsStack.length=b}},b.exports=h},{"../logger":33,"../tree":62,"./visitor":91}],85:[function(a,b,c){function d(a){this.imports=[],this.variableImports=[],this._onSequencerEmpty=a,this._currentDepth=0}d.prototype.addImport=function(a){var b=this,c={callback:a,args:null,isReady:!1};return this.imports.push(c),function(){c.args=Array.prototype.slice.call(arguments,0),c.isReady=!0,b.tryRun()}},d.prototype.addVariableImport=function(a){this.variableImports.push(a)},d.prototype.tryRun=function(){this._currentDepth++;try{for(;;){for(;this.imports.length>0;){var a=this.imports[0];if(!a.isReady)return;
-this.imports=this.imports.slice(1),a.callback.apply(null,a.args)}if(0===this.variableImports.length)break;var b=this.variableImports[0];this.variableImports=this.variableImports.slice(1),b()}}finally{this._currentDepth--}0===this._currentDepth&&this._onSequencerEmpty&&this._onSequencerEmpty()},b.exports=d},{}],86:[function(a,b,c){var d=a("../contexts"),e=a("./visitor"),f=a("./import-sequencer"),g=function(a,b){this._visitor=new e(this),this._importer=a,this._finish=b,this.context=new d.Eval,this.importCount=0,this.onceFileDetectionMap={},this.recursionDetector={},this._sequencer=new f(this._onSequencerEmpty.bind(this))};g.prototype={isReplacing:!1,run:function(a){try{this._visitor.visit(a)}catch(b){this.error=b}this.isFinished=!0,this._sequencer.tryRun()},_onSequencerEmpty:function(){this.isFinished&&this._finish(this.error)},visitImport:function(a,b){var c=a.options.inline;if(!a.css||c){var e=new d.Eval(this.context,this.context.frames.slice(0)),f=e.frames[0];this.importCount++,a.isVariableImport()?this._sequencer.addVariableImport(this.processImportNode.bind(this,a,e,f)):this.processImportNode(a,e,f)}b.visitDeeper=!1},processImportNode:function(a,b,c){var d,e=a.options.inline;try{d=a.evalForImport(b)}catch(f){f.filename||(f.index=a.index,f.filename=a.currentFileInfo.filename),a.css=!0,a.error=f}if(!d||d.css&&!e)this.importCount--,this.isFinished&&this._sequencer.tryRun();else{d.options.multiple&&(b.importMultiple=!0);for(var g=void 0===d.css,h=0;h0},resolveVisibility:function(a,b){if(!a.blocksVisibility()){if(this.isEmpty(a)&&!this.containsSilentNonBlockedChild(b))return;return a}var c=a.rules[0];if(this.keepOnlyVisibleChilds(c),!this.isEmpty(c))return a.ensureVisibility(),a.removeVisibilityBlock(),a},isVisibleRuleset:function(a){return!!a.firstRoot||!this.isEmpty(a)&&!(!a.root&&!this.hasVisibleSelector(a))}};var g=function(a){this._visitor=new e(this),this._context=a,this.utils=new f(a)};g.prototype={isReplacing:!0,run:function(a){return this._visitor.visit(a)},visitRule:function(a,b){if(!a.blocksVisibility()&&!a.variable)return a},visitMixinDefinition:function(a,b){a.frames=[]},visitExtend:function(a,b){},visitComment:function(a,b){if(!a.blocksVisibility()&&!a.isSilent(this._context))return a},visitMedia:function(a,b){var c=a.rules[0].rules;return a.accept(this._visitor),b.visitDeeper=!1,this.utils.resolveVisibility(a,c)},visitImport:function(a,b){if(!a.blocksVisibility())return a},visitDirective:function(a,b){return a.rules&&a.rules.length?this.visitDirectiveWithBody(a,b):this.visitDirectiveWithoutBody(a,b)},visitDirectiveWithBody:function(a,b){function c(a){var b=a.rules;return 1===b.length&&(!b[0].paths||0===b[0].paths.length)}function d(a){var b=a.rules;return c(a)?b[0].rules:b}var e=d(a);return a.accept(this._visitor),b.visitDeeper=!1,this.utils.isEmpty(a)||this._mergeRules(a.rules[0].rules),this.utils.resolveVisibility(a,e)},visitDirectiveWithoutBody:function(a,b){if(!a.blocksVisibility()){if("@charset"===a.name){if(this.charset){if(a.debugInfo){var c=new d.Comment("/* "+a.toCSS(this._context).replace(/\n/g,"")+" */\n");return c.debugInfo=a.debugInfo,this._visitor.visit(c)}return}this.charset=!0}return a}},checkValidNodes:function(a,b){if(a)for(var c=0;c0?a.accept(this._visitor):a.rules=null,b.visitDeeper=!1}return a.rules&&(this._mergeRules(a.rules),this._removeDuplicateRules(a.rules)),this.utils.isVisibleRuleset(a)&&(a.ensureVisibility(),d.splice(0,0,a)),1===d.length?d[0]:d},_compileRulesetPaths:function(a){a.paths&&(a.paths=a.paths.filter(function(a){var b;for(" "===a[0].elements[0].combinator.value&&(a[0].elements[0].combinator=new d.Combinator("")),b=0;b=0;e--)if(c=a[e],c instanceof d.Rule)if(f[c.name]){b=f[c.name],b instanceof d.Rule&&(b=f[c.name]=[f[c.name].toCSS(this._context)]);var g=c.toCSS(this._context);b.indexOf(g)!==-1?a.splice(e,1):b.push(g)}else f[c.name]=c}},_mergeRules:function(a){if(a){for(var b,c,e,f={},g=0;g1){c=b[0];var h=[],i=[];b.map(function(a){"+"===a.merge&&(i.length>0&&h.push(e(i)),i=[]),i.push(a)}),h.push(e(i)),c.value=g(h)}})}},visitAnonymous:function(a,b){if(!a.blocksVisibility())return a.accept(this._visitor),a}},b.exports=g},{"../tree":62,"./visitor":91}],91:[function(a,b,c){function d(a){return a}function e(a,b){var c,d;for(c in a)if(a.hasOwnProperty(c))switch(d=a[c],typeof d){case"function":d.prototype&&d.prototype.type&&(d.prototype.typeIndex=b++);break;case"object":b=e(d,b)}return b}var f=a("../tree"),g={visitDeeper:!0},h=!1,i=function(a){this._implementation=a,this._visitFnCache=[],h||(e(f,1),h=!0)};i.prototype={visit:function(a){if(!a)return a;var b=a.typeIndex;if(!b)return a;var c,e=this._visitFnCache,f=this._implementation,h=b<<1,i=1|h,j=e[h],k=e[i],l=g;if(l.visitDeeper=!0,j||(c="visit"+a.type,j=f[c]||d,k=f[c+"Out"]||d,e[h]=j,e[i]=k),j!==d){var m=j.call(f,a,l);f.isReplacing&&(a=m)}return l.visitDeeper&&a&&a.accept&&a.accept(this),k!=d&&k.call(f,a),a},visitArray:function(a,b){if(!a)return a;var c,d=a.length;if(b||!this._implementation.isReplacing){for(c=0;ck){for(var b=0,c=h.length-j;b {
+ id?: number;
+}
+
+export interface SelectedTrackItem extends Omit {
+ id?: number;
}
/*
-id: 472,
- taskName: 'AppTrackItem',
beginDate: 2017-12-08T07:15:49.792Z,
endDate: 2017-12-08T07:15:52.792Z,
app: 'Code',
diff --git a/client/src/@types/modules/less.d.ts b/client/src/@types/modules/less.d.ts
deleted file mode 100644
index 4204d5bb..00000000
--- a/client/src/@types/modules/less.d.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-// declaration.d.ts
-declare module '*.less' {
- const content: any;
- export default content;
-}
diff --git a/client/src/@types/moment.ts b/client/src/@types/moment.ts
deleted file mode 100644
index bd57e528..00000000
--- a/client/src/@types/moment.ts
+++ /dev/null
@@ -1,100 +0,0 @@
-import moment from 'moment';
-
-moment();
-declare module 'moment' {
- namespace duration {
- const fn: Duration;
- }
-
- interface Duration {
- format: Format;
- }
-
- interface Format {
- defaults: DurationFormatSettings;
-
- (
- template: string | TemplateFunction,
- precision: number,
- settings?: DurationFormatSettings,
- ): string;
- (template: string | TemplateFunction, settings?: DurationFormatSettings): string;
- (settings?: DurationFormatSettings): string;
- }
-
- type UnitOfTrimV1 = 'left' | 'right';
- type UnitOfTrim = 'large' | 'small' | 'both' | 'mid' | 'all' | 'final';
-
- interface DurationFormatSettings {
- trim?: false | UnitOfTrimV1 | UnitOfTrim | string | Array;
- largest?: number;
- trunc?: true;
- stopTrim?: string;
-
- minValue?: number;
- maxValue?: number;
-
- useGrouping?: boolean;
- precision?: number;
- decimalSeparator?: string;
- groupingSeparator?: string;
- grouping?: number[];
-
- useSignificantDigits?: true;
-
- forceLength?: boolean;
- template?: string | TemplateFunction;
-
- userLocale?: string;
- usePlural?: boolean;
- useLeftUnits?: boolean;
- useToLocaleString?: boolean;
- }
-
- type DurationLabelType = 'long' | 'standard' | 'short';
- type DurationTemplate = 'HMS' | 'HM' | 'MS';
- type DurationToken =
- | 'S'
- | 'SS'
- | 'SSS'
- | 's'
- | 'ss'
- | 'sss'
- | 'm'
- | 'mm'
- | 'mmm'
- | 'h'
- | 'hh'
- | 'hhh'
- | 'd'
- | 'dd'
- | 'ddd'
- | 'w'
- | 'ww'
- | 'www'
- | 'M'
- | 'MM'
- | 'MMM'
- | 'y'
- | 'yy'
- | 'yyy';
-
- type DurationLabelDef = { [duration in DurationToken]: string };
- type DurationTimeDef = { [template in DurationTemplate]: string };
-
- interface DurationLabelTypeDef {
- type: DurationLabelType;
- string: string;
- }
-
- interface LocaleSpecification {
- durationLabelsLong: DurationLabelDef;
- durationLabelsStandard: DurationLabelDef;
- durationLabelsShort: DurationLabelDef;
- durationTimeTemplates: DurationTimeDef;
- durationLabelTypes: DurationLabelTypeDef[];
- durationPluralKey: (token: string, integerValue: number, decimalValue: number) => string;
- }
-
- type TemplateFunction = (this: DurationFormatSettings) => string;
-}
diff --git a/client/src/MainRouter.tsx b/client/src/MainRouter.tsx
new file mode 100644
index 00000000..70c4c0e0
--- /dev/null
+++ b/client/src/MainRouter.tsx
@@ -0,0 +1,77 @@
+import { useColorMode } from '@chakra-ui/react';
+import { StoreProvider } from 'easy-peasy';
+import { Settings } from 'luxon';
+import { useCallback, useEffect } from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+import { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary';
+import { TrayLayout } from './components/TrayLayout/TrayLayout';
+import { Logger } from './logger';
+import { RootProvider } from './RootContext';
+import { ChartThemeProvider } from './routes/ChartThemeProvider';
+import { MainAppPage } from './routes/MainAppPage';
+import { NotificationAppPage } from './routes/NotificationAppPage';
+import { TrayAppPage } from './routes/TrayAppPage';
+import { ElectronEventEmitter } from './services/ElectronEventEmitter';
+import { mainStore } from './store/mainStore';
+import { useGoogleAnalytics } from './useGoogleAnalytics';
+
+Settings.defaultLocale = 'en-GB';
+
+export function MainRouter() {
+ useGoogleAnalytics();
+ const { setColorMode } = useColorMode();
+
+ const changeActiveTheme = useCallback(
+ (themeName) => {
+ setColorMode(themeName);
+ },
+ [setColorMode],
+ );
+
+ useEffect(() => {
+ ElectronEventEmitter.on('activeThemeChanged', changeActiveTheme);
+
+ return () => {
+ Logger.debug('Clearing eventEmitter');
+ ElectronEventEmitter.off('activeThemeChanged', changeActiveTheme);
+ };
+ }, [changeActiveTheme]);
+
+ return (
+
+
+
+ {/* Main App with main store */}
+
+
+
+ }
+ />
+
+ {/* Redirect from root to /app */}
+ } />
+
+ {/* Tray App - No longer needs trayStore */}
+
+
+
+
+
+ }
+ />
+
+ } />
+
+ {/* Fallback redirect to /app */}
+ } />
+
+
+
+ );
+}
diff --git a/client/src/RootContext.tsx b/client/src/RootContext.tsx
index cc7a4267..5c40a8f2 100644
--- a/client/src/RootContext.tsx
+++ b/client/src/RootContext.tsx
@@ -1,46 +1,107 @@
-import React, { useEffect, useState, createContext, useCallback } from 'react';
-import useReactRouter from 'use-react-router';
-import { EventEmitter } from './services/EventEmitter';
+import { ReactNode, createContext, useCallback, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { DataSettingsI } from './components/Settings/DataForm.util';
+import { WorkSettingsI } from './components/Settings/WorkForm.util';
import { Logger } from './logger';
+import { ElectronEventEmitter } from './services/ElectronEventEmitter';
+import { fetchDataSettings, fetchWorkSettings, saveDataSettings, saveWorkSettings } from './services/settings.api';
-const defaultWorkSettings = {
+const defaultWorkSettings: WorkSettingsI = {
workDayStartTime: '08:30', // not used
workDayEndTime: '17:00', // not used
splitTaskAfterIdlingForMinutes: 3, // not used in client, put used in backend
- hoursToWork: 8,
+ hoursToWork: 8, // not used
+ sessionLength: 50,
+ minBreakTime: 5,
+ notificationDuration: 10,
+ reNotifyInterval: 5,
+ smallNotificationsEnabled: true,
+};
+const defaultDataSettings: DataSettingsI = {
+ idleAfterSeconds: 60,
+ backgroundJobInterval: 3,
+ recentDaysCount: 7,
};
-export const RootContext = createContext({});
+interface RootContextType {
+ workSettings: WorkSettingsI;
+ updateWorkSettings: (settings: WorkSettingsI) => void;
+ dataSettings: DataSettingsI;
+ updateDataSettings: (settings: DataSettingsI) => void;
+}
-export const RootProvider = ({ children }) => {
- const { history } = useReactRouter();
+export const RootContext = createContext({
+ workSettings: defaultWorkSettings,
+ updateWorkSettings: () => {},
+ dataSettings: defaultDataSettings,
+ updateDataSettings: () => {},
+});
- const prevWorkSettings = JSON.parse((window as any).localStorage.getItem('workSettings')) || {
- defaultWorkSettings,
- };
+interface RootProviderProps {
+ children: ReactNode;
+}
+
+export const RootProvider = ({ children }: RootProviderProps) => {
+ const navigate = useNavigate();
const gotoSettingsPage = useCallback(() => {
Logger.debug('Navigating to settings page');
- history.push('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/app/settings');
- }, [history]);
+ navigate('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/app/settings');
+ }, [navigate]);
+
+ const [workSettings, setWorkSettings] = useState(defaultWorkSettings);
+ const [dataSettings, setDataSettings] = useState(defaultDataSettings);
+
+ const updateWorkSettings = useCallback((newWorkSettings: WorkSettingsI) => {
+ setWorkSettings(newWorkSettings);
+ saveWorkSettings(newWorkSettings);
+ }, []);
+
+ const updateDataSettings = useCallback((newDataSettings: DataSettingsI) => {
+ setDataSettings(newDataSettings);
+ saveDataSettings(newDataSettings);
+ }, []);
+
+ const loadSettings = useCallback(async () => {
+ const newWorkSettings = await fetchWorkSettings();
- const [workSettings, setWorkSettings] = useState(prevWorkSettings);
+ if (newWorkSettings) {
+ setWorkSettings(newWorkSettings);
+ }
+ const newDataSettings = await fetchDataSettings();
+
+ if (newDataSettings) {
+ setDataSettings(newDataSettings);
+ }
+ }, []);
+
+ useEffect(() => {
+ loadSettings();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
useEffect(() => {
- window.localStorage.setItem('workSettings', JSON.stringify(workSettings));
- }, [workSettings]);
+ ElectronEventEmitter.on('WORK_SETTINGS_UPDATED', loadSettings);
+
+ return () => {
+ ElectronEventEmitter.off('WORK_SETTINGS_UPDATED', loadSettings);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
useEffect(() => {
- EventEmitter.on('side:preferences', gotoSettingsPage);
+ ElectronEventEmitter.on('side:preferences', gotoSettingsPage);
return () => {
Logger.debug('Clearing eventEmitter');
- EventEmitter.off('side:preferences', gotoSettingsPage);
+ ElectronEventEmitter.off('side:preferences', gotoSettingsPage);
};
}, [gotoSettingsPage]);
- const defaultContext = {
+ const defaultContext: RootContextType = {
workSettings,
- setWorkSettings,
+ updateWorkSettings,
+ dataSettings,
+ updateDataSettings,
};
return {children} ;
diff --git a/client/src/SummaryContext.tsx b/client/src/SummaryContext.tsx
index 7f9b88c8..17a50a81 100644
--- a/client/src/SummaryContext.tsx
+++ b/client/src/SummaryContext.tsx
@@ -1,44 +1,64 @@
-import moment from 'moment';
-import React, { createContext, useState, useCallback, useEffect } from 'react';
-import { findAllDayItemsForEveryTrack } from './services/trackItem.api';
+import { DateTime } from 'luxon';
+import { createContext, useCallback, useEffect, useState } from 'react';
import {
+ ISummary,
+ ISummaryOnlineTime,
summariseLog,
summariseOnline,
summariseTimeOnline,
} from './components/SummaryCalendar/SummaryCalendar.util';
import { Logger } from './logger';
-import { MODE_MONTH } from './SummaryContext.util';
+import { findAllDayItemsForEveryTrack } from './services/trackItem.api';
+import { CALENDAR_MODE } from './SummaryContext.util';
-export const SummaryContext = createContext({});
+interface SummaryContextType {
+ selectedDate: DateTime;
+ setSelectedDate: (date: DateTime) => void;
+ selectedMode: CALENDAR_MODE;
+ setSelectedMode: (mode: CALENDAR_MODE) => void;
+ logSummary: ISummary;
+ onlineSummary: ISummary;
+ onlineTimesSummary: ISummaryOnlineTime;
+ isLoading: boolean;
+}
-export const SummaryProvider = ({ children }) => {
- const [isLoading, setIsLoading] = useState(false);
- const [selectedDate, setSelectedDate] = useState(moment());
- const [selectedMode, setSelectedMode] = useState(MODE_MONTH);
+export const SummaryContext = createContext({
+ selectedDate: DateTime.now(),
+ setSelectedDate: () => {},
+ selectedMode: CALENDAR_MODE.MONTH,
+ setSelectedMode: () => {},
+ logSummary: {},
+ onlineSummary: {},
+ onlineTimesSummary: {},
+ isLoading: false,
+});
- const [logSummary, setLogSummary] = useState([]);
- const [onlineSummary, setOnlineSummary] = useState([]);
- const [onlineTimesSummary, setOnlineTimesSummary] = useState([]);
+export const SummaryProvider = ({ children }: { children: React.ReactNode }) => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [selectedDate, setSelectedDate] = useState(DateTime.now());
+ const [selectedMode, setSelectedMode] = useState(CALENDAR_MODE.MONTH);
+
+ const [logSummary, setLogSummary] = useState({});
+ const [onlineSummary, setOnlineSummary] = useState({});
+ const [onlineTimesSummary, setOnlineTimesSummary] = useState({});
const loadData = useCallback(async () => {
setIsLoading(true);
try {
// TODO, query month +1 day for sleep time
- const beginDate = moment(selectedDate).startOf(selectedMode);
- const endDate = moment(selectedDate).endOf(selectedMode);
+ const beginDate = selectedDate.startOf(selectedMode.toLowerCase() as 'month' | 'week' | 'day');
+ let endDate = selectedDate.endOf(selectedMode.toLowerCase() as 'month' | 'week' | 'day');
- if (selectedMode === MODE_MONTH) {
- endDate.add(1, 'day');
+ if (selectedMode === CALENDAR_MODE.MONTH) {
+ endDate = endDate.plus({ days: 1 });
}
- const { statusItems, logItems } = await findAllDayItemsForEveryTrack(
- beginDate,
- endDate,
- );
+
+ const { statusItems, logItems } = await findAllDayItemsForEveryTrack(beginDate, endDate);
setLogSummary(summariseLog(logItems, selectedMode));
setOnlineSummary(summariseOnline(statusItems, selectedMode));
setOnlineTimesSummary(summariseTimeOnline(statusItems, selectedMode, beginDate));
} catch (e) {
- Logger.error('Errod loading summary data.', e);
+ Logger.error('Error loading summary data.', e);
} finally {
setIsLoading(false);
}
diff --git a/client/src/SummaryContext.util.ts b/client/src/SummaryContext.util.ts
index 389df336..527f22ed 100644
--- a/client/src/SummaryContext.util.ts
+++ b/client/src/SummaryContext.util.ts
@@ -1,3 +1,7 @@
-export const MODE_MONTH = 'month';
-export const DAY_MONTH_FORMAT = 'MM-DD';
+export enum CALENDAR_MODE {
+ MONTH = 'month',
+ YEAR = 'year',
+}
+
+export const DAY_MONTH_FORMAT = 'MM-dd';
export const MONTH_FORMAT = 'MM';
diff --git a/client/src/__mocks__/logger.ts b/client/src/__mocks__/logger.ts
new file mode 100644
index 00000000..94e0cb2e
--- /dev/null
+++ b/client/src/__mocks__/logger.ts
@@ -0,0 +1,8 @@
+import { vi } from 'vitest';
+
+export const Logger = {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+};
diff --git a/client/src/__tests__/time.testUtils.ts b/client/src/__tests__/time.testUtils.ts
new file mode 100644
index 00000000..40621f83
--- /dev/null
+++ b/client/src/__tests__/time.testUtils.ts
@@ -0,0 +1,5 @@
+export function getTimestamp(dateString: string): number {
+ return new Date(dateString).getTime();
+}
+
+export const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
diff --git a/client/src/analytics.ts b/client/src/analytics.ts
deleted file mode 100644
index e6542580..00000000
--- a/client/src/analytics.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import Analytics from 'analytics';
-import googleAnalytics from '@analytics/google-analytics';
-
-// https://www.npmjs.com/package/@analytics/google-analytics
-const analytics = Analytics({
- app: 'tockler',
- plugins: [
- googleAnalytics({
- trackingId: 'UA-196817622-1',
- anonymizeIp: true,
- customDimensions: {
- version: 'dimension1',
- },
- tasks: {
- // Set checkProtocolTask, checkStorageTask, & historyImportTask for electron apps
- checkProtocolTask: null,
- checkStorageTask: null,
- historyImportTask: null,
- },
- }),
- ],
-});
-
-export { analytics };
diff --git a/client/src/components/BlackBox.tsx b/client/src/components/BlackBox.tsx
new file mode 100644
index 00000000..5ddc0722
--- /dev/null
+++ b/client/src/components/BlackBox.tsx
@@ -0,0 +1,7 @@
+import { Box, useColorModeValue, BoxProps } from '@chakra-ui/react';
+
+export const BlackBox = ({ children, ...rest }: BoxProps) => (
+
+ {children}
+
+);
diff --git a/client/src/components/Card.tsx b/client/src/components/Card.tsx
new file mode 100644
index 00000000..a5e073e7
--- /dev/null
+++ b/client/src/components/Card.tsx
@@ -0,0 +1,15 @@
+import { Box, Flex, Text } from '@chakra-ui/react';
+
+export const Card = ({ title, children, extra, ...rest }) => {
+ return (
+
+
+
+ {title}
+
+ {extra}
+
+ {children}
+
+ );
+};
diff --git a/client/src/components/CardBox.tsx b/client/src/components/CardBox.tsx
new file mode 100644
index 00000000..d48bc915
--- /dev/null
+++ b/client/src/components/CardBox.tsx
@@ -0,0 +1,44 @@
+import { Box, BoxProps, Flex, Text, useColorModeValue } from '@chakra-ui/react';
+import { ReactNode } from 'react';
+
+interface CardBoxProps extends BoxProps {
+ title?: string;
+ extra?: ReactNode;
+ p?: number;
+ divider?: boolean;
+}
+
+export const CardBox = ({ title, children, extra, p = 4, divider, ...rest }: CardBoxProps) => {
+ const titleColor = useColorModeValue('gray.900', 'white');
+ const dividerColor = useColorModeValue('gray.200', 'gray.500');
+
+ return (
+
+ {title && (
+
+
+ {title}
+
+ {extra}
+
+ )}
+ {divider && }
+ {children}
+
+ );
+};
diff --git a/client/src/components/ColorModeSwitcher.tsx b/client/src/components/ColorModeSwitcher.tsx
new file mode 100644
index 00000000..1faf0529
--- /dev/null
+++ b/client/src/components/ColorModeSwitcher.tsx
@@ -0,0 +1,39 @@
+import { useColorMode, useColorModeValue, IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
+import { FaMoon, FaSun } from 'react-icons/fa';
+import { saveThemeToStorage } from '../services/settings.api';
+import { THEMES } from '../store/theme.util';
+
+type ColorModeSwitcherProps = Omit;
+
+export const ColorModeSwitcher = (props: ColorModeSwitcherProps) => {
+ const { colorMode, setColorMode } = useColorMode();
+ const text = useColorModeValue('dark', 'light');
+
+ const SwitchIcon = useColorModeValue(FaMoon, FaSun);
+
+ const setColorModeAndSave = () => {
+ const theme = colorMode === THEMES.DARK ? THEMES.LIGHT : THEMES.DARK;
+ setColorMode(theme);
+ saveThemeToStorage(theme);
+ };
+
+ // TODO: Add Auto mode to toggle also
+
+ const label = `Switch to ${text} mode`;
+
+ return (
+
+ }
+ aria-label={label}
+ {...props}
+ />
+
+ );
+};
diff --git a/client/src/components/Common.styles.ts b/client/src/components/Common.styles.ts
deleted file mode 100644
index 6056f6a1..00000000
--- a/client/src/components/Common.styles.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import styled from 'styled-components';
-
-export const Smtng = styled.div``;
diff --git a/client/src/components/Datepicker/Calendar.tsx b/client/src/components/Datepicker/Calendar.tsx
new file mode 100644
index 00000000..bd9d8a63
--- /dev/null
+++ b/client/src/components/Datepicker/Calendar.tsx
@@ -0,0 +1,137 @@
+import { Box, Button, Flex, HStack, Spacer, StylesProvider, useMultiStyleConfig } from '@chakra-ui/react';
+import { useDatepicker } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+
+import { useEffect } from 'react';
+import { AiOutlineLeft, AiOutlineRight } from 'react-icons/ai';
+import { CALENDAR_MODE } from '../../SummaryContext.util';
+import { CalendarMonth } from './CalendarMonth';
+import { CalendarYear } from './CalendarYear';
+import { DatepickerProvider } from './context/DatepickerContext';
+import { MonthSelect } from './MonthSelect';
+import { monthLabelFormatFn, weekdayLabelFormatLong } from './utils/formatters';
+import { YearSelect } from './YearSelect';
+
+export interface ICalendarProps {
+ dateCellRender: (date: DateTime) => React.ReactNode;
+ onDateClicked: (date: DateTime) => void;
+ setSelectedDate: (date: DateTime) => void;
+ selectedDate: DateTime;
+ selectedMode: CALENDAR_MODE;
+ setSelectedMode: (mode: CALENDAR_MODE) => void;
+ focusedDate: Date;
+}
+
+export const Calendar = ({
+ selectedDate,
+ dateCellRender,
+ onDateClicked,
+ setSelectedDate,
+ selectedMode,
+ setSelectedMode,
+ focusedDate,
+}: ICalendarProps) => {
+ const inputDate = selectedDate.toJSDate();
+
+ const dp = useDatepicker({
+ onDatesChange: () => null,
+ startDate: inputDate,
+ endDate: inputDate,
+ focusedInput: null,
+ numberOfMonths: 1,
+ });
+
+ useEffect(() => {
+ dp.onDateFocus(focusedDate);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [focusedDate]);
+
+ const styles = useMultiStyleConfig('Calendar', {});
+
+ const year = selectedDate.year;
+ const month = selectedDate.month - 1; // Luxon months are 1-12, but we need 0-11 for the selects
+
+ function goToPrevious() {
+ const newDate = selectedDate.minus({ [selectedMode]: 1 });
+ setSelectedDate(newDate);
+ dp.onDateSelect(newDate.toJSDate());
+ }
+
+ function goToNext() {
+ const newDate = selectedDate.plus({ [selectedMode]: 1 });
+ setSelectedDate(newDate);
+ dp.onDateSelect(newDate.toJSDate());
+ }
+
+ const onChangeYear = (event) => {
+ const value = event.target.value;
+ const newDate = selectedDate.set({ year: value });
+ setSelectedDate(newDate);
+ dp.onDateSelect(newDate.toJSDate());
+ };
+
+ const onChangeMonth = (event) => {
+ const value = Number(event.target.value) + 1; // Convert back to 1-12 for Luxon
+ const newDate = selectedDate.set({ month: value });
+ setSelectedDate(newDate);
+ dp.onDateSelect(newDate.toJSDate());
+ };
+
+ const isMonthView = selectedMode === CALENDAR_MODE.MONTH;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {isMonthView && }
+
+
+
+
+
+
+
+
+
+ setSelectedMode(CALENDAR_MODE.MONTH)}
+ >
+ Month view
+
+ setSelectedMode(CALENDAR_MODE.YEAR)}
+ >
+ Year view
+
+
+
+
+ {isMonthView && (
+
+ )}
+ {!isMonthView && (
+
+ )}
+
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/CalendarMonth.tsx b/client/src/components/Datepicker/CalendarMonth.tsx
new file mode 100644
index 00000000..53a4bb04
--- /dev/null
+++ b/client/src/components/Datepicker/CalendarMonth.tsx
@@ -0,0 +1,57 @@
+import { Box, Flex, SimpleGrid, useStyles } from '@chakra-ui/react';
+import { CalendarDay as DatepickerCalendarDay, useMonth } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+
+import { DayBox } from './components/DayBox';
+import { useDatepickerContext } from './context/DatepickerContext';
+
+export interface MonthProps {
+ year: number;
+ month: number;
+ dateCellRender: (date: DateTime) => React.ReactNode;
+ onDateClicked: (date: DateTime) => void;
+}
+
+export const CalendarMonth = ({ year, month, dateCellRender, onDateClicked }: MonthProps) => {
+ const styles = useStyles();
+ const { dayLabelFormat, monthLabelFormat, weekdayLabelFormat, firstDayOfWeek } = useDatepickerContext();
+
+ const { days, weekdayLabels } = useMonth({
+ year,
+ month,
+ dayLabelFormat,
+ monthLabelFormat,
+ weekdayLabelFormat,
+ firstDayOfWeek,
+ });
+
+ return (
+
+
+ {weekdayLabels.map((weekdayLabel: string) => (
+
+ {weekdayLabel}
+
+ ))}
+
+
+
+ {days.map((day: DatepickerCalendarDay, index: number) =>
+ typeof day === 'object' ? (
+
+ {dateCellRender(DateTime.fromJSDate(day.date))}
+
+ ) : (
+
+ ),
+ )}
+
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/CalendarStyle.tsx b/client/src/components/Datepicker/CalendarStyle.tsx
new file mode 100644
index 00000000..6716029e
--- /dev/null
+++ b/client/src/components/Datepicker/CalendarStyle.tsx
@@ -0,0 +1,98 @@
+import { mode } from '@chakra-ui/theme-tools';
+import { ComponentStyleConfig } from '@chakra-ui/react';
+
+const dayStyle = {
+ height: '156px',
+ width: '100%',
+ minWidth: 'unset',
+ border: '2px solid',
+ borderColor: 'transparent',
+};
+
+export const CalendarStyle = {
+ parts: ['calendar', 'weekdayLabel', 'grid'],
+ baseStyle: props => ({
+ calendar: {
+ w: '100%',
+ },
+ separator: {
+ bg: mode('gray.200', 'gray.500')(props),
+ h: '1px',
+ },
+ weekdayLabel: {
+ bg: mode('gray.500', 'gray.500')(props),
+ color: 'white',
+ p: 3,
+ pl: 4,
+ fontWeight: 'bold',
+ fontSize: 'md',
+ },
+
+ grid: {
+ bg: mode('gray.200', 'gray.500')(props),
+ },
+ day: {
+ bg: mode('white', 'gray.700')(props),
+ ...dayStyle,
+ },
+ emptyDay: {
+ bg: mode('gray.100', 'gray.800')(props),
+ ...dayStyle,
+ },
+ }),
+
+ defaultProps: {
+ size: 'md',
+ },
+};
+
+export const CalendarDayStyle: ComponentStyleConfig = {
+ baseStyle: props => ({
+ height: '156px',
+ width: '100%',
+ minWidth: 'unset',
+ fontWeight: 'medium',
+ fontSize: 'md',
+ border: '2px solid',
+
+ borderColor: 'transparent',
+ background: mode('white', 'gray.700')(props),
+ overflow: 'hidden',
+
+ _hover: {
+ borderColor: 'transparent',
+ background: 'transparent',
+ cursor: 'pointer',
+ },
+ }),
+ variants: {
+ normal: props => ({
+ _hover: {
+ borderColor: mode('gray.700', 'white')(props),
+ },
+ }),
+ rangeHover: props => ({
+ _hover: {
+ borderColor: mode('gray.700', 'white')(props),
+ },
+ }),
+ selected: props => ({
+ borderColor: mode('brand.mainColor', 'brand.mainColor')(props),
+
+ _hover: {
+ borderColor: mode('gray.700', 'white')(props),
+ },
+ }),
+ firstOrLast: props => ({
+ textColor: mode('white', 'black')(props),
+ background: mode('black', 'white')(props),
+ _hover: {
+ textColor: mode('white', 'black')(props),
+ background: mode('black', 'white')(props),
+ },
+ }),
+ },
+ defaultProps: {
+ variant: 'normal',
+ },
+};
diff --git a/client/src/components/Datepicker/CalendarYear.tsx b/client/src/components/Datepicker/CalendarYear.tsx
new file mode 100644
index 00000000..13839d1d
--- /dev/null
+++ b/client/src/components/Datepicker/CalendarYear.tsx
@@ -0,0 +1,39 @@
+import { Box, SimpleGrid, useStyles } from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import { MonthBox } from './components/MonthBox';
+import { useDatepickerContext } from './context/DatepickerContext';
+
+export interface YearProps {
+ year: number;
+ dateCellRender: (date: DateTime) => React.ReactNode;
+ onDateClicked: (date: DateTime) => void;
+}
+
+export const CalendarYear = ({ dateCellRender, onDateClicked }: YearProps) => {
+ const styles = useStyles();
+ const { monthLabelFormat } = useDatepickerContext();
+
+ const months = Array.from({ length: 12 }, (_, i) => {
+ return DateTime.now().set({ month: i + 1 });
+ });
+
+ return (
+
+
+
+
+ {months.map((month: DateTime) => (
+
+ {dateCellRender(month)}
+
+ ))}
+
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/DateRangeInput.tsx b/client/src/components/Datepicker/DateRangeInput.tsx
new file mode 100644
index 00000000..bf4c4a96
--- /dev/null
+++ b/client/src/components/Datepicker/DateRangeInput.tsx
@@ -0,0 +1,232 @@
+import { CalendarIcon } from '@chakra-ui/icons';
+import { Box, Stack, StackDivider, useBreakpointValue, Icon } from '@chakra-ui/react';
+import { END_DATE, FocusedInput, getInputValue, OnDatesChangeProps, START_DATE } from '@datepicker-react/hooks';
+import { Ref, useEffect, useRef, useState } from 'react';
+import { AiOutlineSwapRight } from 'react-icons/ai';
+import { Input, InputProps } from './components';
+import { StylesProvider, StylesProviderProps, useStyleProps } from './context/StylesContext';
+import { Datepicker, DatepickerElement, DatepickerProps } from './Datepicker';
+import { dateRangeInputPhrases, DateRangeInputPhrases } from './phrases';
+import { DateRangeInputStyles, InputDate } from './types';
+import { defaultDisplayFormat } from './utils/formatters';
+
+export interface DateRangeInputProps extends Partial, Partial {
+ startDateInputProps?: Partial;
+ endDateInputProps?: Partial;
+ phrases?: DateRangeInputPhrases;
+ showDivider?: boolean;
+ placement?: 'top' | 'bottom';
+ onFocusChange?(focusedInput: FocusedInput): void;
+ endIcon?: typeof CalendarIcon;
+ endId?: string;
+ endName?: string;
+ endPlaceholder?: string;
+ endRef?: Ref;
+ endShowCalendarIcon?: boolean;
+ startIcon?: typeof CalendarIcon;
+ startId?: string;
+ startName?: string;
+ startPlaceholder?: string;
+ startRef?: Ref;
+ startShowCalendarIcon?: boolean;
+ allowEditableInputs?: boolean;
+}
+
+export const DateRangeInput = (props: DateRangeInputProps) => {
+ const {
+ endDate: endDateProp = null,
+ startDate: startDateProp = null,
+ focusedInput: focusedInputProp = null,
+ displayFormat = defaultDisplayFormat,
+ endShowCalendarIcon = true,
+ isDateBlocked = () => false,
+ minBookingDays = 1,
+ phrases = dateRangeInputPhrases,
+ placement = 'bottom',
+ showClose = true,
+ showDivider = false,
+ showResetDates = true,
+ showSelectedDates = true,
+ startShowCalendarIcon = true,
+ onClose = () => {},
+ onDatesChange = () => {},
+ onFocusChange = () => {},
+ styles: customStyles,
+ overwriteDefaultStyles,
+ startIcon,
+ startId,
+ startName,
+ startPlaceholder,
+ startRef,
+ endId,
+ endName,
+ endRef,
+ endIcon,
+ endPlaceholder,
+ changeActiveMonthOnSelect,
+ dayLabelFormat,
+ exactMinBookingDays,
+ firstDayOfWeek,
+ initialVisibleMonth,
+ maxBookingDate,
+ minBookingDate,
+ monthLabelFormat,
+ numberOfMonths,
+ onDayRender,
+ unavailableDates,
+ weekdayLabelFormat,
+ allowEditableInputs,
+ } = props;
+
+ const datepickerRef = useRef(null);
+ const datepickerWrapperRef = useRef(null);
+
+ const [startDate, setStartDate] = useState(startDateProp);
+ const [endDate, setEndDate] = useState(endDateProp);
+ const [focusedInput, setFocusedInput] = useState(focusedInputProp);
+
+ useEffect(() => {
+ setStartDate(startDateProp);
+ setEndDate(endDateProp);
+ }, [startDateProp, endDateProp]);
+
+ const styleProps = useStyleProps({
+ dateRangeInputContainer: { spacing: 1 },
+ dateRangeInputDivider: {},
+ });
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ window.addEventListener('click', onClickOutsideHandler);
+ }
+ return () => {
+ window.removeEventListener('click', onClickOutsideHandler);
+ };
+ });
+
+ function handleOnFocusChange(_focusedInput: FocusedInput) {
+ setFocusedInput(_focusedInput);
+ onFocusChange(_focusedInput);
+ }
+
+ function handleOnDatesChange(data: OnDatesChangeProps) {
+ setStartDate(data.startDate);
+ setEndDate(data.endDate);
+ setFocusedInput(data.focusedInput);
+
+ if (!data.focusedInput) {
+ onDatesChange(data);
+ }
+ }
+
+ function onClickOutsideHandler(event: Event) {
+ if (
+ focusedInput !== null &&
+ datepickerWrapperRef &&
+ datepickerWrapperRef.current &&
+ // @ts-ignore
+ !datepickerWrapperRef.current.contains(event.target)
+ ) {
+ handleOnFocusChange(null);
+ }
+ }
+
+ function handleOnClose() {
+ handleOnFocusChange(null);
+ onClose();
+ }
+
+ function handleInputChange(date: Date) {
+ if (datepickerRef && datepickerRef.current && datepickerRef.current.onDateSelect) {
+ datepickerRef.current.onDateSelect(date);
+ }
+ }
+
+ const isMobile = useBreakpointValue({ base: true, md: false });
+ const vertical = props.vertical || isMobile;
+
+ return (
+
+
+ : undefined}
+ alignItems="center"
+ >
+ handleOnFocusChange(START_DATE)}
+ value={getInputValue(startDate, displayFormat, '')}
+ allowEditableInputs={allowEditableInputs}
+ />
+
+
+ handleOnFocusChange(!startDate ? START_DATE : END_DATE)}
+ value={getInputValue(endDate, displayFormat, '')}
+ allowEditableInputs={allowEditableInputs}
+ />
+
+
+ {focusedInput !== null && (
+
+ )}
+
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/DateSingleInput.tsx b/client/src/components/Datepicker/DateSingleInput.tsx
new file mode 100644
index 00000000..243e3bcd
--- /dev/null
+++ b/client/src/components/Datepicker/DateSingleInput.tsx
@@ -0,0 +1,182 @@
+import { Box } from '@chakra-ui/react';
+import { getInputValue, OnDatesChangeProps, START_DATE } from '@datepicker-react/hooks';
+import { forwardRef, Ref, useEffect, useRef, useState } from 'react';
+import { Input, InputProps } from './components';
+import { StylesProvider } from './context/StylesContext';
+import { Datepicker, DatepickerElement, DatepickerProps } from './Datepicker';
+import { DateSingleInputPhrases, dateSingleInputPhrases } from './phrases';
+import { InputDate } from './types';
+import { defaultDisplayFormat } from './utils/formatters';
+
+export interface OnDateChangeProps {
+ date: InputDate;
+ showDatepicker: boolean;
+}
+
+export interface DateSingleInputProps extends Partial, Partial {
+ onFocusChange?(focusInput: boolean): void;
+ phrases?: DateSingleInputPhrases;
+ placement?: 'top' | 'bottom';
+ showDatepicker?: boolean;
+ date?: InputDate;
+ showResetDate?: boolean;
+}
+
+export const DateSingleInput = forwardRef(
+ (
+ {
+ date: dateProp = null,
+ showDatepicker: showDatepickerProp = false,
+ changeActiveMonthOnSelect,
+ dayLabelFormat,
+ displayFormat = defaultDisplayFormat,
+ firstDayOfWeek,
+ iconComponent,
+ id = 'startDate',
+ initialVisibleMonth,
+ isDateBlocked = () => false,
+ maxBookingDate,
+ minBookingDate,
+ monthLabelFormat,
+ name = 'startDate',
+ numberOfMonths = 1,
+ onChange = () => {},
+ onClick = () => {},
+ onClose = () => {},
+ onDayRender,
+ onFocusChange = () => {},
+ overwriteDefaultStyles,
+ phrases = dateSingleInputPhrases,
+ placeholder,
+ placement = 'bottom',
+ showCalendarIcon = true,
+ showClose = true,
+ showResetDate = true,
+ styles,
+ unavailableDates = [],
+ value,
+ vertical = false,
+ weekdayLabelFormat,
+ allowEditableInputs = false,
+ }: DateSingleInputProps,
+ ref: Ref,
+ ) => {
+ const datepickerRef = useRef(null);
+ const datepickerWrapperRef = useRef(null);
+
+ const [date, setDate] = useState(value ? new Date(value) : dateProp);
+ const [showDatepicker, setShowDatepicker] = useState(showDatepickerProp);
+
+ useEffect(() => {
+ onChange(date);
+ }, [date, onChange]);
+
+ useEffect(() => {
+ onFocusChange(showDatepicker);
+ }, [onFocusChange, showDatepicker]);
+
+ useEffect(() => {
+ if (typeof window !== 'undefined') {
+ window.addEventListener('click', onClickOutsideHandler);
+ }
+ return () => {
+ window.removeEventListener('click', onClickOutsideHandler);
+ };
+ });
+
+ function handleOnFocusChange(show: boolean) {
+ setShowDatepicker(show);
+ }
+
+ function onClickOutsideHandler(event: Event) {
+ if (
+ showDatepicker &&
+ datepickerWrapperRef &&
+ datepickerWrapperRef.current &&
+ // @ts-ignore
+ !datepickerWrapperRef.current.contains(event.target)
+ ) {
+ handleOnFocusChange(false);
+ }
+ }
+
+ function handleDatepickerClose() {
+ handleOnFocusChange(false);
+ onClose();
+ }
+
+ function handleOnDatesChange(data: OnDatesChangeProps) {
+ handleOnFocusChange(data.focusedInput !== null);
+ setDate(data.startDate);
+ }
+
+ function handleInputChange(date: Date) {
+ if (datepickerRef && datepickerRef.current && datepickerRef.current.onDateSelect) {
+ datepickerRef.current.onDateSelect(date);
+ }
+ }
+
+ return (
+
+
+ {
+ handleOnFocusChange(true);
+ onClick();
+ }}
+ disableAccessibility={false}
+ iconComponent={iconComponent}
+ allowEditableInputs={allowEditableInputs}
+ />
+
+
+ {showDatepicker && (
+
+ )}
+
+
+
+ );
+ },
+);
diff --git a/client/src/components/Datepicker/Datepicker.tsx b/client/src/components/Datepicker/Datepicker.tsx
new file mode 100644
index 00000000..a7c13355
--- /dev/null
+++ b/client/src/components/Datepicker/Datepicker.tsx
@@ -0,0 +1,226 @@
+import { ArrowForwardIcon } from '@chakra-ui/icons';
+import { Box, Flex, HStack, Stack, useBreakpointValue, useColorModeValue } from '@chakra-ui/react';
+import { END_DATE, MonthType, START_DATE, useDatepicker, UseDatepickerProps } from '@datepicker-react/hooks';
+import { forwardRef, ReactNode, Ref, useImperativeHandle, useRef } from 'react';
+import { ActionButton, CloseButton, Month, ResetDatesButton, SelectedDate } from './components';
+import { DatepickerFormatProps, DatepickerProvider } from './context/DatepickerContext';
+import { StylesProvider, StylesProviderProps, useStyleProps } from './context/StylesContext';
+import { DatepickerPhrases, datepickerPhrases } from './phrases';
+import { DatepickerComponentStyles } from './types';
+import { dayLabelFormatFn, defaultDisplayFormat, monthLabelFormatFn, weekdayLabelFormatFn } from './utils/formatters';
+
+export interface DatepickerElement {
+ onDateSelect?(date: Date): void;
+}
+
+export interface DatepickerProps
+ extends Partial,
+ Partial,
+ Partial {
+ displayFormat?: string;
+ onClose?(): void;
+ onDayRender?(date: Date): ReactNode;
+ phrases?: DatepickerPhrases;
+ showClose?: boolean;
+ showResetDates?: boolean;
+ showSelectedDates?: boolean;
+ vertical?: boolean;
+}
+
+export const Datepicker = forwardRef((props: DatepickerProps, ref: Ref) => {
+ const {
+ changeActiveMonthOnSelect,
+ dayLabelFormat,
+ displayFormat = defaultDisplayFormat,
+ endDate = null,
+ exactMinBookingDays = false,
+ firstDayOfWeek,
+ focusedInput = null,
+ initialVisibleMonth,
+ isDateBlocked = () => false,
+ maxBookingDate,
+ minBookingDate,
+ minBookingDays = 1,
+ monthLabelFormat,
+ numberOfMonths = 2,
+ onClose = () => {},
+ onDatesChange = () => {},
+ onDayRender,
+ overwriteDefaultStyles,
+ phrases = datepickerPhrases,
+ showClose = true,
+ showResetDates = true,
+ showSelectedDates = true,
+ startDate = null,
+ styles: customStyles,
+ unavailableDates = [],
+ weekdayLabelFormat,
+ } = props;
+
+ const dp = useDatepicker({
+ changeActiveMonthOnSelect,
+ endDate,
+ exactMinBookingDays,
+ firstDayOfWeek,
+ focusedInput,
+ initialVisibleMonth,
+ isDateBlocked,
+ maxBookingDate,
+ minBookingDate,
+ minBookingDays,
+ numberOfMonths,
+ onDatesChange,
+ startDate,
+ unavailableDates,
+ });
+
+ useImperativeHandle(ref, () => ({
+ onDateSelect: (date: Date) => {
+ dp.onDateSelect(date);
+ },
+ }));
+
+ const monthGridRef = useRef(null);
+
+ function scrollTopToMonthGrid() {
+ if (monthGridRef && monthGridRef.current && vertical) {
+ monthGridRef.current.scrollTop = 0;
+ }
+ }
+
+ function _goToNextMonths() {
+ dp.goToNextMonths();
+ scrollTopToMonthGrid();
+ }
+
+ function _goToPreviousMonths() {
+ dp.goToPreviousMonths();
+ scrollTopToMonthGrid();
+ }
+
+ const isMobile = useBreakpointValue({ base: true, md: false });
+ const vertical = props.vertical || isMobile;
+
+ const styleProps = useStyleProps({
+ datepickerContainer: {
+ background: useColorModeValue('gray.100', 'gray.800'),
+ borderRadius: 'md',
+ position: 'relative',
+ width: 'fit-content',
+ shadow: 'md',
+ px: [3, 5],
+ py: [5, 7],
+ zIndex: 1,
+ },
+ monthsWrapper: {
+ spacing: [0, 8],
+ },
+ buttonsWrapper: {
+ spacing: [1, 3],
+ },
+ arrowIcon: {
+ my: [5, 15],
+ color: 'gray.500',
+ },
+ datepickerHeader: {
+ justifyContent: 'space-between',
+ pt: [1, 3],
+ },
+ });
+
+ return (
+
+
+
+ {showClose && }
+
+ {showSelectedDates && (
+
+
+
+
+
+
+
+
+
+ )}
+
+ {
+ if (dp.hoveredDate) {
+ dp.onDateHover(null);
+ }
+ }}
+ >
+ {dp.activeMonths.map((month: MonthType) => (
+
+ ))}
+
+
+
+
+
+
+
+ {showResetDates && (
+
+ )}
+
+
+
+
+
+ );
+});
diff --git a/client/src/components/Datepicker/MonthSelect.tsx b/client/src/components/Datepicker/MonthSelect.tsx
new file mode 100644
index 00000000..c61932ca
--- /dev/null
+++ b/client/src/components/Datepicker/MonthSelect.tsx
@@ -0,0 +1,27 @@
+import { Select } from '@chakra-ui/react';
+
+export const MonthSelect = ({ value, onChange }) => {
+ const months = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+ ];
+ return (
+
+ {months.map((month, idx) => (
+
+ {month}
+
+ ))}
+
+ );
+};
diff --git a/client/src/components/Datepicker/YearSelect.tsx b/client/src/components/Datepicker/YearSelect.tsx
new file mode 100644
index 00000000..9c4be7cf
--- /dev/null
+++ b/client/src/components/Datepicker/YearSelect.tsx
@@ -0,0 +1,17 @@
+import { Select } from '@chakra-ui/react';
+import { range } from 'lodash';
+
+export const YearSelect = ({ value, onChange }) => {
+ var currentYear = new Date().getFullYear();
+ const years = range(2016, currentYear + 1);
+
+ return (
+
+ {years.map((year) => (
+
+ {year}
+
+ ))}
+
+ );
+};
diff --git a/client/src/components/Datepicker/components/ActionButton.tsx b/client/src/components/Datepicker/components/ActionButton.tsx
new file mode 100644
index 00000000..1adfb6a5
--- /dev/null
+++ b/client/src/components/Datepicker/components/ActionButton.tsx
@@ -0,0 +1,41 @@
+import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, ChevronUpIcon } from '@chakra-ui/icons';
+import { IconButton, IconButtonProps, Tooltip } from '@chakra-ui/react';
+
+import { useStyleProps } from '../context/StylesContext';
+import { ActionButtonStyles } from '../types';
+
+export interface ActionButtonProps extends Omit {
+ direction?: 'up' | 'right' | 'down' | 'left';
+ tooltipLabel;
+}
+
+export const ActionButton = ({ direction, tooltipLabel, ...props }: ActionButtonProps) => {
+ let IconComponent = ChevronLeftIcon;
+
+ const styleProps = useStyleProps({
+ actionButton: {
+ position: 'relative',
+ },
+ });
+
+ if (direction === 'up') {
+ IconComponent = ChevronUpIcon;
+ } else if (direction === 'right') {
+ IconComponent = ChevronRightIcon;
+ } else if (direction === 'down') {
+ IconComponent = ChevronDownIcon;
+ } else if (direction === 'left') {
+ IconComponent = ChevronLeftIcon;
+ }
+
+ return (
+
+ }
+ {...props}
+ {...styleProps.actionButton}
+ />
+
+ );
+};
diff --git a/client/src/components/Datepicker/components/CloseButton.tsx b/client/src/components/Datepicker/components/CloseButton.tsx
new file mode 100644
index 00000000..ce278945
--- /dev/null
+++ b/client/src/components/Datepicker/components/CloseButton.tsx
@@ -0,0 +1,17 @@
+import { CloseButton as ChakraCloseButton, CloseButtonProps as ChakraCloseButtonProps } from '@chakra-ui/react';
+import { useStyleProps } from '../context/StylesContext';
+import { CloseButtonStyles } from '../types';
+
+export interface CloseButtonProps extends ChakraCloseButtonProps {}
+
+export const CloseButton = (props: CloseButtonProps) => {
+ const styleProps = useStyleProps({
+ closeButton: {
+ top: 1,
+ right: 1,
+ zIndex: 1,
+ position: 'absolute',
+ },
+ });
+ return ;
+};
diff --git a/client/src/components/Datepicker/components/Day.tsx b/client/src/components/Datepicker/components/Day.tsx
new file mode 100644
index 00000000..fa29bf7d
--- /dev/null
+++ b/client/src/components/Datepicker/components/Day.tsx
@@ -0,0 +1,230 @@
+import { Box, Button, useColorModeValue } from '@chakra-ui/react';
+import { isEndDate, isStartDate, useDay } from '@datepicker-react/hooks';
+import { useMemo, useRef } from 'react';
+import { OnDayRenderType, useDatepickerContext } from '../context/DatepickerContext';
+import { useStyleProps } from '../context/StylesContext';
+import { DayState, DayStyles } from '../types';
+
+function getColor(
+ { isSelected, isWithinHoverRange, isFirst, isLast }: OnDayRenderType,
+ { base, normal, rangeHover, selected, firstOrLast, first, last }: DayState,
+) {
+ let style = base;
+ if (!isSelected && !isWithinHoverRange) style = { ...style, ...normal };
+ if (isSelected) style = { ...style, ...selected };
+ if (isWithinHoverRange) style = { ...style, ...rangeHover };
+ if (isFirst || isLast) style = { ...style, ...firstOrLast };
+ if (isFirst) style = { ...style, ...first };
+ if (isLast) style = { ...style, ...last };
+ return style;
+}
+
+interface DayProps {
+ day: string;
+ date: Date;
+}
+
+export function Day({ day, date }: DayProps) {
+ const dayRef = useRef(null);
+
+ const {
+ focusedDate,
+ isDateFocused,
+ isDateSelected,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateSelect,
+ onDateFocus,
+ onDateHover,
+ onDayRender,
+ startDate,
+ endDate,
+ } = useDatepickerContext();
+
+ const dayProps = useDay({
+ date,
+ focusedDate,
+ isDateFocused,
+ isDateSelected,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateFocus,
+ onDateSelect,
+ onDateHover,
+ dayRef,
+ });
+
+ const {
+ onClick,
+ onKeyDown,
+ onMouseEnter,
+ tabIndex,
+ isSelectedStartOrEnd,
+ isSelected,
+ isWithinHoverRange,
+ disabledDate,
+ } = dayProps;
+
+ const styleProps = useStyleProps({
+ day: {
+ base: {
+ height: ['32px', '48px'],
+ width: ['32px', '48px'],
+ minWidth: 'unset',
+ fontWeight: 'medium',
+ fontSize: ['sm', 'md'],
+ border: '2px solid',
+ textColor: useColorModeValue('gray.900', 'white'),
+ borderRadius: '100%',
+ borderColor: 'transparent',
+ background: 'transparent',
+ overflow: 'hidden',
+ _hover: {
+ borderColor: 'transparent',
+ background: 'transparent',
+ },
+ },
+ normal: {
+ _hover: {
+ borderColor: useColorModeValue('black', 'white'),
+ },
+ },
+ rangeHover: {
+ _hover: {
+ borderColor: useColorModeValue('black', 'white'),
+ },
+ },
+ selected: {
+ _hover: {
+ borderColor: useColorModeValue('black', 'white'),
+ },
+ },
+ firstOrLast: {
+ textColor: useColorModeValue('white', 'black'),
+ background: useColorModeValue('black', 'white'),
+ _hover: {
+ textColor: useColorModeValue('white', 'black'),
+ background: useColorModeValue('black', 'white'),
+ },
+ },
+ first: {},
+ last: {},
+ },
+ dayContainer: {
+ base: {
+ height: ['32px', '48px'],
+ width: ['32px', '48px'],
+ _hover: {
+ borderRightRadius: '100%',
+ },
+ },
+ normal: {},
+ rangeHover: {
+ background: useColorModeValue('gray.100', 'gray.500'),
+ _hover: {
+ borderRightRadius: '100%',
+ },
+ },
+ selected: {
+ background: useColorModeValue('gray.100', 'gray.500'),
+ _hover: {
+ borderRightRadius: '0%',
+ },
+ },
+ firstOrLast: {
+ background: useColorModeValue('gray.100', 'gray.500'),
+ },
+ first: {
+ borderLeftRadius: '100%',
+ },
+ last: {
+ borderRightRadius: '100%',
+ _hover: {
+ borderRightRadius: '100%',
+ },
+ },
+ },
+ });
+
+ const isFirst = isStartDate(date, startDate);
+ const isLast = isEndDate(date, endDate);
+
+ const containerStyle = useMemo(
+ () =>
+ getColor(
+ {
+ isFirst,
+ isLast,
+ isSelected,
+ isWithinHoverRange,
+ isSelectedStartOrEnd,
+ disabledDate,
+ },
+ {
+ base: styleProps.dayContainer.base,
+ normal: styleProps.dayContainer.normal,
+ rangeHover: styleProps.dayContainer.rangeHover,
+ selected: styleProps.dayContainer.selected,
+ first: styleProps.dayContainer.first,
+ last: styleProps.dayContainer.last,
+ firstOrLast: styleProps.dayContainer.firstOrLast,
+ },
+ ),
+ [isFirst, isLast, isSelected, isWithinHoverRange, isSelectedStartOrEnd, disabledDate, styleProps],
+ );
+
+ const buttonStyle = useMemo(
+ () =>
+ getColor(
+ {
+ isFirst,
+ isLast,
+ isSelected,
+ isWithinHoverRange,
+ isSelectedStartOrEnd,
+ disabledDate,
+ },
+ {
+ base: styleProps.day.base,
+ normal: styleProps.day.normal,
+ rangeHover: styleProps.day.rangeHover,
+ selected: styleProps.day.selected,
+ first: styleProps.day.first,
+ last: styleProps.day.last,
+ firstOrLast: styleProps.day.firstOrLast,
+ },
+ ),
+ [isFirst, isLast, isSelected, isWithinHoverRange, isSelectedStartOrEnd, disabledDate, styleProps],
+ );
+
+ return (
+
+
+ {typeof onDayRender === 'function'
+ ? onDayRender(date, {
+ isFirst,
+ isLast,
+ isSelected,
+ isWithinHoverRange,
+ isSelectedStartOrEnd,
+ disabledDate,
+ })
+ : day}
+
+
+ );
+}
diff --git a/client/src/components/Datepicker/components/DayBox.tsx b/client/src/components/Datepicker/components/DayBox.tsx
new file mode 100644
index 00000000..5eb33c2e
--- /dev/null
+++ b/client/src/components/Datepicker/components/DayBox.tsx
@@ -0,0 +1,81 @@
+import { Box, Text } from '@chakra-ui/react';
+import { isEndDate, isStartDate, useDay } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+import { useDatepickerContext } from '../context/DatepickerContext';
+import { DayWrapper } from './DayWrapper';
+
+interface DayProps {
+ day: string;
+ date: Date;
+ children: React.ReactNode;
+ onDateClicked: (date: DateTime) => void;
+}
+
+function getVariant({ isSelected, isWithinHoverRange, isFirst, isLast }) {
+ if (!isSelected && !isWithinHoverRange) return 'normal';
+ if (isSelected) return 'selected';
+ if (isWithinHoverRange) return 'rangeHover';
+ if (isFirst || isLast) return 'firstOrLast';
+ if (isFirst) return 'first';
+ if (isLast) return 'last';
+
+ return 'normal';
+}
+export function DayBox({ day, date, onDateClicked, children }: DayProps) {
+ const {
+ focusedDate,
+ isDateFocused,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateSelect,
+ onDateFocus,
+ onDateHover,
+ startDate,
+ endDate,
+ } = useDatepickerContext();
+
+ const dayProps = useDay({
+ date,
+ focusedDate,
+ isDateFocused,
+ isDateSelected: isDateFocused,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateFocus,
+ onDateSelect,
+ onDateHover,
+ });
+
+ const { onKeyDown, onMouseEnter, tabIndex, isSelected, isWithinHoverRange, disabledDate } = dayProps;
+
+ const isFirst = isStartDate(date, startDate);
+ const isLast = isEndDate(date, endDate);
+
+ const variant = getVariant({
+ isSelected,
+ isWithinHoverRange,
+ isFirst,
+ isLast,
+ });
+
+ return (
+ onDateClicked(DateTime.fromJSDate(date))}
+ onKeyDown={onKeyDown}
+ onMouseEnter={onMouseEnter}
+ tabIndex={tabIndex}
+ disabled={disabledDate}
+ aria-label={`Day-${date.toDateString()}`}
+ >
+
+
+ {day}
+
+
+ {children}
+
+ );
+}
diff --git a/client/src/components/Datepicker/components/DayWrapper.tsx b/client/src/components/Datepicker/components/DayWrapper.tsx
new file mode 100644
index 00000000..9236f4ad
--- /dev/null
+++ b/client/src/components/Datepicker/components/DayWrapper.tsx
@@ -0,0 +1,6 @@
+import { Box, useStyleConfig } from '@chakra-ui/react';
+
+export const DayWrapper = ({ variant, ...rest }) => {
+ const styles = useStyleConfig('CalendarDay', { variant });
+ return ;
+};
diff --git a/client/src/components/Datepicker/components/Input.tsx b/client/src/components/Datepicker/components/Input.tsx
new file mode 100644
index 00000000..e41106fc
--- /dev/null
+++ b/client/src/components/Datepicker/components/Input.tsx
@@ -0,0 +1,129 @@
+import { CalendarIcon } from '@chakra-ui/icons';
+import {
+ Icon,
+ Input as ChakraInput,
+ InputGroup,
+ InputRightElement,
+ InputProps as ChakraInputProps,
+} from '@chakra-ui/react';
+import { parseDate } from '@datepicker-react/hooks';
+import { ChangeEvent, FocusEvent, forwardRef, Ref, useEffect, useRef, useState } from 'react';
+import { useStyleProps } from '../context/StylesContext';
+import { InputComponentStyles, InputDate } from '../types';
+import { defaultDisplayFormat } from '../utils/formatters';
+import { getStateStyle } from '../utils/getStateStyle';
+
+export interface InputProps {
+ allowEditableInputs?: boolean;
+ dateFormat?: string;
+ disableAccessibility?: boolean;
+ iconComponent?: typeof CalendarIcon;
+ id?: string;
+ isActive?: boolean;
+ name?: string;
+ onChange?(date: InputDate): void;
+ onClick?(): void;
+ placeholder?: string;
+ showCalendarIcon?: boolean;
+ value?: string;
+}
+
+interface BaseProps extends Omit, InputProps {}
+
+export const Input = forwardRef((props: BaseProps, inputRef: Ref) => {
+ const {
+ dateFormat = defaultDisplayFormat,
+ disableAccessibility,
+ iconComponent = CalendarIcon,
+ id,
+ isActive = false,
+ name,
+ onChange = () => {},
+ onClick = () => {},
+ placeholder,
+ showCalendarIcon = true,
+ value,
+ allowEditableInputs = false,
+
+ ...inputProps
+ } = props;
+
+ const ref = useRef(null);
+
+ const [searchString, setSearchString] = useState(value);
+
+ const styleProps = useStyleProps({
+ inputComponentInputGroup: {
+ base: {},
+ active: {},
+ },
+ inputComponentInput: {
+ base: {},
+ active: {},
+ },
+ inputComponentIcon: {
+ base: {},
+ active: {},
+ },
+ inputComponentInputAddon: {
+ base: {},
+ active: {},
+ },
+ });
+
+ // Note: value was updated outside of InputComponent
+ useEffect(() => {
+ setSearchString(value);
+ }, [value]);
+
+ function handleOnChange(e: ChangeEvent) {
+ const dateValue = e.target.value;
+ setSearchString(dateValue);
+
+ if (typeof ref.current === 'number') {
+ clearTimeout(ref.current);
+ }
+
+ ref.current = setTimeout(() => {
+ onClick();
+ const parsedDate = parseDate(dateValue, dateFormat, new Date());
+ const isValidDate = !isNaN(parsedDate.getDate());
+
+ if (isValidDate) {
+ onChange(parsedDate);
+ } else {
+ onChange(null);
+ }
+ }, 1000);
+ }
+
+ function handleOnFocus(_e: FocusEvent) {
+ onClick();
+ }
+
+ return (
+
+ {showCalendarIcon && (
+ }
+ />
+ )}
+
+
+ );
+});
diff --git a/client/src/components/Datepicker/components/Month.tsx b/client/src/components/Datepicker/components/Month.tsx
new file mode 100644
index 00000000..9fd51c7b
--- /dev/null
+++ b/client/src/components/Datepicker/components/Month.tsx
@@ -0,0 +1,69 @@
+import { Box, Flex, SimpleGrid, Text } from '@chakra-ui/react';
+import { CalendarDay, useMonth } from '@datepicker-react/hooks';
+
+import { useDatepickerContext } from '../context/DatepickerContext';
+import { useStyleProps } from '../context/StylesContext';
+import { MonthStyles } from '../types';
+import { Day } from './Day';
+
+export interface MonthProps {
+ year: number;
+ month: number;
+}
+
+export const Month = ({ year, month }: MonthProps) => {
+ const styleProps = useStyleProps({
+ monthContainer: {},
+ monthMonthLabel: {
+ justifyContent: 'center',
+ fontWeight: 'bold',
+ mb: 6,
+ fontSize: ['md', 'lg'],
+ },
+ monthWeekdayLabel: {
+ justifyContent: 'center',
+ color: 'gray.500',
+ mb: 4,
+ fontSize: ['sm', 'md'],
+ },
+ monthDayGrid: {
+ rowGap: 1,
+ },
+ emptyDay: {},
+ });
+
+ const { dayLabelFormat, monthLabelFormat, weekdayLabelFormat, firstDayOfWeek } = useDatepickerContext();
+
+ const { days, weekdayLabels, monthLabel } = useMonth({
+ year,
+ month,
+ dayLabelFormat,
+ monthLabelFormat,
+ weekdayLabelFormat,
+ firstDayOfWeek,
+ });
+
+ return (
+
+
+ {monthLabel}
+
+
+ {weekdayLabels.map((weekdayLabel: string) => (
+
+ {weekdayLabel}
+
+ ))}
+
+
+ {days.map((day: CalendarDay, index: number) =>
+ typeof day === 'object' ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/components/MonthBox.tsx b/client/src/components/Datepicker/components/MonthBox.tsx
new file mode 100644
index 00000000..9dea1057
--- /dev/null
+++ b/client/src/components/Datepicker/components/MonthBox.tsx
@@ -0,0 +1,81 @@
+import { Box, Text } from '@chakra-ui/react';
+import { isEndDate, isStartDate, useDay } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+import { useDatepickerContext } from '../context/DatepickerContext';
+import { DayWrapper } from './DayWrapper';
+
+interface DayProps {
+ month: string;
+ date: Date;
+ children: React.ReactNode;
+ onDateClicked: (date: DateTime) => void;
+}
+
+function getVariant({ isSelected, isWithinHoverRange, isFirst, isLast }) {
+ if (!isSelected && !isWithinHoverRange) return 'normal';
+ if (isSelected) return 'selected';
+ if (isWithinHoverRange) return 'rangeHover';
+ if (isFirst || isLast) return 'firstOrLast';
+ if (isFirst) return 'first';
+ if (isLast) return 'last';
+
+ return 'normal';
+}
+export function MonthBox({ month, date, onDateClicked, children }: DayProps) {
+ const {
+ focusedDate,
+ isDateFocused,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateSelect,
+ onDateFocus,
+ onDateHover,
+ startDate,
+ endDate,
+ } = useDatepickerContext();
+
+ const dayProps = useDay({
+ date,
+ focusedDate,
+ isDateFocused,
+ isDateSelected: isDateFocused,
+ isDateHovered,
+ isDateBlocked,
+ isFirstOrLastSelectedDate,
+ onDateFocus,
+ onDateSelect,
+ onDateHover,
+ });
+
+ const { onKeyDown, onMouseEnter, tabIndex, isSelected, isWithinHoverRange, disabledDate } = dayProps;
+
+ const isFirst = isStartDate(date, startDate);
+ const isLast = isEndDate(date, endDate);
+
+ const variant = getVariant({
+ isSelected,
+ isWithinHoverRange,
+ isFirst,
+ isLast,
+ });
+
+ return (
+ onDateClicked(DateTime.fromJSDate(date))}
+ onKeyDown={onKeyDown}
+ onMouseEnter={onMouseEnter}
+ tabIndex={tabIndex}
+ disabled={disabledDate}
+ aria-label={`Day-${date.toDateString()}`}
+ >
+
+
+ {month}
+
+
+ {children}
+
+ );
+}
diff --git a/client/src/components/Datepicker/components/ResetDatesButton.tsx b/client/src/components/Datepicker/components/ResetDatesButton.tsx
new file mode 100644
index 00000000..235009ae
--- /dev/null
+++ b/client/src/components/Datepicker/components/ResetDatesButton.tsx
@@ -0,0 +1,34 @@
+import { RepeatIcon } from '@chakra-ui/icons';
+import { Button } from '@chakra-ui/react';
+
+import { useDatepickerContext } from '../context/DatepickerContext';
+import { useStyleProps } from '../context/StylesContext';
+import { ResetDatesButtonStyles } from '../types';
+
+interface ResetDatesProps {
+ onResetDates(): void;
+ text: string;
+}
+
+export function ResetDatesButton({ onResetDates, text }: ResetDatesProps) {
+ const { phrases } = useDatepickerContext();
+
+ const styleProps = useStyleProps({
+ resetDatesButton: {},
+ });
+
+ return (
+ }
+ tabIndex={-1}
+ aria-label={phrases.resetDates}
+ {...styleProps.resetDatesButton}
+ onClick={onResetDates}
+ onMouseUp={(e) => {
+ e.currentTarget.blur();
+ }}
+ >
+ {text}
+
+ );
+}
diff --git a/client/src/components/Datepicker/components/SelectedDate.tsx b/client/src/components/Datepicker/components/SelectedDate.tsx
new file mode 100644
index 00000000..f147246e
--- /dev/null
+++ b/client/src/components/Datepicker/components/SelectedDate.tsx
@@ -0,0 +1,52 @@
+import { Stack, Text } from '@chakra-ui/react';
+import { getInputValue } from '@datepicker-react/hooks';
+import { useDatepickerContext } from '../context/DatepickerContext';
+import { useStyleProps } from '../context/StylesContext';
+import { InputDate, SelectDateStyles } from '../types';
+import { getStateStyle } from '../utils/getStateStyle';
+
+export interface SelectedDateProps {
+ isFocused: boolean;
+ date: InputDate;
+}
+
+export const SelectedDate = ({ isFocused, date }: SelectedDateProps) => {
+ const { phrases, displayFormat } = useDatepickerContext();
+
+ const styleProps = useStyleProps({
+ selectDateContainer: {
+ base: {
+ width: '100%',
+ borderBottom: '2px solid',
+ borderColor: 'gray.300',
+ paddingBottom: [1, 3],
+ },
+ active: {
+ borderColor: 'blue.400',
+ },
+ },
+ selectDateText: {
+ base: {
+ fontSize: 'xs',
+ color: 'gray.500',
+ },
+ active: {},
+ },
+ selectDateDateText: {
+ base: {
+ fontWeight: 'bold',
+ fontSize: ['sm', 'md', 'lg'],
+ },
+ active: {},
+ },
+ });
+
+ return (
+
+ {phrases.datepickerStartDateLabel}
+
+ {getInputValue(date, displayFormat, phrases.datepickerStartDatePlaceholder)}
+
+
+ );
+};
diff --git a/client/src/components/Datepicker/components/index.ts b/client/src/components/Datepicker/components/index.ts
new file mode 100644
index 00000000..4a26b1a9
--- /dev/null
+++ b/client/src/components/Datepicker/components/index.ts
@@ -0,0 +1,7 @@
+export * from './ActionButton'
+export * from './CloseButton'
+export * from './Day'
+export * from './Input'
+export * from './Month'
+export * from './ResetDatesButton'
+export * from './SelectedDate'
diff --git a/client/src/components/Datepicker/context/DatepickerContext.tsx b/client/src/components/Datepicker/context/DatepickerContext.tsx
new file mode 100644
index 00000000..d63c9f73
--- /dev/null
+++ b/client/src/components/Datepicker/context/DatepickerContext.tsx
@@ -0,0 +1,96 @@
+import { FocusedInput, FormatFunction, useDatepicker } from '@datepicker-react/hooks';
+import { createContext, ReactNode, useContext } from 'react';
+import { datepickerPhrases, DatepickerPhrases } from '../phrases';
+import { InputDate } from '../types';
+import { dayLabelFormatFn, defaultDisplayFormat, monthLabelFormatFn, weekdayLabelFormatFn } from '../utils/formatters';
+
+export type UseDatepickerReturnType = ReturnType;
+
+export interface DatepickerFormatProps {
+ dayLabelFormat: typeof dayLabelFormatFn;
+ weekdayLabelFormat: typeof weekdayLabelFormatFn;
+ monthLabelFormat: typeof monthLabelFormatFn;
+}
+
+export type OnDayRenderType = {
+ isFirst: boolean;
+ isLast: boolean;
+ isSelected: boolean;
+ isWithinHoverRange: boolean;
+ isSelectedStartOrEnd: boolean;
+ disabledDate: boolean;
+};
+
+export interface DatepickerContextBaseProps {
+ displayFormat: FormatFunction | string;
+ startDate: InputDate;
+ endDate: InputDate;
+ phrases: DatepickerPhrases;
+ focusedInput: FocusedInput;
+ onDayRender?(date: Date, state: OnDayRenderType): ReactNode;
+ children?: ReactNode;
+}
+
+export interface DatepickerContextProps
+ extends DatepickerContextBaseProps,
+ DatepickerFormatProps,
+ UseDatepickerReturnType {}
+
+export interface DatepickerProviderProps extends Partial {}
+
+const defaultBase: DatepickerContextBaseProps = {
+ startDate: null,
+ endDate: null,
+ focusedInput: null,
+ onDayRender: undefined,
+ displayFormat: defaultDisplayFormat,
+ phrases: datepickerPhrases,
+};
+
+const defaultFormatters: DatepickerFormatProps = {
+ monthLabelFormat: monthLabelFormatFn,
+ weekdayLabelFormat: weekdayLabelFormatFn,
+ dayLabelFormat: dayLabelFormatFn,
+};
+
+const defaultUseDatepicker: UseDatepickerReturnType = {
+ numberOfMonths: 2,
+ activeMonths: [],
+ firstDayOfWeek: 0,
+ focusedDate: null,
+ hoveredDate: null,
+ goToDate: () => {},
+ goToNextMonths: () => {},
+ goToNextMonthsByOneMonth: () => {},
+ goToNextYear: () => {},
+ goToPreviousMonths: () => {},
+ goToPreviousMonthsByOneMonth: () => {},
+ goToPreviousYear: () => {},
+ isDateBlocked: () => false,
+ isDateFocused: () => false,
+ isDateHovered: () => false,
+ isDateSelected: () => false,
+ isEndDate: () => false,
+ isFirstOrLastSelectedDate: () => false,
+ isStartDate: () => false,
+ onDateFocus: () => {},
+ onDateHover: () => {},
+ onDateSelect: () => {},
+ onResetDates: () => {},
+};
+
+export const datepickerContextDefaultValue: DatepickerContextProps = {
+ ...defaultBase,
+ ...defaultFormatters,
+ ...defaultUseDatepicker,
+};
+
+export const DatepickerContext = createContext(datepickerContextDefaultValue);
+
+export const useDatepickerContext = () => useContext(DatepickerContext);
+
+export const DatepickerProvider = ({ children, ...props }: DatepickerProviderProps) => (
+
+ {children}
+
+);
diff --git a/client/src/components/Datepicker/context/StylesContext.tsx b/client/src/components/Datepicker/context/StylesContext.tsx
new file mode 100644
index 00000000..953937b2
--- /dev/null
+++ b/client/src/components/Datepicker/context/StylesContext.tsx
@@ -0,0 +1,104 @@
+import { createContext, FC, ReactNode, useContext } from 'react';
+import { DatepickerStyles } from '../types';
+import merge from '../utils/deepmerge';
+import pick from '../utils/pick';
+
+export interface StylesContextProps {
+ overwriteDefaultStyles: boolean;
+ styles: DatepickerStyles;
+}
+
+export interface StylesProviderProps {
+ overwriteDefaultStyles?: boolean;
+ styles?: Partial;
+ children: ReactNode;
+}
+
+export const emptyStylesObject: DatepickerStyles = {
+ actionButton: {},
+ arrowIcon: {},
+ buttonsWrapper: {},
+ closeButton: {},
+ datepickerContainer: {},
+ dateRangeInputContainer: {},
+ dateRangeInputDivider: {},
+ day: {
+ base: {},
+ normal: {},
+ rangeHover: {},
+ selected: {},
+ first: {},
+ firstOrLast: {},
+ last: {},
+ },
+ dayContainer: {
+ base: {},
+ normal: {},
+ rangeHover: {},
+ selected: {},
+ first: {},
+ firstOrLast: {},
+ last: {},
+ },
+ inputComponentIcon: {
+ active: {},
+ base: {},
+ },
+ inputComponentInput: {
+ active: {},
+ base: {},
+ },
+ inputComponentInputAddon: {
+ active: {},
+ base: {},
+ },
+ inputComponentInputGroup: {
+ active: {},
+ base: {},
+ },
+ monthContainer: {},
+ monthDayGrid: {},
+ monthMonthLabel: {},
+ monthsWrapper: {},
+ monthWeekdayLabel: {},
+ resetDatesButton: {},
+ selectDateContainer: {
+ active: {},
+ base: {},
+ },
+ selectDateDateText: {
+ active: {},
+ base: {},
+ },
+ selectDateText: {
+ active: {},
+ base: {},
+ },
+ datepickerHeader: {},
+ emptyDay: {},
+};
+
+export const StylesContext = createContext({
+ styles: emptyStylesObject,
+ overwriteDefaultStyles: false,
+});
+
+export const StylesProvider: FC = ({
+ children,
+ overwriteDefaultStyles = false,
+ styles = emptyStylesObject,
+}) => (
+
+ {children}
+
+);
+
+export function useStyleProps = Partial>(
+ inlineStyles: InitialStyles,
+) {
+ const { styles, overwriteDefaultStyles } = useContext(StylesContext);
+ const keys = Object.keys(inlineStyles) as (keyof DatepickerStyles)[];
+ const filteredStyles = pick(styles, keys);
+ const result = merge(filteredStyles, !overwriteDefaultStyles ? inlineStyles : ({} as InitialStyles));
+ return result;
+}
diff --git a/client/src/components/Datepicker/index.ts b/client/src/components/Datepicker/index.ts
new file mode 100644
index 00000000..470b4c50
--- /dev/null
+++ b/client/src/components/Datepicker/index.ts
@@ -0,0 +1,6 @@
+export * from './Datepicker'
+export * from './DateRangeInput'
+export * from './DateSingleInput'
+export * from './phrases'
+export * from './types'
+export * from './utils/formatters'
diff --git a/client/src/components/Datepicker/phrases.ts b/client/src/components/Datepicker/phrases.ts
new file mode 100644
index 00000000..c7bb74e6
--- /dev/null
+++ b/client/src/components/Datepicker/phrases.ts
@@ -0,0 +1,52 @@
+export interface DatepickerPhrases {
+ datepickerStartDatePlaceholder: string
+ datepickerStartDateLabel: string
+ datepickerEndDateLabel: string
+ datepickerEndDatePlaceholder: string
+ resetDates: string
+ close: string
+}
+
+export interface DateRangeInputPhrases extends DatepickerPhrases {
+ startDateAriaLabel: string
+ endDateAriaLabel: string
+ startDatePlaceholder: string
+ endDatePlaceholder: string
+}
+
+export interface DateRangeInputPhrases extends DatepickerPhrases {
+ startDateAriaLabel: string
+ endDateAriaLabel: string
+ startDatePlaceholder: string
+ endDatePlaceholder: string
+}
+
+export interface DateSingleInputPhrases extends DatepickerPhrases {
+ dateAriaLabel: string
+ datePlaceholder: string
+}
+
+export const datepickerPhrases: DatepickerPhrases = {
+ datepickerStartDatePlaceholder: 'Select',
+ datepickerStartDateLabel: 'Start date:',
+ datepickerEndDatePlaceholder: 'Select',
+ datepickerEndDateLabel: 'End date:',
+ resetDates: 'Reset dates',
+ close: 'Close',
+}
+
+export const dateRangeInputPhrases: DateRangeInputPhrases = {
+ ...datepickerPhrases,
+
+ startDateAriaLabel: 'Start date',
+ endDateAriaLabel: 'End date',
+
+ startDatePlaceholder: 'Start date',
+ endDatePlaceholder: 'End date',
+}
+
+export const dateSingleInputPhrases: DateSingleInputPhrases = {
+ ...datepickerPhrases,
+ dateAriaLabel: 'Select date',
+ datePlaceholder: 'Select date',
+}
diff --git a/client/src/components/Datepicker/types/index.ts b/client/src/components/Datepicker/types/index.ts
new file mode 100644
index 00000000..115db144
--- /dev/null
+++ b/client/src/components/Datepicker/types/index.ts
@@ -0,0 +1,2 @@
+export * from './styles'
+export * from './utils'
diff --git a/client/src/components/Datepicker/types/styles.ts b/client/src/components/Datepicker/types/styles.ts
new file mode 100644
index 00000000..43713d6c
--- /dev/null
+++ b/client/src/components/Datepicker/types/styles.ts
@@ -0,0 +1,88 @@
+import {
+ BoxProps,
+ ButtonProps,
+ CloseButtonProps,
+ FlexProps,
+ IconButtonProps,
+ IconProps,
+ InputAddonProps,
+ InputGroupProps,
+ InputProps,
+ SimpleGridProps,
+ StackDividerProps,
+ StackProps,
+} from '@chakra-ui/react';
+
+export type StateProp = { base: T; active?: T };
+
+export interface ActionButtonStyles {
+ actionButton: Partial;
+}
+
+export interface CloseButtonStyles {
+ closeButton: Partial;
+}
+
+export interface DatepickerComponentStyles {
+ datepickerContainer: BoxProps;
+ monthsWrapper: StackProps;
+ buttonsWrapper: StackProps;
+ arrowIcon: Omit;
+ datepickerHeader: FlexProps;
+}
+
+export interface DayState {
+ base: T;
+ normal: T;
+ rangeHover: T;
+ selected: T;
+ firstOrLast: T;
+ first: T;
+ last: T;
+}
+
+export interface DayStyles {
+ day: DayState;
+ dayContainer: DayState;
+}
+
+export interface InputComponentStyles {
+ inputComponentInputGroup: StateProp;
+ inputComponentInput: StateProp;
+ inputComponentIcon: StateProp>;
+ inputComponentInputAddon: StateProp;
+}
+
+export interface MonthStyles {
+ monthContainer: BoxProps;
+ monthMonthLabel: BoxProps;
+ monthWeekdayLabel: BoxProps;
+ emptyDay: BoxProps;
+ monthDayGrid: SimpleGridProps;
+}
+
+export interface ResetDatesButtonStyles {
+ resetDatesButton: ButtonProps;
+}
+
+export interface SelectDateStyles {
+ selectDateContainer: StateProp;
+ selectDateText: StateProp;
+ selectDateDateText: StateProp;
+}
+
+export interface DateRangeInputStyles {
+ dateRangeInputContainer: StackProps;
+ dateRangeInputDivider: StackDividerProps;
+}
+
+export interface DatepickerStyles
+ extends ActionButtonStyles,
+ CloseButtonStyles,
+ DatepickerComponentStyles,
+ DayStyles,
+ InputComponentStyles,
+ MonthStyles,
+ ResetDatesButtonStyles,
+ SelectDateStyles,
+ DateRangeInputStyles {}
diff --git a/client/src/components/Datepicker/types/utils.ts b/client/src/components/Datepicker/types/utils.ts
new file mode 100644
index 00000000..a930cb7f
--- /dev/null
+++ b/client/src/components/Datepicker/types/utils.ts
@@ -0,0 +1 @@
+export type InputDate = Date | null
diff --git a/client/src/components/Datepicker/utils/deepmerge.ts b/client/src/components/Datepicker/utils/deepmerge.ts
new file mode 100644
index 00000000..a8f35c5e
--- /dev/null
+++ b/client/src/components/Datepicker/utils/deepmerge.ts
@@ -0,0 +1,41 @@
+// source: https://github.com/voodoocreation/ts-deepmerge/blob/master/src/index.ts
+interface IObject {
+ [key: string]: any
+}
+
+type TUnionToIntersection = (U extends any
+? (k: U) => void
+: never) extends (k: infer I) => void
+ ? I
+ : never
+
+// istanbul ignore next
+const isObject = (obj: any) => {
+ if (typeof obj === 'object' && obj !== null) {
+ if (typeof Object.getPrototypeOf === 'function') {
+ const prototype = Object.getPrototypeOf(obj)
+ return prototype === Object.prototype || prototype === null
+ }
+
+ return Object.prototype.toString.call(obj) === '[object Object]'
+ }
+
+ return false
+}
+
+const merge = (...objects: T): TUnionToIntersection =>
+ objects.reduce((result, current) => {
+ Object.keys(current).forEach(key => {
+ if (Array.isArray(result[key]) && Array.isArray(current[key])) {
+ result[key] = Array.from(new Set(result[key].concat(current[key])))
+ } else if (isObject(result[key]) && isObject(current[key])) {
+ result[key] = merge(result[key], current[key])
+ } else {
+ result[key] = current[key]
+ }
+ })
+
+ return result
+ }, {}) as any
+
+export default merge
diff --git a/client/src/components/Datepicker/utils/formatters.ts b/client/src/components/Datepicker/utils/formatters.ts
new file mode 100644
index 00000000..61c8e866
--- /dev/null
+++ b/client/src/components/Datepicker/utils/formatters.ts
@@ -0,0 +1,10 @@
+import dateFormat from 'date-fns/format';
+
+export { weekdayLabelFormat as weekdayLabelFormatFn } from '@datepicker-react/hooks';
+
+export const weekdayLabelFormatLong = (date: Date) => dateFormat(date, 'EEEE');
+
+export const dayLabelFormatFn = (date: Date) => dateFormat(date, 'd');
+export const monthLabelFormatFn = (date: Date) => dateFormat(date, 'MMMM');
+
+export const defaultDisplayFormat = 'MM/dd/yyyy';
diff --git a/client/src/components/Datepicker/utils/getStateStyle.ts b/client/src/components/Datepicker/utils/getStateStyle.ts
new file mode 100644
index 00000000..870ffec9
--- /dev/null
+++ b/client/src/components/Datepicker/utils/getStateStyle.ts
@@ -0,0 +1,5 @@
+import { StateProp } from '../types'
+
+export const getStateStyle = ({ base, active }: StateProp, isActive: boolean) => {
+ return isActive ? { ...base, ...active } : base
+}
diff --git a/client/src/components/Datepicker/utils/pick.ts b/client/src/components/Datepicker/utils/pick.ts
new file mode 100644
index 00000000..4ceb3281
--- /dev/null
+++ b/client/src/components/Datepicker/utils/pick.ts
@@ -0,0 +1,9 @@
+function pick(obj: T, keys: K[]): Pick {
+ const ret: any = {}
+ keys.forEach(key => {
+ ret[key] = obj[key]
+ })
+ return ret
+}
+
+export default pick
diff --git a/client/src/components/ErrorBoundary/ErrorBoundary.tsx b/client/src/components/ErrorBoundary/ErrorBoundary.tsx
new file mode 100644
index 00000000..163c00e9
--- /dev/null
+++ b/client/src/components/ErrorBoundary/ErrorBoundary.tsx
@@ -0,0 +1,138 @@
+import {
+ Alert,
+ AlertDescription,
+ AlertIcon,
+ AlertTitle,
+ Box,
+ Button,
+ Code,
+ Heading,
+ HStack,
+ Stack,
+ Text,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import { Component, ErrorInfo, ReactNode } from 'react';
+
+interface Props {
+ children: ReactNode;
+ fallback?: ReactNode;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+ errorInfo: ErrorInfo | null;
+}
+
+const ErrorFallback = ({
+ error,
+ errorInfo,
+ resetErrorBoundary,
+}: {
+ error: Error | null;
+ errorInfo: ErrorInfo | null;
+ resetErrorBoundary: () => void;
+}) => {
+ const bgColor = useColorModeValue('red.50', 'rgba(254, 178, 178, 0.16)');
+ const borderColor = useColorModeValue('red.400', 'red.300');
+
+ return (
+
+
+
+ Something went wrong
+ An error occurred in the application
+
+
+
+
+ Error Details
+
+ {error && (
+
+ Error:
+
+ {error.toString()}
+
+
+ )}
+
+ {errorInfo && (
+
+ Component Stack:
+
+ {errorInfo.componentStack}
+
+
+ )}
+
+
+
+ Try Again
+
+
+
+
+ );
+};
+
+export class ErrorBoundary extends Component {
+ constructor(props: Props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return {
+ hasError: true,
+ error,
+ errorInfo: null,
+ };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
+ this.setState({
+ error,
+ errorInfo,
+ });
+
+ // You can also log the error to an error reporting service here
+ console.error('Error caught by ErrorBoundary:', error, errorInfo);
+ }
+
+ resetErrorBoundary = () => {
+ this.setState({
+ hasError: false,
+ error: null,
+ errorInfo: null,
+ });
+ };
+
+ render(): ReactNode {
+ const { hasError, error, errorInfo } = this.state;
+ const { children, fallback } = this.props;
+
+ if (hasError) {
+ if (fallback) {
+ return fallback;
+ }
+
+ return ;
+ }
+
+ return children;
+ }
+}
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx
new file mode 100644
index 00000000..77eb3f03
--- /dev/null
+++ b/client/src/components/Header/Header.tsx
@@ -0,0 +1,32 @@
+import { Flex, Box, Center, Link } from '@chakra-ui/react';
+import { useColorModeValue } from '@chakra-ui/react';
+import { TocklerLogo } from './TocklerLogo';
+import { TocklerLogoText } from './TocklerLogoText';
+
+export const Header = ({ children, brandLinkProps }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {children}
+
+);
diff --git a/client/src/components/Header/MenuItem.tsx b/client/src/components/Header/MenuItem.tsx
new file mode 100644
index 00000000..6d5fa19f
--- /dev/null
+++ b/client/src/components/Header/MenuItem.tsx
@@ -0,0 +1,14 @@
+import { Link as RouterLink } from 'react-router-dom';
+import { Box, Center } from '@chakra-ui/react';
+import { Button } from '@chakra-ui/react';
+
+export const MenuItem = ({ to, icon, title }) => (
+
+
+
+ {icon}
+ {title}
+
+
+
+);
diff --git a/client/src/components/Header/TocklerLogo.tsx b/client/src/components/Header/TocklerLogo.tsx
new file mode 100644
index 00000000..5aad2e69
--- /dev/null
+++ b/client/src/components/Header/TocklerLogo.tsx
@@ -0,0 +1,38 @@
+import { Icon } from '@chakra-ui/icons';
+
+export const TocklerLogo = (props) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
diff --git a/client/src/components/Header/TocklerLogoText.tsx b/client/src/components/Header/TocklerLogoText.tsx
new file mode 100644
index 00000000..a1ab6e2c
--- /dev/null
+++ b/client/src/components/Header/TocklerLogoText.tsx
@@ -0,0 +1,14 @@
+import { Icon } from '@chakra-ui/icons';
+
+export const TocklerLogoText = (props) => (
+
+ Tockler
+
+
+
+
+);
diff --git a/client/src/components/LineCharts/LineChart.tsx b/client/src/components/LineCharts/LineChart.tsx
index 1f89f7ec..e250b8d1 100644
--- a/client/src/components/LineCharts/LineChart.tsx
+++ b/client/src/components/LineCharts/LineChart.tsx
@@ -1,25 +1,32 @@
-import React, { useContext } from 'react';
-import moment from 'moment';
import { values } from 'lodash';
-import { VictoryBar, VictoryChart, VictoryAxis, VictoryTooltip } from 'victory';
-import { convertDate, TIME_FORMAT, DAY_MONTH_LONG_FORMAT, COLORS } from '../../constants';
+import { useContext } from 'react';
-import { useWindowWidth } from '@react-hook/window-size/throttled';
-import { SummaryContext } from '../../SummaryContext';
+import { useMeasure } from '@uidotdev/usehooks';
+import { IoMdMoon, IoMdSunny } from 'react-icons/io';
import {
- addToTimeDuration,
- formatToTimeEveryOther,
- formatToDay,
- toTimeDuration,
-} from './LineChart.util';
-import { diffAndFormatShort } from '../../utils';
+ DomainPaddingPropType,
+ DomainTuple,
+ ForAxes,
+ VictoryAxis,
+ VictoryBar,
+ VictoryChart,
+ VictoryScatter,
+ VictoryTooltip,
+ VictoryVoronoiContainer,
+} from 'victory';
+import { COLORS, convertDate, DAY_MONTH_LONG_FORMAT, TIME_FORMAT } from '../../constants';
import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { SummaryContext } from '../../SummaryContext';
+import { formatDurationInternal } from '../../utils';
+import { BlackBox } from '../BlackBox';
+import { BAR_WIDTH } from '../Timeline/timeline.constants';
+import { dateToDayLabel, formatToHours } from './LineChart.util';
-const scale: any = { x: 'time', y: 'time' };
-const padding: any = { left: 50, top: 20, bottom: 20, right: 10 };
-const domainPadding: any = { y: 40, x: [20, 40] };
+const scale: { x: 'time'; y: 'linear' } = { x: 'time', y: 'linear' };
+const padding = { left: 25, top: 20, bottom: 30, right: 10 };
+const domainPadding: DomainPaddingPropType = { y: 0, x: [BAR_WIDTH, 0] };
-const labelComponent = theme => (
+const labelComponent = (theme) => (
(
/>
);
+const formatToTime = (d) => convertDate(d).toFormat(TIME_FORMAT);
+const formatToLong = (d) => convertDate(d).toFormat(DAY_MONTH_LONG_FORMAT);
+
+const dateToMinutes = (useStartDate) => (d) => {
+ const m = convertDate(useStartDate ? d.beginDate : d.endDate);
+ const hours = m.hour + m.minute / 60;
+
+ return hours / 24;
+};
+
+const ScatterPoint = ({ x = 0, y = 0, showMoon = false, showSun = false }) => {
+ if (showMoon) {
+ return ;
+ }
+ if (showSun) {
+ return ;
+ }
+ return null;
+};
+
+const getXAxisDay = (d) => convertDate(d.beginDate).startOf('day').valueOf();
+
export const LineChart = () => {
const { chartTheme } = useChartThemeState();
- const chartWidth = useWindowWidth();
+ const [ref, { width }] = useMeasure();
const { onlineTimesSummary, selectedDate } = useContext(SummaryContext);
const onlineTimesValues = values(onlineTimesSummary);
- const barHeight = 20;
+ const daysInMonth = selectedDate?.daysInMonth;
+
+ const maxOnline = Math.max(...onlineTimesValues.map((d) => d.online));
+
+ const domain: ForAxes = {
+ x: [selectedDate.startOf('month').toJSDate(), selectedDate.endOf('month').toJSDate()],
+ y: [0, 1],
+ };
- const daysInMonth = selectedDate.daysInMonth();
- const daysArray = Array.from(Array(daysInMonth), (_, i) => i + 1);
+ const axisStyle = {
+ grid: { strokeWidth: 0 },
+ ticks: { stroke: 'gray', size: 5 },
+ };
+
+ const isNarrow = width ? width < 1400 : false;
return (
-
-
-
-
-
- toTimeDuration(convertDate(d.beginDate), convertDate(d.beginDate))}
- y0={d => toTimeDuration(convertDate(d.beginDate), convertDate(d.endDate))}
- x={d => formatToDay(convertDate(d.beginDate).startOf('day'))}
- barWidth={barHeight}
- data={onlineTimesValues}
- labelComponent={labelComponent(chartTheme)}
- labels={({ datum }) =>
- `${convertDate(datum.beginDate).format(DAY_MONTH_LONG_FORMAT)}\r\n
- Start time: ${convertDate(datum.beginDate).format(
- TIME_FORMAT,
- )}\r\nEnd time: ${convertDate(datum.endDate).format(
- TIME_FORMAT,
- )}\r\nDuration: ${diffAndFormatShort(datum.beginDate, datum.endDate)}`
- }
- />
- toTimeDuration(convertDate(d.beginDate), convertDate(d.beginDate))}
- y0={d => addToTimeDuration(convertDate(d.beginDate), d.online)}
- x={d => formatToDay(convertDate(d.beginDate).startOf('day'))}
- barWidth={10}
- style={{ data: { fill: COLORS.green } }}
- data={onlineTimesValues}
- labelComponent={labelComponent(chartTheme)}
- labels={({ datum }) =>
- `${convertDate(datum.beginDate).format(
- DAY_MONTH_LONG_FORMAT,
- )}\r\nWorked: ${moment.duration(datum.online).format()}`
+
+
+ {
+ const { childName, beginDate, endDate, online } = datum;
+ if (childName === 'beginDate') {
+ return `Start time: ${formatToTime(beginDate)}`;
+ }
+ if (childName === 'endDate') {
+ return `End time: ${formatToTime(endDate)}`;
+ }
+ if (childName === 'online') {
+ return `${formatToLong(datum.beginDate)}\r\nWorked: ${formatDurationInternal(online)}`;
+ }
+ return '';
+ }}
+ />
}
- />
-
+ >
+
+
+
+ d.online / maxOnline}
+ barWidth={BAR_WIDTH}
+ style={{ data: { fill: COLORS.green } }}
+ data={onlineTimesValues}
+ />
+ }
+ />
+ }
+ />
+
+
);
};
diff --git a/client/src/components/LineCharts/LineChart.util.ts b/client/src/components/LineCharts/LineChart.util.ts
index fe6bf865..18ee1401 100644
--- a/client/src/components/LineCharts/LineChart.util.ts
+++ b/client/src/components/LineCharts/LineChart.util.ts
@@ -1,46 +1,60 @@
-import moment from 'moment';
-import { convertDate } from '../../constants';
+import { intervalToDuration } from 'date-fns';
import { toInteger } from 'lodash';
+import { DateTime } from 'luxon';
+import { convertDate } from '../../constants';
export const generateTickValues = (date, ticks, unit, startOf) => {
const day = convertDate(date).startOf(startOf);
const dates = [...Array(ticks)].map((__, i) => {
- return day.clone().add(i, unit);
+ return day.plus({ [unit]: i });
});
return dates;
};
export const toTimeDuration = (from, to) => {
- const start = moment(from).startOf('day');
-
- return moment(moment(to).diff(start));
+ const start = DateTime.fromJSDate(from).startOf('day');
+ return DateTime.fromJSDate(to).diff(start);
};
-export const addToTimeDuration = (from, duration) => {
- const start = moment(from).startOf('day');
- return moment(moment(from).diff(start) + duration);
+export const addToTimeDuration = (from, duration) => {
+ const start = DateTime.fromJSDate(from).startOf('day');
+ return DateTime.fromJSDate(from).diff(start).plus(duration);
};
-export const isOddHour = date => moment(date).get('hour') % 2;
+export const isOddHour = (date) => DateTime.fromJSDate(date).hour % 2;
-export const formatToTime = t => moment.utc(t).format('HH:mm');
+export const formatToTime = (t: number) => DateTime.fromMillis(t).toFormat('HH:mm');
-export const formatToTimeEveryOther = t => {
- const hour = moment(t).startOf('hour');
- return formatToTime(hour);
+export const formatToTimeEveryOther = (t: number) => {
+ const hour = DateTime.fromMillis(t).startOf('hour');
+ return formatToTime(hour.toMillis());
};
-export const formatToDay = t => toInteger(t.format('DD'));
+export const toHours = (t: number) => {
+ const duration = intervalToDuration({ start: 0, end: t });
+ return duration.hours ?? 0;
+};
+
+export const formatToHours = (max: number) => (t: number) => {
+ const duration = intervalToDuration({ start: 0, end: t * max });
+ return `${duration.hours} h`;
+};
+
+export const formatToDay = (t: DateTime) => toInteger(t.toFormat('dd'));
+
+export const dateToDayLabel = (short) => (date) => {
+ return DateTime.fromJSDate(date).toFormat(short ? 'dd' : 'dd ccc');
+};
-export const timeTickValues = t => {
+export const timeTickValues = () => {
const ticks = 36;
- const day = moment();
+ const day = DateTime.now();
const dates = [...Array(ticks)].map((__, i) => {
- return toTimeDuration(day, day.clone().add(i, 'hour'));
+ return toTimeDuration(day.toJSDate(), day.plus({ hours: i }).toJSDate());
});
return dates;
};
-export const dayTickValues = t => generateTickValues(t, 31, 'day', 'month');
+export const dayTickValues = (t) => generateTickValues(t, 31, 'day', 'month');
diff --git a/client/src/components/LineCharts/LineCharts.styles.ts b/client/src/components/LineCharts/LineCharts.styles.ts
deleted file mode 100644
index b66050c9..00000000
--- a/client/src/components/LineCharts/LineCharts.styles.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import styled from 'styled-components';
-import { Box } from 'reflexbox';
-
-export const LineChartBox = styled(Box).attrs({ m: 2, p: 2 })`
- background: ${({ theme: { variables } }) => variables['@component-background']};
-`;
diff --git a/client/src/components/Loader.jsx b/client/src/components/Loader.jsx
new file mode 100644
index 00000000..2ca9db87
--- /dev/null
+++ b/client/src/components/Loader.jsx
@@ -0,0 +1,9 @@
+import { Box, CircularProgress } from '@chakra-ui/react';
+
+export const Loader = () => {
+ return (
+
+
+
+ );
+};
diff --git a/client/src/components/LoadingLine.tsx b/client/src/components/LoadingLine.tsx
new file mode 100644
index 00000000..b58e62ae
--- /dev/null
+++ b/client/src/components/LoadingLine.tsx
@@ -0,0 +1,25 @@
+import { Box, useToken } from '@chakra-ui/react';
+import { keyframes } from '@emotion/react';
+
+// Loading line animation
+const loadingAnimation = keyframes`
+ 0% { width: 0%; left: 0; }
+ 50% { width: 70%; left: 15%; }
+ 100% { width: 100%; left: 0; }
+`;
+
+export const LoadingLine = () => {
+ const [blue500] = useToken('colors', ['blue.500']);
+
+ return (
+
+ );
+};
diff --git a/client/src/components/MainLayout/HeaderMenu.styles.ts b/client/src/components/MainLayout/HeaderMenu.styles.ts
deleted file mode 100644
index 1433c913..00000000
--- a/client/src/components/MainLayout/HeaderMenu.styles.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import styled from 'styled-components';
-
-export const Brand = styled.div`
- font-family: 'Berkshire Swash', cursive;
- text-align: left;
- font-size: 26px;
-`;
-
-export const Img = styled.img`
- margin-right: 15px;
- margin-bottom: 6px;
-`;
diff --git a/client/src/components/MainLayout/HeaderMenu.tsx b/client/src/components/MainLayout/HeaderMenu.tsx
index abed35ad..28d0d944 100644
--- a/client/src/components/MainLayout/HeaderMenu.tsx
+++ b/client/src/components/MainLayout/HeaderMenu.tsx
@@ -1,59 +1,28 @@
-import { Menu } from 'antd';
import {
- BarsOutlined,
- AreaChartOutlined,
- SearchOutlined,
- SettingOutlined,
- QuestionCircleOutlined,
-} from '@ant-design/icons';
-import React from 'react';
-import { Link } from 'react-router-dom';
-import styled from 'styled-components';
-import tocklerIcon from '../../assets/icons/tockler_icon.png';
-import { Brand, Img } from './HeaderMenu.styles';
+ AiOutlineAreaChart,
+ AiOutlineBars,
+ AiOutlineQuestionCircle,
+ AiOutlineSearch,
+ AiOutlineSetting,
+} from 'react-icons/ai';
-const GrayMenu = styled(Menu)``;
+import { Box } from '@chakra-ui/react';
+import { Link as RouterLink } from 'react-router-dom';
+import { ColorModeSwitcher } from '../ColorModeSwitcher';
+import { Header } from '../Header/Header';
+import { MenuItem } from '../Header/MenuItem';
-export const HeaderMenu = ({ location }: any) => (
-
-
-
-
-
- Tockler
-
-
-
-
-
-
- Timeline
-
-
-
-
-
- Summary
-
-
-
-
-
- Search
-
-
-
-
-
- Settings
-
-
+export const HeaderMenu = () => (
+
+ } title="Timeline" />
+ } title="Summary" />
+ } title="Search" />
+ } title="Settings" />
+ } title="Support" />
+
-
-
-
- Support
-
-
-
+
+
+
+
);
diff --git a/client/src/components/MainLayout/MainLayout.tsx b/client/src/components/MainLayout/MainLayout.tsx
index 62c09e10..e00eaf3f 100644
--- a/client/src/components/MainLayout/MainLayout.tsx
+++ b/client/src/components/MainLayout/MainLayout.tsx
@@ -1,19 +1,15 @@
-import { Layout } from 'antd';
-import React from 'react';
-import { HeaderMenu } from './HeaderMenu';
+import { Box } from '@chakra-ui/react';
-const { Footer, Content } = Layout;
+import { Outlet } from 'react-router-dom';
+import { HeaderMenu } from './HeaderMenu';
-export function MainLayout({ children, location }: any) {
+export function MainLayout() {
return (
-
+
+
+
+
+
+
);
}
diff --git a/client/src/components/MakeUnitSmaller.tsx b/client/src/components/MakeUnitSmaller.tsx
new file mode 100644
index 00000000..9ea7e72a
--- /dev/null
+++ b/client/src/components/MakeUnitSmaller.tsx
@@ -0,0 +1,17 @@
+import { Text, useColorModeValue } from '@chakra-ui/react';
+
+export const MakeUnitSmaller = ({ children }) => {
+ const values = children.split(/(\d+)/);
+ const color = useColorModeValue('gray.700', 'gray.300');
+
+ return values.map((value) => {
+ if (isNaN(value)) {
+ return (
+
+ {value}
+
+ );
+ }
+ return value;
+ });
+};
diff --git a/client/src/components/Metric.tsx b/client/src/components/Metric.tsx
new file mode 100644
index 00000000..07b18aaa
--- /dev/null
+++ b/client/src/components/Metric.tsx
@@ -0,0 +1,16 @@
+import { Box, Text, useColorModeValue } from '@chakra-ui/react';
+
+export const Metric = ({ title, value }) => {
+ return (
+
+
+
+ {title}
+
+
+
+ {value}
+
+
+ );
+};
diff --git a/client/src/components/MetricTile.tsx b/client/src/components/MetricTile.tsx
new file mode 100644
index 00000000..5a2ea551
--- /dev/null
+++ b/client/src/components/MetricTile.tsx
@@ -0,0 +1,10 @@
+import { BoxProps, Flex } from '@chakra-ui/react';
+import { TileBox } from './TileBox';
+
+export const MetricTile = ({ children, ...rest }: BoxProps) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/client/src/components/PieCharts/AppUsageChart.tsx b/client/src/components/PieCharts/AppUsageChart.tsx
new file mode 100644
index 00000000..73b74afa
--- /dev/null
+++ b/client/src/components/PieCharts/AppUsageChart.tsx
@@ -0,0 +1,101 @@
+import _ from 'lodash';
+import { memo } from 'react';
+
+import { useMeasure } from '@uidotdev/usehooks';
+import { VictoryBar, VictoryLabel, VictoryStack, VictoryStyleInterface } from 'victory';
+import { useStoreState } from '../../store/easyPeasy';
+import { BAR_WIDTH } from '../Timeline/timeline.constants';
+import { filterItems } from '../Timeline/timeline.utils';
+import { sumAppObject, SumItem } from './MetricTiles.utils';
+
+import { TrackItemType } from '../../enum/TrackItemType';
+import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { formatDurationInternal } from '../../utils';
+import { BarWithTooltip } from '../Timeline/BarWithTooltip';
+import { colorProp } from '../charts.utils';
+import { getTextWidth } from './AppUsageChart.utils';
+
+export const AppUsageChart = memo(() => {
+ const [ref, { width }] = useMeasure();
+ const { chartTheme } = useChartThemeState();
+ const timeItems = useStoreState((state) => state.timeItems);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+
+ const appItems = filterItems(timeItems[TrackItemType.AppTrackItem] || [], visibleTimerange);
+
+ const groupByField = 'app';
+
+ const data = _(appItems)
+ .groupBy(groupByField)
+ .map((b) => {
+ return b.reduce(sumAppObject(visibleTimerange), {
+ app: b[0].app,
+ title: b[0].title,
+ timeDiffInMs: 0,
+ color: b[0].color,
+ } as SumItem);
+ })
+ .sortBy('timeDiffInMs')
+ .reverse()
+ .valueOf();
+
+ const getTooltipLabel = (datum) => {
+ return `${datum[groupByField]}\r\n${formatDurationInternal(datum.timeDiffInMs)}`;
+ };
+
+ const style: VictoryStyleInterface = {
+ data: {
+ fill: colorProp,
+ stroke: colorProp,
+ strokeWidth: 0.5,
+ fillOpacity: 0.75,
+ },
+ };
+
+ const labelPadding = 25;
+ return (
+
+
+ {data.map((item) => (
+ datum.app}
+ labelComponent={
+ {
+ const text = `${datum.app} - ${formatDurationInternal(datum.timeDiffInMs)}`;
+ const textWidth = getTextWidth(
+ text,
+ 14,
+ '"Gill Sans", Seravek, "Trebuchet MS", sans-serif;',
+ );
+
+ const timeDiff = datum._y1 - datum._y0;
+
+ const width =
+ typeof scale === 'object' && typeof scale.y === 'function'
+ ? scale.y(timeDiff)
+ : 0;
+
+ const canFit = textWidth + labelPadding * 2 < width;
+
+ return canFit ? text : '';
+ }}
+ dx={-labelPadding}
+ dy={-5}
+ />
+ }
+ dataComponent={ }
+ />
+ ))}
+
+
+ );
+});
diff --git a/client/src/components/PieCharts/AppUsageChart.utils.ts b/client/src/components/PieCharts/AppUsageChart.utils.ts
new file mode 100644
index 00000000..f91895f0
--- /dev/null
+++ b/client/src/components/PieCharts/AppUsageChart.utils.ts
@@ -0,0 +1,17 @@
+/**
+ * Measures the rendered width of arbitrary text given the font size and font face
+ * @param {string} text The text to measure
+ * @param {number} fontSize The font size in pixels
+ * @param {string} fontFace The font face ("Arial", "Helvetica", etc.)
+ * @returns {number} The width of the text
+ * */
+export function getTextWidth(text, fontSize, fontFace) {
+ const canvas = document.createElement('canvas');
+ const context = canvas.getContext('2d');
+ if (!context) {
+ console.warn('No 2d context');
+ return 0;
+ }
+ context.font = `${fontSize}px ${fontFace}`;
+ return context.measureText(text).width;
+}
diff --git a/client/src/components/PieCharts/MetricTiles.tsx b/client/src/components/PieCharts/MetricTiles.tsx
new file mode 100644
index 00000000..1335cbb8
--- /dev/null
+++ b/client/src/components/PieCharts/MetricTiles.tsx
@@ -0,0 +1,78 @@
+import { memo } from 'react';
+import { filterItems } from '../Timeline/timeline.utils';
+
+import { HStack } from '@chakra-ui/react';
+import { maxBy, minBy } from 'lodash';
+import { DateTime } from 'luxon';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { TIME_FORMAT_SHORT } from '../../constants';
+import { TrackItemType } from '../../enum/TrackItemType';
+import { useStoreState } from '../../store/easyPeasy';
+import { secondsToClock } from '../../time.util';
+import { MakeUnitSmaller } from '../MakeUnitSmaller';
+import { Metric } from '../Metric';
+import { MetricTile } from '../MetricTile';
+import { getOnlineTime, getTasksTime } from './MetricTiles.utils';
+
+const NO_VALUE = '-';
+
+const EMPTY_ARRAY: ITrackItem[] = [];
+
+export const MetricTiles = memo(() => {
+ const timeItems = useStoreState((state) => state.timeItems);
+ const timerange = useStoreState((state) => state.timerange);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+
+ const logItems = filterItems(timeItems[TrackItemType.LogTrackItem] || EMPTY_ARRAY, visibleTimerange);
+
+ const onlineItems =
+ timeItems[TrackItemType.StatusTrackItem]?.filter((item) => item.app === 'ONLINE') || EMPTY_ARRAY;
+
+ //
+ const onlineTotal = getOnlineTime(onlineItems, timerange);
+ const onlineTotalVisible = getOnlineTime(filterItems(onlineItems, visibleTimerange), visibleTimerange);
+ const tasksTotal = getTasksTime(timeItems[TrackItemType.LogTrackItem] || EMPTY_ARRAY, timerange);
+ const tasksTotalVisible = getTasksTime(logItems, visibleTimerange);
+
+ const wakeItem: ITrackItem | undefined = minBy(onlineItems, (c: ITrackItem) => c.beginDate);
+ const sleepItem: ITrackItem | undefined = maxBy(onlineItems, (c: ITrackItem) => c.endDate);
+
+ return (
+
+
+ {secondsToClock(onlineTotal / 1000, 0, 2)}}
+ />
+ {secondsToClock(onlineTotalVisible / 1000, 0, 2) || NO_VALUE}
+ }
+ />
+
+
+ {secondsToClock(tasksTotal / 1000, 0, 2) || NO_VALUE}}
+ />
+ {secondsToClock(tasksTotalVisible / 1000, 0, 2) || NO_VALUE}
+ }
+ />
+
+
+
+
+
+
+ );
+});
diff --git a/client/src/components/PieCharts/MetricTiles.utils.test.ts b/client/src/components/PieCharts/MetricTiles.utils.test.ts
new file mode 100644
index 00000000..d3b02145
--- /dev/null
+++ b/client/src/components/PieCharts/MetricTiles.utils.test.ts
@@ -0,0 +1,95 @@
+import { DateTime } from 'luxon';
+import { describe, expect, it } from 'vitest';
+import { sumApp } from './MetricTiles.utils';
+
+describe('MetricTiles utils', () => {
+ it('sumApp in range items', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items = [
+ {
+ id: 1,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T17:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T17:10:00.000Z').toMillis(),
+ },
+ {
+ id: 2,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ {
+ id: 3,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T18:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:00:00.000Z').toMillis(),
+ },
+ ];
+ const actual = items.reduce(sumApp(visibleTimerange), 0);
+ expect(actual).toEqual(30 * 60 * 1000);
+ });
+ it('sumApp does not include out of range items', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items = [
+ {
+ id: 1,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T16:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T16:59:59.000Z').toMillis(),
+ },
+ {
+ id: 2,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ {
+ id: 3,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T19:00:01.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:10:00.000Z').toMillis(),
+ },
+ ];
+ const actual = items.reduce(sumApp(visibleTimerange), 0);
+ expect(actual).toEqual(10 * 60 * 1000);
+ });
+
+ it('sumApp uses only in range time', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items = [
+ {
+ id: 1,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T16:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T17:10:00.000Z').toMillis(),
+ },
+ {
+ id: 2,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ {
+ id: 3,
+ app: 'Test',
+ beginDate: DateTime.fromISO('2021-06-19T18:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:10:00.000Z').toMillis(),
+ },
+ ];
+ const actual = items.reduce(sumApp(visibleTimerange), 0);
+ expect(actual).toEqual(30 * 60 * 1000);
+ });
+});
+
+export // Use an empty export to please Babel's single file emit.
+// https://github.com/Microsoft/TypeScript/issues/15230
+ {};
diff --git a/client/src/components/PieCharts/MetricTiles.utils.ts b/client/src/components/PieCharts/MetricTiles.utils.ts
new file mode 100644
index 00000000..2e892d9c
--- /dev/null
+++ b/client/src/components/PieCharts/MetricTiles.utils.ts
@@ -0,0 +1,54 @@
+import { DateTime } from 'luxon';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { convertDate } from '../../constants';
+
+export type SumItem = {
+ app: string;
+ title?: string;
+ timeDiffInMs: number;
+ color?: string;
+};
+
+export const clampRange = (clampToRange: DateTime[], range: DateTime[]) => {
+ const begin = DateTime.max(clampToRange[0], convertDate(range[0]));
+ const end = DateTime.min(clampToRange[1], convertDate(range[1]));
+ return [begin, end];
+};
+
+export const sumAppObject = (visibleTimerange: DateTime[]) => (newItem: { timeDiffInMs: number }, item: ITrackItem) => {
+ const [beginClamp, endClamp] = visibleTimerange;
+
+ const [beginDate, endDate] = clampRange(
+ [beginClamp, endClamp],
+ [convertDate(item.beginDate), convertDate(item.endDate)],
+ );
+
+ const diff = endDate.diff(beginDate).milliseconds;
+ if (diff < 0) {
+ return { ...item, timeDiffInMs: newItem.timeDiffInMs };
+ }
+
+ return { ...item, timeDiffInMs: newItem.timeDiffInMs + diff };
+};
+
+export const sumApp = (visibleTimerange: DateTime[]) => (timeDiffInMs: number, item: ITrackItem) => {
+ const newItem = sumAppObject(visibleTimerange)({ timeDiffInMs }, item);
+ return newItem.timeDiffInMs;
+};
+
+export const getOnlineTime = (items: ITrackItem[], visibleTimerange: DateTime[]) => {
+ const onlineItems = items.filter((item) => item.app === 'ONLINE');
+ return onlineItems.reduce(sumApp(visibleTimerange), 0);
+};
+
+export const getLastOnlineTime = (items: ITrackItem[], visibleTimerange: DateTime[]) => {
+ const onlineItems = items.filter((item) => item.app === 'ONLINE');
+ if (onlineItems.length > 1) {
+ return [onlineItems.reverse()[1]].reduce(sumApp(visibleTimerange), 0);
+ }
+ return;
+};
+
+export const getTasksTime = (items: ITrackItem[], visibleTimerange: DateTime[]) => {
+ return items.reduce(sumApp(visibleTimerange), 0);
+};
diff --git a/client/src/components/PieCharts/PieChart.tsx b/client/src/components/PieCharts/PieChart.tsx
index b4777fcb..bc1a7136 100644
--- a/client/src/components/PieCharts/PieChart.tsx
+++ b/client/src/components/PieCharts/PieChart.tsx
@@ -1,12 +1,10 @@
-import React from 'react';
-
import _ from 'lodash';
-import moment from 'moment';
-import { VictoryContainer, VictoryPie } from 'victory';
+import { VictoryContainer, VictoryPie, VictoryStyleInterface } from 'victory';
import { convertDate } from '../../constants';
-import { PieLabel } from './PieLabel';
-import { colorProp } from '../charts.utils';
import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { formatDurationInternal } from '../../utils';
+import { colorProp } from '../charts.utils';
+import { PieLabel } from './PieLabel';
const sumApp = (p, c) => {
return _.extend(p, {
@@ -21,7 +19,7 @@ export const PieChart = ({ items, taskName, width }) => {
const pieData = _(items)
.groupBy(groupByField)
- .map(b => {
+ .map((b) => {
return b.reduce(sumApp, {
app: b[0].app,
title: b[0].title,
@@ -31,7 +29,7 @@ export const PieChart = ({ items, taskName, width }) => {
})
.valueOf();
- const style: any = {
+ const style: VictoryStyleInterface = {
data: {
fill: colorProp,
stroke: colorProp,
@@ -50,12 +48,9 @@ export const PieChart = ({ items, taskName, width }) => {
containerComponent={ }
style={style}
labels={({ datum }) => {
- const dur = moment.duration(datum.timeDiffInMs);
- const formattedDuration = dur.format();
-
- return `${datum[groupByField]} [${formattedDuration}]`;
+ return `${datum[groupByField]} [${formatDurationInternal(datum.timeDiffInMs)}]`;
}}
- labelComponent={ }
+ labelComponent={ }
x="app"
y="timeDiffInMs"
data={pieData}
diff --git a/client/src/components/PieCharts/PieCharts.styles.ts b/client/src/components/PieCharts/PieCharts.styles.ts
deleted file mode 100644
index 0b4f57d5..00000000
--- a/client/src/components/PieCharts/PieCharts.styles.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import styled from 'styled-components';
-
-export const Heading = styled.h3`
- text-align: center;
- color: rgba(0, 0, 0, 0.65);
-`;
diff --git a/client/src/components/PieCharts/PieCharts.tsx b/client/src/components/PieCharts/PieCharts.tsx
deleted file mode 100644
index 5835316a..00000000
--- a/client/src/components/PieCharts/PieCharts.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Box, Flex } from 'reflexbox';
-import { useWindowWidth } from '@react-hook/window-size/throttled';
-import React, { memo, useContext } from 'react';
-import { RootContext } from '../../RootContext';
-import { filterItems } from '../Timeline/timeline.utils';
-import { PieChart } from './PieChart';
-import { Heading } from './PieCharts.styles';
-import { WorkProgressChart } from './WorkProgressChart';
-import { useStoreState } from '../../store/easyPeasy';
-
-export const PieCharts = memo(() => {
- const timeItems = useStoreState(state => state.timeItems);
- const visibleTimerange = useStoreState(state => state.visibleTimerange);
-
- const { workSettings } = useContext(RootContext);
- const innerWidth = useWindowWidth();
-
- const appItems = filterItems(timeItems.appItems, visibleTimerange);
- const statusItems = filterItems(timeItems.statusItems, visibleTimerange);
- const logItems = filterItems(timeItems.logItems, visibleTimerange);
-
- const pieWidth = innerWidth / 4;
-
- return (
-
-
-
-
-
-
- Tasks
-
-
-
-
-
- Status
-
-
-
-
-
- Progress for {workSettings.hoursToWork}h
-
-
-
-
-
- App usage
-
-
-
- );
-});
diff --git a/client/src/components/PieCharts/PieLabel.tsx b/client/src/components/PieCharts/PieLabel.tsx
index 3b111574..973192b2 100644
--- a/client/src/components/PieCharts/PieLabel.tsx
+++ b/client/src/components/PieCharts/PieLabel.tsx
@@ -1,39 +1,39 @@
-import React from 'react';
+import { PureComponent } from 'react';
import { VictoryTooltip } from 'victory';
+import { gray900 } from '../Timeline/ChartTheme';
interface IProps {
text?: string;
width: number;
+ innerWidth: number;
color?: string;
- datum?: any;
- theme: any;
+ datum?: { beginDate: number; endDate: number; diff: number };
+ theme: { tooltip: { style: { fontSize: number } }; isDark: boolean };
}
-export class PieLabel extends React.PureComponent {
+export class PieLabel extends PureComponent {
public static defaultEvents = VictoryTooltip.defaultEvents;
public render() {
- const { width, datum, theme } = this.props;
-
- const labelWidth = width / 2;
+ const { width, innerWidth, theme } = this.props;
return (
diff --git a/client/src/components/PieCharts/WorkProgressChart.tsx b/client/src/components/PieCharts/WorkProgressChart.tsx
index ffae0b6c..d3f51234 100644
--- a/client/src/components/PieCharts/WorkProgressChart.tsx
+++ b/client/src/components/PieCharts/WorkProgressChart.tsx
@@ -1,12 +1,10 @@
-import React from 'react';
-
import _ from 'lodash';
-import moment from 'moment';
import { VictoryContainer, VictoryPie } from 'victory';
import { convertDate } from '../../constants';
import { PieLabel } from './PieLabel';
import { colorProp } from '../charts.utils';
import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { formatDurationInternal } from '../../utils';
const sumApp = (p, c) => {
return _.extend(p, {
@@ -19,9 +17,9 @@ export const WorkProgressChart = ({ items, width, hoursToWork }) => {
const groupByField = 'app';
const pieData: any[] = _(items)
- .filter(item => item.app === 'ONLINE')
+ .filter((item) => item.app === 'ONLINE')
.groupBy(groupByField)
- .map(b => {
+ .map((b) => {
return b.reduce(sumApp, {
app: 'Worked',
timeDiffInMs: 0,
@@ -31,21 +29,20 @@ export const WorkProgressChart = ({ items, width, hoursToWork }) => {
.valueOf();
if (pieData.length) {
- const workDay = moment.duration(Number(hoursToWork), 'hours');
+ const workDayMs = hoursToWork * 60 * 60 * 1000;
const workedMs = pieData[0].timeDiffInMs;
// pauses must be 10% of worked time
const pausesInHour = 0.1;
- const worked = moment.duration(workedMs);
- const pauses = moment.duration(workedMs * pausesInHour);
+ const pausesMs = workedMs * pausesInHour;
- const undone = workDay.subtract(worked).subtract(pauses);
+ const undoneMs = workDayMs - workedMs - pausesMs;
const undoneItem = {
app: 'Work to be done',
title: 'Work to be done',
- timeDiffInMs: undone.asMilliseconds(),
+ timeDiffInMs: undoneMs,
color: 'lightgray',
};
pieData.push(undoneItem);
@@ -53,7 +50,7 @@ export const WorkProgressChart = ({ items, width, hoursToWork }) => {
const pausesItem = {
app: 'Pauses',
title: 'Pauses',
- timeDiffInMs: pauses.asMilliseconds(),
+ timeDiffInMs: pausesMs,
color: 'gray',
};
pieData.push(pausesItem);
@@ -78,12 +75,9 @@ export const WorkProgressChart = ({ items, width, hoursToWork }) => {
containerComponent={ }
style={style}
labels={({ datum }) => {
- const dur = moment.duration(datum.timeDiffInMs);
- const formattedDuration = dur.format();
-
- return `${datum[groupByField]} [${formattedDuration}]`;
+ return `${datum[groupByField]} [${formatDurationInternal(datum.timeDiffInMs)}]`;
}}
- labelComponent={ }
+ labelComponent={ }
x="app"
y="timeDiffInMs"
data={pieData}
diff --git a/client/src/components/SearchResults/SearchDeleteButtons.tsx b/client/src/components/SearchResults/SearchDeleteButtons.tsx
new file mode 100644
index 00000000..5970f0e1
--- /dev/null
+++ b/client/src/components/SearchResults/SearchDeleteButtons.tsx
@@ -0,0 +1,46 @@
+import { Button, HStack, Text } from '@chakra-ui/react';
+import { Logger } from '../../logger';
+import { deleteByIds } from '../../services/trackItem.api';
+import { TableButtonsProps } from '../TrackItemTable/TrackItemTable.utils';
+
+interface Props extends TableButtonsProps {
+ refreshData: () => void;
+}
+
+export const SearchDeleteButtons: React.FC = (props) => {
+ const { selectedFlatRows, selectedRowIds, refreshData } = props;
+
+ const deleteTimelineItems = async (ids: number[]) => {
+ console.log('Delete timeline items', ids);
+
+ if (ids) {
+ await deleteByIds(ids);
+ Logger.debug('Deleted timeline items', ids);
+ } else {
+ Logger.error('No ids, not deleting from DB');
+ }
+ };
+
+ const deleteSelectedItems = () => {
+ console.log('selectedFlatRows...', selectedFlatRows);
+ deleteTimelineItems(selectedFlatRows.map(({ original }) => original.id)).then(() => {
+ refreshData();
+ });
+ };
+
+ const hasSelected = Object.keys(selectedRowIds).length > 0;
+
+ return (
+
+ {hasSelected && (
+
+ Delete
+
+ {Object.keys(selectedRowIds).length}
+
+ items
+
+ )}
+
+ );
+};
diff --git a/client/src/components/SearchResults/SearchOptions.tsx b/client/src/components/SearchResults/SearchOptions.tsx
index a74b33cb..5e44b6f6 100644
--- a/client/src/components/SearchResults/SearchOptions.tsx
+++ b/client/src/components/SearchResults/SearchOptions.tsx
@@ -1,124 +1,127 @@
-import { Box, Flex } from 'reflexbox';
-import { Button, DatePicker } from 'antd';
-import { LeftOutlined, RightOutlined } from '@ant-design/icons';
-import moment from 'moment';
-import React from 'react';
-
+import { Box, Button, Flex, HStack, Text, useColorModeValue } from '@chakra-ui/react';
+import { OnDatesChangeProps } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+import { useState } from 'react';
+import { AiOutlineLeft, AiOutlineRight } from 'react-icons/ai';
import { Logger } from '../../logger';
+import { DateRangeInput } from '../Datepicker';
-const { RangePicker } = DatePicker;
+const getDayBefore = (d: DateTime) => d.minus({ days: 1 });
+const getDayAfter = (d: DateTime) => d.plus({ days: 1 });
-const getDayBefore = d => moment(d).subtract(1, 'days');
-const getDayAfter = d => moment(d).add(1, 'days');
+enum RANGE_OPTIONS {
+ WEEK,
+ MONTH,
+ MONTHS_3,
+ YEAR,
+ YEARS_3,
+}
export const SearchOptions = ({ setTimerange, timerange }) => {
- const onChange = (dates: any) => {
- Logger.debug('TIMERANGE:', dates);
- if (dates != null) {
- const beginDate = dates[0];
- const endDate = dates[1];
- const newTimerange = [beginDate, endDate];
- setTimerange(newTimerange);
- } else {
- Logger.error('No dates selected');
- }
- };
+ const [localRange, setLocalRange] = useState(RANGE_OPTIONS.WEEK);
- const selectWeek = () => {
- const beginDate = moment()
- .startOf('day')
- .subtract(7, 'days');
- const endDate = moment().endOf('day');
- setTimerange([beginDate, endDate]);
- };
- const selectMonth = () => {
- const beginDate = moment()
- .startOf('day')
- .subtract(1, 'month');
- const endDate = moment().endOf('day');
- setTimerange([beginDate, endDate]);
- };
+ const selectRange = (range) => {
+ let beginDate = DateTime.now().startOf('day');
+ const endDate = DateTime.now().endOf('day');
- const selectMonth3 = () => {
- const beginDate = moment()
- .startOf('day')
- .subtract(3, 'month');
- const endDate = moment().endOf('day');
- setTimerange([beginDate, endDate]);
- };
+ if (range === RANGE_OPTIONS.WEEK) {
+ beginDate = beginDate.minus({ days: 7 });
+ }
- const selectYear = () => {
- const beginDate = moment()
- .startOf('day')
- .subtract(1, 'year');
- const endDate = moment().endOf('day');
- setTimerange([beginDate, endDate]);
- };
+ if (range === RANGE_OPTIONS.MONTH) {
+ beginDate = beginDate.minus({ months: 1 });
+ }
+ if (range === RANGE_OPTIONS.MONTHS_3) {
+ beginDate = beginDate.minus({ months: 3 });
+ }
+ if (range === RANGE_OPTIONS.YEAR) {
+ beginDate = beginDate.minus({ years: 1 });
+ }
+ if (range === RANGE_OPTIONS.YEARS_3) {
+ beginDate = beginDate.minus({ years: 3 });
+ }
- const selectYear3 = () => {
- const beginDate = moment()
- .startOf('day')
- .subtract(3, 'year');
- const endDate = moment().endOf('day');
setTimerange([beginDate, endDate]);
+
+ setLocalRange(range);
};
const goBackOneDay = () => {
- const beginDate = getDayBefore(moment(timerange[0]));
- const endDate = getDayBefore(moment(timerange[1]));
+ const beginDate = getDayBefore(timerange[0]);
+ const endDate = getDayBefore(timerange[1]);
setTimerange([beginDate, endDate]);
};
const goForwardOneDay = () => {
- const beginDate = getDayAfter(moment(timerange[0]));
- const endDate = getDayAfter(moment(timerange[1]));
+ const beginDate = getDayAfter(timerange[0]);
+ const endDate = getDayAfter(timerange[1]);
setTimerange([beginDate, endDate]);
};
- Logger.debug('Have timerange in Search:', timerange);
+ const handleOnDatesChange = (data: OnDatesChangeProps) => {
+ Logger.debug('TIMERANGE:', data);
+
+ const { startDate, endDate } = data;
+ if (!startDate || !endDate) {
+ return;
+ }
+ const newTimerange = [DateTime.fromJSDate(startDate).startOf('day'), DateTime.fromJSDate(endDate).endOf('day')];
+
+ setTimerange(newTimerange);
+ };
+
+ const selectVariant = (range) => (localRange === range ? 'solid' : 'outline');
return (
-
-
-
-
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
-
+
-
-
+
+
+ Select range
+
+
+ selectRange(RANGE_OPTIONS.WEEK)} variant={selectVariant(RANGE_OPTIONS.WEEK)}>
Week
-
-
-
+
+ selectRange(RANGE_OPTIONS.MONTH)} variant={selectVariant(RANGE_OPTIONS.MONTH)}>
Month
-
-
-
+
+ selectRange(RANGE_OPTIONS.MONTHS_3)}
+ variant={selectVariant(RANGE_OPTIONS.MONTHS_3)}
+ >
3 Months
-
-
-
+
+ selectRange(RANGE_OPTIONS.YEAR)} variant={selectVariant(RANGE_OPTIONS.YEAR)}>
Year
-
-
-
+
+ selectRange(RANGE_OPTIONS.YEARS_3)}
+ variant={selectVariant(RANGE_OPTIONS.YEARS_3)}
+ >
3 Years
-
+
);
};
diff --git a/client/src/components/SearchResults/SearchResults.styles.ts b/client/src/components/SearchResults/SearchResults.styles.ts
deleted file mode 100644
index 30fd2d4f..00000000
--- a/client/src/components/SearchResults/SearchResults.styles.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Box, Flex } from 'reflexbox';
-import styled from 'styled-components';
-
-export const FilterDropdown = styled(Flex).attrs({ p: 2 })`
- border-radius: 6px;
- background: #fff;
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
-`;
-
-export const FilterInput = styled(Box).attrs({ mr: 1 })`
- width: 130px;
-`;
-export const Highlight = styled.span`
- color: #f50;
-`;
-
-export const TotalCount = styled.div`
- text-align: right;
- padding-right: 10px;
-`;
diff --git a/client/src/components/SearchResults/SearchResults.tsx b/client/src/components/SearchResults/SearchResults.tsx
index 66edbb58..ff3859c4 100644
--- a/client/src/components/SearchResults/SearchResults.tsx
+++ b/client/src/components/SearchResults/SearchResults.tsx
@@ -1,182 +1,87 @@
-import { Table, Tooltip } from 'antd';
-import { UnorderedListOutlined } from '@ant-design/icons';
-import useReactRouter from 'use-react-router';
-import { sumBy } from 'lodash';
-import moment from 'moment';
-import React, { useState, useRef, useEffect, memo } from 'react';
-import Moment from 'react-moment';
-import { convertDate, DATE_TIME_FORMAT, TIME_FORMAT } from '../../constants';
-import { diffAndFormatShort } from '../../utils';
-import { TotalCount } from './SearchResults.styles';
-import { Logger } from '../../logger';
+import { IconButton, Tooltip } from '@chakra-ui/react';
+import { ColumnDef } from '@tanstack/react-table';
+import { DateTime } from 'luxon';
+import { memo } from 'react';
+import { AiOutlineUnorderedList } from 'react-icons/ai';
+import { useNavigate } from 'react-router-dom';
+
+import { ITrackItem } from '../../@types/ITrackItem';
+import { SearchResultI } from '../../services/trackItem.api';
import { useStoreActions } from '../../store/easyPeasy';
+import { ItemsTable } from '../TrackItemTable/ItemsTable';
+import { defaultTableButtonsProps } from '../TrackItemTable/TrackItemTable.utils';
+import { SearchDeleteButtons } from './SearchDeleteButtons';
-const calculateTotal = filteredData => {
- const totalMs = sumBy(filteredData, (c: any) =>
- convertDate(c.endDate).diff(convertDate(c.beginDate)),
- );
- const dur = moment.duration(totalMs);
-
- return Total {dur.format()} ;
-};
+const ActionCell = ({ cell }) => {
+ const { beginDate, endDate } = cell.row.original;
-const SearchResultsPlain = ({ searchResult, searchPaging, setSearchPaging }) => {
- const loadTimerange = useStoreActions(state => state.loadTimerange);
- const setVisibleTimerange = useStoreActions(state => state.setVisibleTimerange);
-
- const { history } = useReactRouter();
-
- const paginationConf = {
- total: searchResult.total,
- showSizeChanger: true,
- pageSize: searchPaging.pageSize,
- current: searchPaging.page,
- pageSizeOptions: ['50', '100', '300', '500'],
- onChange: (page, pageSize) => {
- console.error('Pagination changed', page, pageSize);
- // setSearchPaging({ page, pageSize });
- },
- };
- const [state, setState] = useState({
- filteredInfo: {},
- sortedInfo: {},
- filterDropdownVisible: false,
- searchText: '',
- filtered: false,
- selectedRowKeys: [],
- });
+ const loadTimerange = useStoreActions((state) => state.loadTimerange);
+ const setVisibleTimerange = useStoreActions((state) => state.setVisibleTimerange);
- const searchInput = useRef();
-
- useEffect(() => {
- if (searchInput.current) {
- searchInput.current.focus();
- }
- }, [state.filterDropdownVisible]);
-
- const handleChange = (pagination: any, filters: any, sorter: any) => {
- Logger.debug('Various parameters', pagination, filters, sorter);
- setSearchPaging({ page: pagination.current, pageSize: pagination.pageSize });
- setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
- };
-
- const onSelectChange = selectedRowKeys => {
- Logger.debug('selectedRowKeys changed: ', selectedRowKeys);
- setState({ ...state, selectedRowKeys });
- };
+ const navigate = useNavigate();
- const goToTimelinePage = record => {
- loadTimerange([
- moment(record.beginDate).startOf('day'),
- moment(record.beginDate).endOf('day'),
- ]);
+ const goToTimelinePage = (record) => {
+ const beginDateTime = DateTime.fromMillis(record.beginDate);
+ loadTimerange([beginDateTime.startOf('day'), beginDateTime.endOf('day')]);
setVisibleTimerange([
- moment(record.beginDate).subtract(15, 'minutes'),
- moment(record.endDate).add(15, 'minutes'),
+ DateTime.fromMillis(record.beginDate).minus({ minutes: 15 }),
+ DateTime.fromMillis(record.endDate).plus({ minutes: 15 }),
]);
- history.push('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/app/timeline');
+ navigate('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/app/timeline');
};
- const { isOneDay, sortedInfo } = state;
-
- const columns = [
- {
- title: 'App',
- dataIndex: 'app',
- key: 'app',
- width: 200,
- sorter: (a: any, b: any) => a.app - b.app,
- sortOrder: sortedInfo.columnKey === 'app' && sortedInfo.order,
- },
- {
- title: 'Title',
- dataIndex: 'title',
- key: 'title',
-
- sorter: (a: any, b: any) => a.title.length - b.title.length,
- sortOrder: sortedInfo.columnKey === 'title' && sortedInfo.order,
- },
- {
- title: 'URL',
- dataIndex: 'url',
- key: 'url',
- width: 300,
- ellipsis: true,
- sorter: (a: any, b: any) => a.url.length - b.url.length,
- sortOrder: sortedInfo.columnKey === 'url' && sortedInfo.order,
- },
- {
- title: 'Begin',
- dataIndex: 'beginDate',
- key: 'beginDate',
- width: 170,
- onFilter: (value: any, record: any) => convertDate(record.beginDate) > value,
- sorter: (a: any, b: any) =>
- convertDate(a.beginDate).valueOf() - convertDate(b.beginDate).valueOf(),
- sortOrder: sortedInfo.columnKey === 'beginDate' && sortedInfo.order,
- render: (text, record) => (
-
- {record.beginDate}
-
- ),
- },
- {
- title: 'End',
- dataIndex: 'endDate',
- key: 'endDate',
- width: 170,
- onFilter: (value: any, record: any) => convertDate(record.endDate) > value,
- sorter: (a: any, b: any) =>
- convertDate(a.endDate).valueOf() - convertDate(b.endDate).valueOf(),
- sortOrder: sortedInfo.columnKey === 'endDate' && sortedInfo.order,
-
- render: (text, record) => (
- {record.endDate}
- ),
- },
-
- {
- title: 'Dur',
- dataIndex: '',
- key: 'duration',
- width: 80,
- sorter: (a: any, b: any) =>
- convertDate(a.endDate).diff(convertDate(a.beginDate)) -
- convertDate(b.endDate).diff(convertDate(b.beginDate)),
- sortOrder: sortedInfo.columnKey === 'duration' && sortedInfo.order,
-
- render: (text, record) => (
- {diffAndFormatShort(record.beginDate, record.endDate)}
- ),
- },
- {
- title: '',
- dataIndex: '',
- key: 'actions',
- width: 40,
- render: (text, record) => (
-
- goToTimelinePage(record)} />
-
- ),
- },
- ];
-
- const { selectedRowKeys } = state;
- const rowSelection = {
- selectedRowKeys,
- onChange: onSelectChange,
- };
+ return (
+
+ }
+ onClick={() => goToTimelinePage({ beginDate, endDate })}
+ />
+
+ );
+};
+const extraColumns: ColumnDef[] = [
+ {
+ id: 'actions',
+ header: '',
+ enableColumnFilter: false,
+ cell: ActionCell,
+ size: 20,
+ minSize: 20,
+ maxSize: 20,
+ },
+];
+
+interface SearchResultsProps {
+ searchResult: SearchResultI;
+ fetchData: (params: { pageSize: number; pageIndex: number; sortBy: { id: string; desc: boolean }[] }) => void;
+ pageIndex: number;
+ resetButtonsRef: React.RefObject;
+ refreshData: () => void;
+}
+
+const SearchResultsPlain = ({
+ searchResult,
+ fetchData,
+ pageIndex,
+ resetButtonsRef,
+ refreshData,
+}: SearchResultsProps) => {
return (
- `${record.id}`}
- columns={columns}
- pagination={paginationConf}
- dataSource={searchResult.results}
- onChange={handleChange}
- footer={calculateTotal}
+ }
/>
);
};
diff --git a/client/src/components/Settings/AnalyserForm.tsx b/client/src/components/Settings/AnalyserForm.tsx
index af6d13ad..6ed5b9d7 100644
--- a/client/src/components/Settings/AnalyserForm.tsx
+++ b/client/src/components/Settings/AnalyserForm.tsx
@@ -1,38 +1,52 @@
-import { Flex } from 'reflexbox';
-import { Button, Card, Tooltip } from 'antd';
-import { InfoCircleOutlined, PlusOutlined } from '@ant-design/icons';
-import React, { useState, useEffect } from 'react';
-import { AnalyserFormItem } from './AnalyserFormItem';
-import { fetchAnalyserSettings, saveAnalyserSettings } from '../../services/settings.api';
+import { Box, Button, Flex, FormControl, FormLabel, HStack, Switch, Text, Tooltip } from '@chakra-ui/react';
+import { useEffect, useState } from 'react';
+import { AiOutlineInfoCircle } from 'react-icons/ai';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { TrackItemType } from '../../enum/TrackItemType';
+import {
+ fetchAnalyserSettings,
+ getTaskAnalyserEnabled,
+ saveAnalyserSettings,
+ setTaskAnalyserEnabled,
+} from '../../services/settings.api';
import { useStoreState } from '../../store/easyPeasy';
+import { CardBox } from '../CardBox';
+import { AnalyserItem } from './AnalyserForm.util';
+import { AnalyserFormItem } from './AnalyserFormItem';
-const defaultAnalyserSettings = [
+const defaultAnalyserSettings: AnalyserItem[] = [
{ findRe: '\\w+-\\d+.*JIRA', takeTitle: '', takeGroup: '\\w+-\\d+', enabled: true },
{ findRe: '9GAG', takeTitle: '', takeGroup: '9GAG', enabled: true },
];
-const emptyItem = { findRe: '', takeTitle: '', takeGroup: '', enabled: false };
+const emptyItem: AnalyserItem = { findRe: '', takeTitle: '', takeGroup: '', enabled: false };
+
+const EMPTY_APP_ITEMS: ITrackItem[] = [];
export const AnalyserForm = () => {
- const timeItems = useStoreState(state => state.timeItems);
- const { appItems } = timeItems;
- const [analyserItems, setAnalyserItems] = useState([]);
+ const timeItems = useStoreState((state) => state.timeItems);
+ const appItems = timeItems[TrackItemType.AppTrackItem];
+ const [analyserItems, setAnalyserItems] = useState([]);
+ const [isAnalyserEnabled, setIsAnalyserEnabled] = useState(false);
useEffect(() => {
async function fetchSettings() {
const items = await fetchAnalyserSettings();
setAnalyserItems(items || []);
+
+ const enabled = await getTaskAnalyserEnabled();
+ setIsAnalyserEnabled(enabled);
}
fetchSettings();
}, []);
- const removeItem = index => () => {
+ const removeItem = (index: number) => () => {
analyserItems.splice(index, 1);
setAnalyserItems([...analyserItems]);
saveAnalyserSettings([...analyserItems]);
};
- const saveItem = index => data => {
+ const saveItem = (index: number) => (data: AnalyserItem) => {
analyserItems[index] = data;
setAnalyserItems([...analyserItems]);
saveAnalyserSettings([...analyserItems]);
@@ -44,33 +58,84 @@ export const AnalyserForm = () => {
const setDefaults = () => {
setAnalyserItems([...analyserItems, ...defaultAnalyserSettings]);
+ saveAnalyserSettings([...analyserItems, ...defaultAnalyserSettings]);
+ };
+
+ const handleAnalyserToggle = async (e) => {
+ const enabled = e.target.checked;
+ setIsAnalyserEnabled(enabled);
+ await setTaskAnalyserEnabled(enabled);
};
return (
-
-
-
+ isAnalyserEnabled && (
+
+
+ Add sample values
+
+
+
+
+
+
+
+
+ )
}
>
- {analyserItems.map((item, index) => (
-
- ))}
-
- } onClick={addItem} />
+
+
+
+ Enable task analyser
+
+
+ {isAnalyserEnabled ? (
+
+ When enabled, the task analyser will automatically suggest tasks based on your window
+ activity
+
+ ) : (
+
+ Enable to automatically suggest tasks based on your window activity
+
+ )}
+
-
- Add sample values
-
-
+ {isAnalyserEnabled && (
+ <>
+
+
+ Note: Please ensure that notifications are allowed in your system settings, as they may not
+ appear otherwise.
+
+
+ {analyserItems.map((item, index) => (
+
+ ))}
+
+
+ Add New Item
+
+
+ >
+ )}
+
);
};
diff --git a/client/src/components/Settings/AnalyserForm.util.test.ts b/client/src/components/Settings/AnalyserForm.util.test.ts
new file mode 100644
index 00000000..40a5e920
--- /dev/null
+++ b/client/src/components/Settings/AnalyserForm.util.test.ts
@@ -0,0 +1,232 @@
+import { describe, expect, it } from 'vitest';
+import type { ITrackItem } from '../../@types/ITrackItem';
+import type { AnalyserItem, AnalyserTestItemI } from './AnalyserForm.util';
+import { findFirst, testAnalyserItem } from './AnalyserForm.util';
+
+describe('findFirst', () => {
+ it('should return undefined when findRe is empty', () => {
+ expect(findFirst('test string', '')).toBeUndefined();
+ });
+
+ it('should return the first match when pattern is found', () => {
+ expect(findFirst('JIRA-123 Some task', '\\w+-\\d+')).toBe('JIRA-123');
+ });
+
+ it('should return undefined when pattern is not found', () => {
+ expect(findFirst('No match here', '\\d{3}')).toBeUndefined();
+ });
+
+ it('should handle special characters in regex', () => {
+ expect(findFirst('(test) [group]', '\\(.*?\\)')).toBe('(test)');
+ });
+
+ it('should handle invalid regex patterns gracefully', () => {
+ // Unterminated group
+ expect(findFirst('test string', '(abc')).toBeUndefined();
+ // Unmatched closing parenthesis
+ expect(findFirst('test string', 'abc)')).toBeUndefined();
+ // Invalid character class
+ expect(findFirst('test string', '[abc')).toBeUndefined();
+ // Invalid quantifier
+ expect(findFirst('test string', 'abc{2,1}')).toBeUndefined();
+ });
+});
+
+describe('testAnalyserItem', () => {
+ const mockAnalyserSetting: AnalyserItem = {
+ findRe: '\\w+-\\d+',
+ takeTitle: 'Task:\\s*(.*)',
+ takeGroup: '\\w+-\\d+',
+ enabled: true,
+ };
+
+ it('should return empty array when appItems is null or undefined', () => {
+ expect(testAnalyserItem(undefined!, mockAnalyserSetting)).toEqual([]);
+ });
+
+ it('should handle invalid regex patterns in analyser settings', () => {
+ const invalidPatterns: AnalyserItem[] = [
+ {
+ // Unterminated group
+ findRe: '(abc',
+ takeTitle: 'Task:\\s*(.*)',
+ takeGroup: '\\w+-\\d+',
+ enabled: true,
+ },
+ {
+ // Valid findRe but invalid takeTitle
+ findRe: '\\w+-\\d+',
+ takeTitle: '(Task:.*',
+ takeGroup: '\\w+-\\d+',
+ enabled: true,
+ },
+ {
+ // Valid findRe but invalid takeGroup
+ findRe: '\\w+-\\d+',
+ takeTitle: 'Task:\\s*(.*)',
+ takeGroup: '[group',
+ enabled: true,
+ },
+ ];
+
+ const mockAppItems: ITrackItem[] = [
+ {
+ id: 1,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ ];
+ const firstPattern = invalidPatterns[0];
+ const result = testAnalyserItem(mockAppItems, firstPattern);
+ expect(result).toEqual([]);
+
+ const secondPattern = invalidPatterns[1];
+ const result2 = testAnalyserItem(mockAppItems, secondPattern);
+ expect(result2).toEqual([
+ {
+ findRe: 'JIRA-123',
+ takeGroup: 'JIRA-123',
+ takeTitle: 'JIRA-123 Task: Fix bug',
+ title: 'JIRA-123 Task: Fix bug',
+ },
+ ]);
+
+ const thirdPattern = invalidPatterns[2];
+ const result3 = testAnalyserItem(mockAppItems, thirdPattern);
+ expect(result3).toEqual([
+ {
+ findRe: 'JIRA-123',
+ takeGroup: 'JIRA-123',
+ takeTitle: 'Task: Fix bug',
+ title: 'JIRA-123 Task: Fix bug',
+ },
+ ]);
+ });
+
+ it('should process app items and return matching test items', () => {
+ const mockAppItems: ITrackItem[] = [
+ {
+ id: 1,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 2,
+ title: 'JIRA-456 Task: Add feature',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 3,
+ title: 'No match here',
+ app: 'Other',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ ];
+
+ const expected: AnalyserTestItemI[] = [
+ {
+ title: 'JIRA-123 Task: Fix bug',
+ findRe: 'JIRA-123',
+ takeGroup: 'JIRA-123',
+ takeTitle: 'Task: Fix bug',
+ },
+ {
+ title: 'JIRA-456 Task: Add feature',
+ findRe: 'JIRA-456',
+ takeGroup: 'JIRA-456',
+ takeTitle: 'Task: Add feature',
+ },
+ ];
+
+ const result = testAnalyserItem(mockAppItems, mockAnalyserSetting);
+ expect(result).toEqual(expected);
+ });
+
+ it('should remove duplicate items based on title', () => {
+ const mockAppItems: ITrackItem[] = [
+ {
+ id: 1,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 2,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 3,
+ title: 'JIRA-456 Task: Add feature',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ ];
+
+ const result = testAnalyserItem(mockAppItems, mockAnalyserSetting);
+ expect(result.length).toBe(2); // Should only have 2 unique items
+ expect(result.filter((item) => item.findRe === 'JIRA-123')).toHaveLength(1);
+ });
+
+ it('should handle empty or invalid regex patterns', () => {
+ const invalidAnalyserSetting: AnalyserItem = {
+ findRe: '',
+ takeTitle: '',
+ takeGroup: '',
+ enabled: true,
+ };
+
+ const mockAppItems: ITrackItem[] = [
+ {
+ id: 1,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ ];
+
+ const result = testAnalyserItem(mockAppItems, invalidAnalyserSetting);
+ expect(result).toEqual([]);
+ });
+
+ it('should handle missing title in app items', () => {
+ const mockAppItems: ITrackItem[] = [
+ {
+ id: 1,
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 2,
+ title: '',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ {
+ id: 3,
+ title: 'JIRA-123 Task: Fix bug',
+ app: 'JIRA',
+ beginDate: Date.now(),
+ endDate: Date.now(),
+ },
+ ];
+
+ const result = testAnalyserItem(mockAppItems, mockAnalyserSetting);
+ expect(result.length).toBe(1);
+ expect(result[0].title).toBe('JIRA-123 Task: Fix bug');
+ });
+});
diff --git a/client/src/components/Settings/AnalyserForm.util.ts b/client/src/components/Settings/AnalyserForm.util.ts
index ff6cbe97..6d515c16 100644
--- a/client/src/components/Settings/AnalyserForm.util.ts
+++ b/client/src/components/Settings/AnalyserForm.util.ts
@@ -1,33 +1,36 @@
-import _ from 'lodash';
+import { uniqBy } from 'lodash';
+import { ITrackItem } from '../../@types/ITrackItem';
import { Logger } from '../../logger';
-export const findFirst = (str, findRe): any => {
+export const findFirst = (str: string, findRe: string): string | undefined => {
if (!findRe) {
- return;
+ return undefined;
}
+ try {
+ const re: RegExp = new RegExp(findRe, 'g');
+ const result: RegExpExecArray | null = re.exec(str);
- const re = new RegExp(findRe, 'g');
- const result = re.exec(str);
-
- if (result != null) {
- const first = result[0];
-
- return first;
+ if (result != null) {
+ const first: string = result[0];
+ return first;
+ }
+ } catch (error) {
+ Logger.error('Error in findFirst', error);
}
- return;
+ return undefined;
};
-export const testAnalyserItem = (appItems, analyseSetting) => {
+export const testAnalyserItem = (appItems: ITrackItem[], analyseSetting: AnalyserItem): Array => {
if (!appItems) {
Logger.error('appItems not loaded');
return [];
}
- const testItems: any = [];
+ const testItems: AnalyserTestItemI[] = [];
- appItems.forEach(item => {
- const testItem = { ...item };
- const str = testItem.title;
+ appItems.forEach((item) => {
+ const testItem: AnalyserTestItemI = { title: item.title || '' };
+ const str: string = testItem.title;
testItem.findRe = findFirst(str, analyseSetting.findRe);
testItem.takeGroup = findFirst(str, analyseSetting.takeGroup) || testItem.findRe;
@@ -38,7 +41,20 @@ export const testAnalyserItem = (appItems, analyseSetting) => {
}
});
- const analyserTestItems = _.uniqBy(testItems, 'title');
+ const analyserTestItems = uniqBy(testItems, 'title');
return analyserTestItems;
};
+
+export interface AnalyserItem {
+ findRe: string;
+ takeTitle: string;
+ takeGroup: string;
+ enabled: boolean;
+}
+export interface AnalyserTestItemI {
+ title: string;
+ findRe?: string;
+ takeTitle?: string;
+ takeGroup?: string;
+}
diff --git a/client/src/components/Settings/AnalyserFormItem.tsx b/client/src/components/Settings/AnalyserFormItem.tsx
index 1b8832cb..a04203c4 100644
--- a/client/src/components/Settings/AnalyserFormItem.tsx
+++ b/client/src/components/Settings/AnalyserFormItem.tsx
@@ -1,11 +1,27 @@
-import { Box, Flex } from 'reflexbox';
-import { Button, Divider, Form, Input, Switch } from 'antd';
-import { DeleteOutlined } from '@ant-design/icons';
-import React, { useState } from 'react';
-import { useFormState } from 'react-use-form-state';
-import { testAnalyserItem } from './AnalyserForm.util';
-
-const AnalyserTestItem = ({ item }) => (
+import {
+ Box,
+ Flex,
+ FormControl,
+ FormErrorMessage,
+ FormLabel,
+ HStack,
+ IconButton,
+ Input,
+ Switch,
+ Text,
+} from '@chakra-ui/react';
+import { useEffect, useId, useState } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { AiOutlineDelete } from 'react-icons/ai';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { BlackBox } from '../BlackBox';
+import { AnalyserItem, AnalyserTestItemI, testAnalyserItem } from './AnalyserForm.util';
+
+interface AnalyserTestItemProps {
+ item: AnalyserTestItemI;
+}
+
+const AnalyserTestItem = ({ item }: AnalyserTestItemProps) => (
{item.title}
@@ -18,63 +34,167 @@ const AnalyserTestItem = ({ item }) => (
);
-export const AnalyserFormItem = ({ analyserItem, removeItem, appItems, saveItem }) => {
+type Inputs = {
+ findRe: string;
+ takeGroup: string;
+ takeTitle: string;
+ enabled: boolean;
+};
+
+interface AnalyserFormItemProps {
+ analyserItem: AnalyserItem;
+ removeItem: () => void;
+ appItems: ITrackItem[];
+ saveItem: (data: Inputs) => void;
+}
+
+const validateRegex = (value: string) => {
+ if (!value) return true; // Empty is valid
+ try {
+ new RegExp(value);
+ return true;
+ } catch (e: unknown) {
+ if (e instanceof Error) {
+ return `Invalid regular expression pattern: ${e.message}`;
+ }
+ return 'Invalid regular expression pattern';
+ }
+};
+
+export const AnalyserFormItem = ({ analyserItem, removeItem, appItems, saveItem }: AnalyserFormItemProps) => {
+ // Generate unique IDs for this component instance
+ const id = useId();
+ const enabledSwitchId = `enabled-switch-${id}`;
+ const testSwitchId = `test-switch-${id}`;
+
const [showTests, setShowTests] = useState(false);
const toggleShowTests = () => {
setShowTests(!showTests);
};
- const [, { text, raw }] = useFormState(analyserItem, {
- onChange: (__ignore, ___ignore, nextStateValues) => {
- saveItem(nextStateValues);
- },
+ const {
+ watch,
+ getValues,
+ reset,
+ register,
+ control,
+ formState: { isDirty, isValid, errors },
+ } = useForm({
+ mode: 'onChange',
+ defaultValues: analyserItem,
+ reValidateMode: 'onChange',
});
- const check = raw('enabled');
+ const watchAllFields = watch();
+
+ useEffect(() => {
+ if (isDirty && isValid) {
+ console.info('Save values,', getValues(), analyserItem);
+ saveItem(getValues());
+ reset(getValues());
+ }
+ }, [getValues, saveItem, watchAllFields, isDirty, analyserItem, reset, isValid]);
+
+ const analysedItems = (showTests && testAnalyserItem(appItems, analyserItem)) || [];
return (
-
+
);
};
diff --git a/client/src/components/Settings/AppForm.tsx b/client/src/components/Settings/AppForm.tsx
index 41c4eb4b..36bc65da 100644
--- a/client/src/components/Settings/AppForm.tsx
+++ b/client/src/components/Settings/AppForm.tsx
@@ -1,62 +1,131 @@
-import { Card, Form, Switch, Typography } from 'antd';
-import React from 'react';
+import { FormControl, FormLabel, Switch, Text } from '@chakra-ui/react';
+import { ChangeEvent } from 'react';
+
import {
- getOpenAtLogin,
getIsAutoUpdateEnabled,
- saveOpenAtLogin,
- saveIsAutoUpdateEnabled,
getIsLoggingEnabled,
+ getMacAutoHideMenuBarEnabled,
+ getNativeThemeChange,
+ getOpenAtLogin,
+ getUsePurpleTrayIcon,
+ saveIsAutoUpdateEnabled,
saveIsLoggingEnabled,
+ saveMacAutoHideMenuBarEnabled,
+ saveNativeThemeChange,
+ saveOpenAtLogin,
+ saveUsePurpleTrayIcon,
} from '../../services/settings.api';
-
-const { Text } = Typography;
+import '../../types/electron-bridge';
+import { CardBox } from '../CardBox';
export const AppForm = () => {
+ const isNativeThemeEnabled = getNativeThemeChange();
const openAtLogin = getOpenAtLogin();
const isAutoUpdateEnabled = getIsAutoUpdateEnabled();
const isLoggingEnabled = getIsLoggingEnabled();
- const onChangeOpenAtLogin = value => {
- saveOpenAtLogin(value);
+ const usePurpleTrayIcon = getUsePurpleTrayIcon();
+ const macAutoHideMenuBarEnabled = getMacAutoHideMenuBarEnabled();
+ const onChangeNativeThemeChange = (event: ChangeEvent) => {
+ saveNativeThemeChange(event.target.checked);
+ };
+ const onChangeOpenAtLogin = (event: ChangeEvent) => {
+ saveOpenAtLogin(event.target.checked);
};
- const onChangeAutoUpdate = value => {
- saveIsAutoUpdateEnabled(value);
+ const onChangeAutoUpdate = (event: ChangeEvent) => {
+ saveIsAutoUpdateEnabled(event.target.checked);
+ };
+ const onChangeLogging = (event: ChangeEvent) => {
+ saveIsLoggingEnabled(event.target.checked);
+ };
+ const onChangeUsePurpleTrayIcon = (event: ChangeEvent) => {
+ saveUsePurpleTrayIcon(event.target.checked);
};
- const onChangeLogging = value => {
- saveIsLoggingEnabled(value);
+ const onChangeMacAutoHideMenuBarEnabled = (event: ChangeEvent) => {
+ saveMacAutoHideMenuBarEnabled(event.target.checked);
};
- const appName = process.env.REACT_APP_NAME;
- const platform = (window as any).platform;
+ const appName = import.meta.env.VITE_NAME;
+ const platform = window.electronBridge.platform;
const linuxPath = `~/.config/${appName}/logs/main.log`;
const macOSPath = `~/Library/Logs/${appName}/main.log`;
const windowsPath = `%USERPROFILE%\\AppData\\Roaming\${appName}\\logs\\main.log`;
let logPath = linuxPath;
+
+ const isMacOS = platform === 'darwin';
if (platform === 'win32') {
logPath = windowsPath;
- } else if (platform === 'darwin') {
+ } else if (isMacOS) {
logPath = macOSPath;
}
return (
-
-
-
- {' '} Run at login
-
-
- {' '}
- Auto update
-
-
- {' '}
- Enable logging (Applies after restart)
-
- Log path: {logPath}
-
-
-
+
+
+
+ Use OS theme?
+
+
+
+
+
+ Run at login?
+
+
+
+
+
+ Auto update?
+
+
+
+
+
+
+ Use purple tray icon?
+
+
+
+
+
+
+ Enable logging? (Applies after restart)
+
+
+
+
+ Log path: {logPath}
+
+ {window.electronBridge.platform === 'darwin' && (
+ <>
+
+
+ Enable tray positioning for auto-hide menu bar
+
+
+
+
+ Enable this if you use "Automatically hide and show the menu bar" in macOS settings
+
+ >
+ )}
+
);
};
diff --git a/client/src/components/Settings/AppVersion.tsx b/client/src/components/Settings/AppVersion.tsx
new file mode 100644
index 00000000..e107ef9c
--- /dev/null
+++ b/client/src/components/Settings/AppVersion.tsx
@@ -0,0 +1,30 @@
+import { Box, Text } from '@chakra-ui/react';
+import { useEffect, useState } from 'react';
+import '../../types/electron-bridge';
+
+export const AppVersion = () => {
+ const [appVersion, setAppVersion] = useState('-');
+
+ useEffect(() => {
+ const fetchVersion = async () => {
+ try {
+ if (window?.electronBridge?.appVersion) {
+ const version = await window.electronBridge.appVersion();
+ setAppVersion(version);
+ }
+ } catch (error) {
+ console.error('Error fetching app version:', error);
+ }
+ };
+
+ fetchVersion();
+ }, []);
+
+ return (
+
+
+ Version: {appVersion}
+
+
+ );
+};
diff --git a/client/src/components/Settings/DataForm.tsx b/client/src/components/Settings/DataForm.tsx
new file mode 100644
index 00000000..cd504fa6
--- /dev/null
+++ b/client/src/components/Settings/DataForm.tsx
@@ -0,0 +1,78 @@
+import { Box, FormControl, FormErrorMessage, FormLabel, Input, Text } from '@chakra-ui/react';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { useContext, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { useDebouncedCallback } from 'use-debounce';
+import * as yup from 'yup';
+import { RootContext } from '../../RootContext';
+import { CardBox } from '../CardBox';
+import { DataSettingsI } from './DataForm.util';
+
+const schema = yup
+ .object({
+ idleAfterSeconds: yup.string().required().label('Idle after in Seconds'),
+ backgroundJobInterval: yup.number().required().positive().integer().label('Session Length'),
+ })
+ .required();
+
+export const DataForm = () => {
+ const { dataSettings, updateDataSettings } = useContext(RootContext);
+
+ const {
+ watch,
+ reset,
+ formState: { errors, isDirty, isValid },
+ register,
+ } = useForm({ mode: 'onChange', defaultValues: dataSettings, resolver: yupResolver(schema) });
+
+ const watchAllFields = watch();
+
+ const debouncedUpdate = useDebouncedCallback(
+ (value) => {
+ console.info('Save values: ', value);
+ updateDataSettings(value);
+ },
+ 1000,
+ { leading: false, trailing: true },
+ );
+
+ useEffect(() => {
+ if (isDirty && isValid) {
+ const formValues = watchAllFields;
+ debouncedUpdate(formValues);
+ reset(formValues);
+ } else if (isDirty) {
+ debouncedUpdate.cancel();
+ }
+ }, [watchAllFields, isDirty, isValid, debouncedUpdate, reset]);
+
+ return (
+
+
+ Background job interval (in seconds)
+
+
+ {errors.backgroundJobInterval && errors.backgroundJobInterval.message}
+
+
+
+
+ Background job runs every 3 seconds by default. In there application checks active window title and
+ the state of system (idle, offline, online)
+
+
+
+
+ Idle after (in seconds)
+
+ {errors.idleAfterSeconds && errors.idleAfterSeconds.message}
+
+
+
+
+ Time when system adds "Idle" item to timeline.
+
+
+
+ );
+};
diff --git a/client/src/components/Settings/DataForm.util.tsx b/client/src/components/Settings/DataForm.util.tsx
new file mode 100644
index 00000000..126b5440
--- /dev/null
+++ b/client/src/components/Settings/DataForm.util.tsx
@@ -0,0 +1,5 @@
+export type DataSettingsI = {
+ idleAfterSeconds: number;
+ backgroundJobInterval: number;
+ recentDaysCount: number;
+};
diff --git a/client/src/components/Settings/LocaleSelect.tsx b/client/src/components/Settings/LocaleSelect.tsx
deleted file mode 100644
index 05cca9b4..00000000
--- a/client/src/components/Settings/LocaleSelect.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Select } from 'antd';
-import React from 'react';
-import moment from 'moment';
-import { useAppDataState } from '../../routes/AppDataProvider';
-import etEe from 'antd/es/locale/et_EE';
-import { Logger } from '../../logger';
-
-const { Option } = Select;
-
-export const LocaleSelect = () => {
- const localeList = moment.locales();
- const defaultLocale = moment.locale();
-
- const state: any = useAppDataState();
-
- function handleChange(value) {
- Logger.debug(`Setting locale ${value}`);
- moment.locale(value);
- state.setLocale(etEe);
- }
-
- return (
-
- {localeList.map(locale => (
-
- {locale}
-
- ))}
-
- );
-};
diff --git a/client/src/components/Settings/SettingsForm.styles.ts b/client/src/components/Settings/SettingsForm.styles.ts
deleted file mode 100644
index b3bf3308..00000000
--- a/client/src/components/Settings/SettingsForm.styles.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import styled from 'styled-components';
-
-export const EditForm = styled.div``;
-
-export const labelCol = { span: 6, offset: 0 };
-export const wrapperCol = { span: 14, offset: 0 };
diff --git a/client/src/components/Settings/SettingsForm.tsx b/client/src/components/Settings/SettingsForm.tsx
index fe5f5f2b..7481aa86 100644
--- a/client/src/components/Settings/SettingsForm.tsx
+++ b/client/src/components/Settings/SettingsForm.tsx
@@ -1,31 +1,22 @@
-import { Box, Flex } from 'reflexbox';
-import { Form } from 'antd';
-import React from 'react';
+import { Divider, VStack } from '@chakra-ui/react';
+import '../../types/electron-bridge';
import { AnalyserForm } from './AnalyserForm';
import { AppForm } from './AppForm';
+import { AppVersion } from './AppVersion';
+import { DataForm } from './DataForm';
import { WorkForm } from './WorkForm';
-import { ThemeSelect } from './ThemeSelect';
export const SettingsForm = () => {
return (
-
+
+
+
+
);
};
diff --git a/client/src/components/Settings/ThemeSelect.tsx b/client/src/components/Settings/ThemeSelect.tsx
deleted file mode 100644
index bc274768..00000000
--- a/client/src/components/Settings/ThemeSelect.tsx
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Select } from 'antd';
-import React from 'react';
-import { Box, Flex } from 'reflexbox';
-import { ColorPicker } from '../Timeline/ColorPicker';
-import { useStoreActions, useStoreState } from '../../store/easyPeasy';
-import { PRIMARY_COLOR_VAR, themes } from '../../store/theme.util';
-
-export const ThemeSelect = () => {
- const theme: any = useStoreState(state => state.theme);
- const setThemeByName = useStoreActions(actions => actions.setThemeByName);
- const setThemeWithVariables = useStoreActions(actions => actions.setThemeWithVariables);
-
- return (
-
-
- Selected Theme
-
-
- {
- setThemeByName(theme);
- }}
- >
- {Object.keys(themes).map(name => (
-
- {name}
-
- ))}
-
-
-
- {
- setThemeWithVariables({
- ...theme,
- variables: {
- ...theme.variables,
- [PRIMARY_COLOR_VAR]: color,
- },
- });
- }}
- />
-
-
- );
-};
diff --git a/client/src/components/Settings/WorkForm.tsx b/client/src/components/Settings/WorkForm.tsx
index 56684668..07179f8e 100644
--- a/client/src/components/Settings/WorkForm.tsx
+++ b/client/src/components/Settings/WorkForm.tsx
@@ -1,30 +1,146 @@
-import { Card, Input, Typography } from 'antd';
-import { ClockCircleOutlined } from '@ant-design/icons';
-import React, { useContext } from 'react';
-import { useFormState } from 'react-use-form-state';
+import {
+ Box,
+ Button,
+ Divider,
+ FormControl,
+ FormErrorMessage,
+ FormHelperText,
+ FormLabel,
+ Input,
+ Switch,
+ Text,
+} from '@chakra-ui/react';
+import { yupResolver } from '@hookform/resolvers/yup';
+import { useContext, useEffect } from 'react';
+import { useForm } from 'react-hook-form';
+import { useDebouncedCallback } from 'use-debounce';
+import * as yup from 'yup';
import { RootContext } from '../../RootContext';
+import { notifyUser } from '../../services/settings.api';
+import { CardBox } from '../CardBox';
+import { WorkSettingsI } from './WorkForm.util';
-const { Text } = Typography;
+const schema = yup
+ .object({
+ hoursToWork: yup.string().required().label('Hours to work'),
+ sessionLength: yup.number().required().positive().integer().label('Session Length'),
+ notificationDuration: yup.number().required().positive().integer().label('Notification duration'),
+ minBreakTime: yup.number().required().positive().integer().label('Min break time'),
+ reNotifyInterval: yup.number().required().integer().label('Re-notify interval'),
+ smallNotificationsEnabled: yup.boolean().label('Notifications'),
+ })
+ .required();
export const WorkForm = () => {
- const { workSettings, setWorkSettings } = useContext(RootContext);
- // tslint:disable-next-line: variable-name
- const [, { number }] = useFormState(workSettings, {
- onChange: (__ignore, ___ignore, nextStateValues) => {
- setWorkSettings(nextStateValues);
+ const { workSettings, updateWorkSettings } = useContext(RootContext);
+
+ const {
+ watch,
+ reset,
+ formState: { errors, isDirty, isValid },
+ register,
+ } = useForm({ mode: 'onChange', defaultValues: workSettings, resolver: yupResolver(schema) });
+
+ const watchAllFields = watch();
+
+ const debouncedUpdate = useDebouncedCallback(
+ (value) => {
+ console.info('Save values: ', value);
+ updateWorkSettings(value);
},
- });
+ 1000,
+ { leading: false, trailing: true },
+ );
+
+ useEffect(() => {
+ if (isDirty && isValid) {
+ const formValues = watchAllFields;
+ debouncedUpdate(formValues);
+ reset(formValues);
+ } else if (isDirty) {
+ debouncedUpdate.cancel();
+ }
+ }, [watchAllFields, isDirty, isValid, debouncedUpdate, reset]);
+
+ const smallNotificationsEnabled = watch('smallNotificationsEnabled');
+ const minBreakTime = watch('minBreakTime');
return (
-
- }
- maxLength={25}
- {...number({ name: 'hoursToWork' })}
- />
-
- Used in Progress pie chart to calculate workday progress
-
+
+
+ Workday length
+
+ {errors.hoursToWork && errors.hoursToWork.message}
+
+
+
+
+
+
+
+ Break notify settings
+
+
+
+
+ Used to show one session progress in tray graphic and to notify about a break. The idea of this
+ notification is for you not to lose focus, but to remind you to take a break if you are in between
+ some work.
+
+
+
+ Session length in minutes
+
+ {errors.sessionLength && errors.sessionLength.message}
+ {smallNotificationsEnabled && (
+
+ You are notified if online time exceeds this. Ignoring idle times less than {minBreakTime}{' '}
+ minutes
+
+ )}
+
+
+
+
+ Reminder notifications enabled
+
+
+
+
+
+ Re-Notify interval minutes
+
+ {errors.reNotifyInterval && errors.reNotifyInterval.message}
+
+
+ Notification duration in seconds
+
+
+ {errors.notificationDuration && errors.notificationDuration.message}
+
+
+
+ Min break time in minutes
+
+ {errors.minBreakTime && errors.minBreakTime.message}
+
+
+ notifyUser(1999999)}>
+ Show me the minimal notification
+
+
+
);
};
diff --git a/client/src/components/Settings/WorkForm.util.tsx b/client/src/components/Settings/WorkForm.util.tsx
new file mode 100644
index 00000000..ced498cb
--- /dev/null
+++ b/client/src/components/Settings/WorkForm.util.tsx
@@ -0,0 +1,11 @@
+export type WorkSettingsI = {
+ workDayStartTime: string;
+ workDayEndTime: string;
+ splitTaskAfterIdlingForMinutes: number;
+ hoursToWork: number;
+ sessionLength: number;
+ minBreakTime: number;
+ notificationDuration: number;
+ reNotifyInterval: number;
+ smallNotificationsEnabled: boolean;
+};
diff --git a/client/src/components/SummaryCalendar/ItemIcon.tsx b/client/src/components/SummaryCalendar/ItemIcon.tsx
new file mode 100644
index 00000000..a7b6bc76
--- /dev/null
+++ b/client/src/components/SummaryCalendar/ItemIcon.tsx
@@ -0,0 +1,13 @@
+import { Box } from '@chakra-ui/react';
+
+export const ItemIcon = (props) => (
+
+);
diff --git a/client/src/components/SummaryCalendar/SummaryCalendar.styles.ts b/client/src/components/SummaryCalendar/SummaryCalendar.styles.ts
deleted file mode 100644
index f9c95003..00000000
--- a/client/src/components/SummaryCalendar/SummaryCalendar.styles.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import styled from 'styled-components';
-
-export const TaskList = styled.ul`
- list-style: none;
- margin: 0;
- padding: 0;
-`;
-
-export const Item = styled.li`
- list-style: none;
- margin: 0;
- padding: 0;
-`;
diff --git a/client/src/components/SummaryCalendar/SummaryCalendar.tsx b/client/src/components/SummaryCalendar/SummaryCalendar.tsx
index d521413b..4f37e174 100644
--- a/client/src/components/SummaryCalendar/SummaryCalendar.tsx
+++ b/client/src/components/SummaryCalendar/SummaryCalendar.tsx
@@ -1,31 +1,55 @@
-import { Flex } from 'reflexbox';
-import { Calendar, Spin } from 'antd';
-import { CoffeeOutlined, EyeOutlined, LaptopOutlined, ToolOutlined } from '@ant-design/icons';
-import moment from 'moment';
-import React, { useContext, useEffect } from 'react';
-import useReactRouter from 'use-react-router';
+import { Box, Flex, Text, useColorModeValue, VStack } from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import { useContext, useEffect } from 'react';
+import { IoMdMoon, IoMdSunny } from 'react-icons/io';
+import { useNavigate } from 'react-router-dom';
import { SummaryContext } from '../../SummaryContext';
-import { Spinner } from '../Timeline/Timeline.styles';
-import { Item, TaskList } from './SummaryCalendar.styles';
-import { Logger } from '../../logger';
+import { CALENDAR_MODE, DAY_MONTH_FORMAT } from '../../SummaryContext.util';
import { convertDate, TIME_FORMAT_SHORT } from '../../constants';
-import { formatDuration } from './SummaryCalendar.util';
-import classNames from 'classnames';
-import padStart from 'lodash/padStart';
-import { DAY_MONTH_FORMAT } from '../../SummaryContext.util';
+import { Logger } from '../../logger';
import { useStoreActions, useStoreState } from '../../store/easyPeasy';
-
-moment.locale('et');
+import { formatDurationInternal } from '../../utils';
+import { Calendar } from '../Datepicker/Calendar';
+import { Loader } from '../Timeline/Loader';
+import { ItemIcon } from './ItemIcon';
const icons = {
- coffee: ,
- 'eye-invisible': ,
- laptop: ,
- tool: ,
+ wakeTime: (
+
+
+
+ ),
+ sleepTime: (
+
+
+
+ ),
+ online: ,
+ tasks: ,
};
+
+const DayContent = ({ listData }) => {
+ const color = useColorModeValue('black', 'gray.300');
+ return (
+
+ {listData.map((item) => (
+
+ {icons[item.type]}
+
+ {item.time}
+
+
+ {item.title}
+
+
+ ))}
+
+ );
+};
+
export const SummaryCalendar = () => {
- const timerange = useStoreState(state => state.timerange);
- const loadTimerange = useStoreActions(state => state.loadTimerange);
+ const timerange = useStoreState((state) => state.timerange);
+ const loadTimerange = useStoreActions((state) => state.loadTimerange);
const {
selectedDate,
@@ -38,21 +62,16 @@ export const SummaryCalendar = () => {
isLoading,
} = useContext(SummaryContext);
- const { history } = useReactRouter();
+ const navigate = useNavigate();
- const onDateClicked = (date: any) => {
+ const onDateClicked = (date: DateTime) => {
if (!date) {
Logger.error('No date');
return;
}
- loadTimerange([date.clone().startOf('day'), date.clone().endOf('day')]);
- history.push('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/app/timeline');
- };
-
- const changeSelectedDate = (date?: any, mode?: 'month' | 'year') => {
- setSelectedDate(date);
- setSelectedMode(mode);
+ loadTimerange([date.startOf('day'), date.endOf('day')]);
+ navigate('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/app/timeline');
};
useEffect(() => {
@@ -60,112 +79,63 @@ export const SummaryCalendar = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const getListData = day => {
- const listData: any[] = [];
+ const getListData = (day: string) => {
+ const listData: Array<{ type: string; time: string; title: string }> = [];
const times = onlineTimesSummary[day];
if (times) {
listData.push({
- type: 'coffee',
- content: `Wake time ${convertDate(times.beginDate).format(TIME_FORMAT_SHORT)}`,
+ type: 'wakeTime',
+ time: convertDate(times.beginDate).toFormat(TIME_FORMAT_SHORT),
+ title: `Wake time`,
});
listData.push({
- type: 'eye-invisible',
- content: `Sleep time ${convertDate(times.endDate).format(TIME_FORMAT_SHORT)}`,
+ type: 'sleepTime',
+ time: convertDate(times.endDate).toFormat(TIME_FORMAT_SHORT),
+ title: `Sleep time`,
});
}
const online = onlineSummary[day];
if (online) {
- listData.push({ type: 'laptop', content: `Worked ${formatDuration(online)}` });
+ listData.push({ type: 'online', time: formatDurationInternal(online), title: `Online` });
}
const worked = logSummary[day];
if (worked) {
- listData.push({ type: 'tool', content: `Tasks ${formatDuration(worked)}` });
+ listData.push({ type: 'tasks', time: formatDurationInternal(worked), title: `Tasks` });
}
return listData || [];
};
- const dateCellRender = value => {
- if (value.month() === selectedDate.month()) {
- const listData = getListData(value.format(DAY_MONTH_FORMAT));
- return (
-
- {listData.map(item => (
- -
- {icons[item.type]}
- {' '}
- {item.content}
-
- ))}
-
- );
- }
- return null;
- };
-
- const dateFullCellRender = date => {
- let style = {};
-
- const day = date.day();
- const isWeekend = day === 6 || day === 0;
- const isToday = moment().isSame(date, 'day');
- if (isWeekend) {
- if (isToday) {
- style = { background: '#e6f7ff' };
- } else {
- style = { background: 'rgb(230, 230, 230, 0.2)' };
+ const dateCellRender = (value: DateTime) => {
+ if (selectedMode === CALENDAR_MODE.MONTH) {
+ if (value.month === selectedDate.month) {
+ const listData = getListData(value.toFormat(DAY_MONTH_FORMAT));
+ return ;
}
+ return null;
+ } else if (selectedMode === CALENDAR_MODE.YEAR) {
+ const listData = getListData(value.month.toString());
+ return ;
+ } else {
+ Logger.error('Unknown mode for calendar');
}
- return (
- onDateClicked(date)}
- >
-
- {padStart(date.date(), 2, '0')}
-
-
- {dateCellRender && dateCellRender(date)}
-
-
- );
- };
-
- const monthCellRender = value => {
- const listData = getListData(value.month());
- return (
-
- {listData.map(item => (
- -
- {icons[item.type]}
- {' '}
- {item.content}
-
- ))}
-
- );
};
return (
-
- {isLoading && (
-
-
-
- )}
+ <>
+ {isLoading && }
+
-
+ >
);
};
diff --git a/client/src/components/SummaryCalendar/SummaryCalendar.util.test.ts b/client/src/components/SummaryCalendar/SummaryCalendar.util.test.ts
index 0fd9ad08..5731a994 100644
--- a/client/src/components/SummaryCalendar/SummaryCalendar.util.test.ts
+++ b/client/src/components/SummaryCalendar/SummaryCalendar.util.test.ts
@@ -1,10 +1,12 @@
+import { DateTime } from 'luxon';
+import { describe, expect, it } from 'vitest';
+import { CALENDAR_MODE } from '../../SummaryContext.util';
import { summariseTimeOnline } from './SummaryCalendar.util';
import { TEST_INPUT } from './SummaryCalendar.util.testdata';
-import moment from 'moment';
describe('summariseTimeOnline', () => {
it('parses online items', () => {
- const actual = summariseTimeOnline(TEST_INPUT, 'month', moment(1589311528755));
+ const actual = summariseTimeOnline(TEST_INPUT, CALENDAR_MODE.MONTH, DateTime.fromMillis(1589311528755));
const result = {
'05-12': { beginDate: 1589311528755, endDate: 1589312113568, online: 358396 },
'05-14': { beginDate: 1589480558049, endDate: 1589483666850, online: 2707998 },
@@ -18,16 +20,21 @@ describe('summariseTimeOnline', () => {
it('online item over midnight - until 04:00 - it goes to previous day', () => {
const input = [
{
+ id: 1,
app: 'ONLINE',
- beginDate: moment('2020-04-10 02:30').valueOf(),
- endDate: moment('2020-04-10 03:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 02:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 03:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
];
- const actual = summariseTimeOnline(input, 'month', moment('2020-04-10 04:30'));
+ const actual = summariseTimeOnline(
+ input,
+ CALENDAR_MODE.MONTH,
+ DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm'),
+ );
const result = {
'04-09': {
- beginDate: moment('2020-04-10 02:30').valueOf(),
- endDate: moment('2020-04-10 03:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 02:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 03:30', 'yyyy-MM-dd HH:mm').toMillis(),
online: 3600000,
},
};
@@ -38,16 +45,21 @@ describe('summariseTimeOnline', () => {
it('online item after 04:00 goes to correct day', () => {
const input = [
{
+ id: 1,
app: 'ONLINE',
- beginDate: moment('2020-04-10 04:30').valueOf(),
- endDate: moment('2020-04-10 05:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 05:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
];
- const actual = summariseTimeOnline(input, 'month', moment('2020-04-10 04:30'));
+ const actual = summariseTimeOnline(
+ input,
+ CALENDAR_MODE.MONTH,
+ DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm'),
+ );
const result = {
'04-10': {
- beginDate: moment('2020-04-10 04:30').valueOf(),
- endDate: moment('2020-04-10 05:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 05:30', 'yyyy-MM-dd HH:mm').toMillis(),
online: 3600000,
},
};
@@ -58,31 +70,39 @@ describe('summariseTimeOnline', () => {
it('woke up 04:30 and went to sleep 03:30', () => {
const input = [
{
+ id: 1,
app: 'ONLINE',
- beginDate: moment('2020-04-10 04:30').valueOf(),
- endDate: moment('2020-04-10 05:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 05:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
{
+ id: 2,
app: 'ONLINE',
- beginDate: moment('2020-04-10 11:30').valueOf(),
- endDate: moment('2020-04-10 15:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 11:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-10 15:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
{
+ id: 3,
app: 'ONLINE',
- beginDate: moment('2020-04-10 23:30').valueOf(),
- endDate: moment('2020-04-11 01:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 23:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-11 01:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
{
+ id: 4,
app: 'ONLINE',
- beginDate: moment('2020-04-11 02:30').valueOf(),
- endDate: moment('2020-04-11 03:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-11 02:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-11 03:30', 'yyyy-MM-dd HH:mm').toMillis(),
},
];
- const actual = summariseTimeOnline(input, 'month', moment('2020-04-10 04:30'));
+ const actual = summariseTimeOnline(
+ input,
+ CALENDAR_MODE.MONTH,
+ DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm'),
+ );
const result = {
'04-10': {
- beginDate: moment('2020-04-10 04:30').valueOf(),
- endDate: moment('2020-04-11 03:30').valueOf(),
+ beginDate: DateTime.fromFormat('2020-04-10 04:30', 'yyyy-MM-dd HH:mm').toMillis(),
+ endDate: DateTime.fromFormat('2020-04-11 03:30', 'yyyy-MM-dd HH:mm').toMillis(),
online: 28800000,
},
};
@@ -93,4 +113,4 @@ describe('summariseTimeOnline', () => {
export // Use an empty export to please Babel's single file emit.
// https://github.com/Microsoft/TypeScript/issues/15230
-{};
+ {};
diff --git a/client/src/components/SummaryCalendar/SummaryCalendar.util.ts b/client/src/components/SummaryCalendar/SummaryCalendar.util.ts
index 3775d7b1..5e3df951 100644
--- a/client/src/components/SummaryCalendar/SummaryCalendar.util.ts
+++ b/client/src/components/SummaryCalendar/SummaryCalendar.util.ts
@@ -1,76 +1,86 @@
import _ from 'lodash';
-import {
- convertDate,
- TIME_FORMAT,
- BREAKPOINT_TIME,
- DURATION_FORMAT,
- DURATION_SETTINGS,
-} from '../../constants';
-import moment from 'moment';
-import { DAY_MONTH_FORMAT, MODE_MONTH } from '../../SummaryContext.util';
-
-export const formatDuration = dur =>
- moment.duration(dur).format(DURATION_FORMAT, DURATION_SETTINGS);
-
-export const groupByField = mode => item =>
- mode === MODE_MONTH
- ? convertDate(item.beginDate).format(DAY_MONTH_FORMAT)
- : convertDate(item.beginDate).month();
-
-export const groupByActualDay = item => {
+import { DateTime } from 'luxon';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { BREAKPOINT_TIME, convertDate, TIME_FORMAT } from '../../constants';
+import { CALENDAR_MODE, DAY_MONTH_FORMAT } from '../../SummaryContext.util';
+
+export const groupByField = (mode) => (item) =>
+ mode === CALENDAR_MODE.MONTH
+ ? convertDate(item.beginDate).toFormat(DAY_MONTH_FORMAT)
+ : convertDate(item.beginDate).month;
+
+export const groupByActualDay = (item) => {
const date = convertDate(item.beginDate);
- if (date.format(TIME_FORMAT) < BREAKPOINT_TIME) {
- return date.subtract(1, 'day').format(DAY_MONTH_FORMAT);
+ if (date.toFormat(TIME_FORMAT) < BREAKPOINT_TIME) {
+ return date.minus({ days: 1 }).toFormat(DAY_MONTH_FORMAT);
}
- return date.format(DAY_MONTH_FORMAT);
+ return date.toFormat(DAY_MONTH_FORMAT);
};
-export const summariseLog = (items, mode) => {
- const data = {};
+export interface ISummary {
+ [key: string]: number;
+}
+
+export const summariseLog = (items: ITrackItem[], mode: CALENDAR_MODE): ISummary => {
+ const data: ISummary = {};
_(items)
.groupBy(groupByField(mode))
.forEach((value, key) => {
- data[key] = _.sumBy(value, c => convertDate(c.endDate).diff(convertDate(c.beginDate)));
+ data[key] = _.sumBy(value, (c) => convertDate(c.endDate).diff(convertDate(c.beginDate)).milliseconds);
});
return data;
};
export const summariseOnline = (items, mode) => {
- const data = {};
+ const data: ISummary = {};
_(items)
- .filter(item => item.app === 'ONLINE')
+ .filter((item) => item.app === 'ONLINE')
.groupBy(groupByField(mode))
.forEach((value, key) => {
- data[key] = _.sumBy(value, c => convertDate(c.endDate).diff(convertDate(c.beginDate)));
+ data[key] = _.sumBy(value, (c) => convertDate(c.endDate).diff(convertDate(c.beginDate)).milliseconds);
});
return data;
};
-export const summariseTimeOnline = (items, mode, beginDate) => {
+interface IOnlineTime {
+ beginDate: Date;
+ endDate: Date;
+ online: number;
+}
+
+export interface ISummaryOnlineTime {
+ [key: string]: IOnlineTime;
+}
+
+export const summariseTimeOnline = (
+ items: ITrackItem[],
+ mode: CALENDAR_MODE,
+ beginDate: DateTime,
+): ISummaryOnlineTime => {
if (mode === 'year') {
- return [];
+ return {};
}
// We are taking sleep time from next months first day, but going to remove it from end result
- const currentMonth = beginDate.month();
+ const currentMonth = beginDate.month;
const data = _(items)
- .filter(item => item.app === 'ONLINE')
+ .filter((item) => item.app === 'ONLINE')
.groupBy(groupByActualDay)
- .map((value, key) => {
+ .map((value) => {
return {
- beginDate: _.minBy(value, c => convertDate(c.beginDate)).beginDate,
- endDate: _.maxBy(value, c => convertDate(c.endDate)).endDate,
- online: _.sumBy(value, c => convertDate(c.endDate).diff(convertDate(c.beginDate))),
+ beginDate: _.minBy(value, (c) => convertDate(c.beginDate).toMillis())?.beginDate,
+ endDate: _.maxBy(value, (c) => convertDate(c.endDate).toMillis())?.endDate,
+ online: _.sumBy(value, (c) => convertDate(c.endDate).diff(convertDate(c.beginDate)).milliseconds),
};
})
.reduce((result, currentValue) => {
const key = groupByActualDay(currentValue);
- const month = moment(key, DAY_MONTH_FORMAT).month();
+ const month = DateTime.fromFormat(key, DAY_MONTH_FORMAT).month;
if (currentMonth === month) {
result[key] = currentValue;
@@ -78,5 +88,6 @@ export const summariseTimeOnline = (items, mode, beginDate) => {
return result;
}, {});
+ console.log('data', data);
return data;
};
diff --git a/client/src/components/TaskList.tsx b/client/src/components/TaskList.tsx
new file mode 100644
index 00000000..a443423c
--- /dev/null
+++ b/client/src/components/TaskList.tsx
@@ -0,0 +1,53 @@
+import _ from 'lodash';
+
+import { Box, HStack, Text } from '@chakra-ui/react';
+import { useStoreState } from '../store/easyPeasy';
+import { sumAppObject, SumItem } from './PieCharts/MetricTiles.utils';
+import { filterItems } from './Timeline/timeline.utils';
+
+import { TrackItemType } from '../enum/TrackItemType';
+import { shortTime } from '../time.util';
+
+export const TaskList = () => {
+ const timeItems = useStoreState((state) => state.timeItems);
+ const timerange = useStoreState((state) => state.timerange);
+
+ const logItems = filterItems(timeItems[TrackItemType.LogTrackItem] || [], timerange);
+
+ const groupByField = 'app';
+
+ const data = _(logItems)
+ .groupBy(groupByField)
+ .map((b) => {
+ return b.reduce(sumAppObject(timerange), {
+ app: b[0].app,
+ title: b[0].title,
+ timeDiffInMs: 0,
+ color: b[0].color,
+ } as SumItem);
+ })
+ .valueOf();
+
+ return (
+ <>
+ {data.map(({ app, title, color, timeDiffInMs }) => {
+ return (
+
+
+
+
+ {app}
+
+
+
+ {title}
+
+
+ {shortTime(timeDiffInMs)}
+
+
+ );
+ })}
+ >
+ );
+};
diff --git a/client/src/components/TileBox.tsx b/client/src/components/TileBox.tsx
new file mode 100644
index 00000000..fd9f398a
--- /dev/null
+++ b/client/src/components/TileBox.tsx
@@ -0,0 +1,14 @@
+import { Box, useColorModeValue } from '@chakra-ui/react';
+
+export const TileBox = ({ children, ...rest }) => (
+
+ {children}
+
+);
diff --git a/client/src/components/Timeline/BarWithTooltip.tsx b/client/src/components/Timeline/BarWithTooltip.tsx
index 43bf0491..33505f43 100644
--- a/client/src/components/Timeline/BarWithTooltip.tsx
+++ b/client/src/components/Timeline/BarWithTooltip.tsx
@@ -1,16 +1,34 @@
-import React, { useState } from 'react';
+import { useRef, useState } from 'react';
import { Portal } from 'react-portal';
import { Bar, VictoryTooltip } from 'victory';
+interface PropsI {
+ datum?: {};
+ onClickBarItem?: any;
+ getTooltipLabel: any;
+ popoverTriggerRef?: any;
+ x?: number;
+ y?: number;
+ theme: any;
+ centerTime?: boolean;
+}
+
+const calculateMiddle = (datum) => {
+ return datum.beginDate + (datum.endDate - datum.beginDate) / 2;
+};
+
export const BarWithTooltip = ({
datum = {},
onClickBarItem,
getTooltipLabel,
+ popoverTriggerRef = null,
x = 0,
y = 0,
theme,
+ centerTime = false,
...rest
-}) => {
+}: PropsI) => {
+ const barRef = useRef(null);
const [hover, setHover] = useState(false);
const onMouseEnter = () => {
@@ -24,17 +42,31 @@ export const BarWithTooltip = ({
const events = {
onMouseEnter,
onMouseLeave,
- onClick: () => onClickBarItem(datum),
+ onClick: () => {
+ // Todo: Find a way to get barRef from Bar
+
+ if (onClickBarItem && popoverTriggerRef) {
+ console.info('barRef.current', barRef.current);
+ // eslint-disable-next-line react-compiler/react-compiler
+ popoverTriggerRef.current = barRef.current;
+ onClickBarItem(datum);
+ }
+ },
};
+ const midpoint = calculateMiddle(datum);
+ // @ts-ignore
+ const newX = rest.scale.y(midpoint);
+
return (
<>
{ }
+
{hover && (
)}
diff --git a/client/src/components/Timeline/BrushHandle.tsx b/client/src/components/Timeline/BrushHandle.tsx
new file mode 100644
index 00000000..5ca7c8b0
--- /dev/null
+++ b/client/src/components/Timeline/BrushHandle.tsx
@@ -0,0 +1,56 @@
+export const BrushHandle = ({ smaller = false, ...rest }) => {
+ const handleHeight = smaller ? 20 : 25;
+ const handleWidth = 8;
+
+ const y = smaller ? 1 : 2;
+
+ // Add functions to handle pointer capture
+ const handlePointerDown = (e) => {
+ // Set pointer capture on the handle
+ e.target.setPointerCapture(e.pointerId);
+ };
+
+ return (
+
+ {/* Simple rectangle handle */}
+
+
+ {/* Invisible larger hit area to make handle easier to grab */}
+
+
+ );
+};
+
+// export const BrushHandle = (props) => (
+//
+//
+//
+//
+//
+//
+// );
diff --git a/client/src/components/Timeline/ChartTheme.ts b/client/src/components/Timeline/ChartTheme.ts
index 5b52ff53..23f5f4f6 100644
--- a/client/src/components/Timeline/ChartTheme.ts
+++ b/client/src/components/Timeline/ChartTheme.ts
@@ -10,26 +10,28 @@ const lightGreen500 = '#8BC34A';
const teal700 = '#00796B';
const cyan900 = '#006064';
const colors = [deepOrange600, yellow200, lime300, lightGreen500, teal700, cyan900];
-const blueGrey50 = '#ECEFF1';
-const blueGrey300 = '#90A4AE';
-export const blueGrey700 = '#455A64';
-export const grey900 = '#212121';
-export const disabledGrey = '#e6e6e6';
+const bluegray300 = 'var(--chakra-colors-gray-300)';
+
+export const bluegray700 = 'var(--chakra-colors-gray-700)';
+export const gray900 = 'var(--chakra-colors-gray-900)';
+export const disabledgray = 'var(--chakra-colors-gray-300)';
+const almostWhite = 'var(--chakra-colors-gray-100)';
+const gray = 'var(--chakra-colors-gray-500)';
export const getLabelColor = (isDark, isEnabled) => {
if (isDark) {
- return isEnabled ? almostWhite : blueGrey700;
+ return isEnabled ? almostWhite : bluegray700;
}
- return !isEnabled ? disabledGrey : disabledGrey;
+ return !isEnabled ? disabledgray : disabledgray;
};
// *
// * Typography
// *
const sansSerif = "'Roboto', 'Helvetica Neue', Helvetica, sans-serif";
const letterSpacing = 'normal';
-const fontSize = 10;
+const fontSize = 12;
// *
// * Layout
// *
@@ -47,22 +49,15 @@ const baseLabelStyles = isDark => ({
fontSize,
letterSpacing,
padding,
- fill: isDark ? almostWhite : blueGrey700,
+ fill: isDark ? almostWhite : bluegray700,
stroke: 'transparent',
strokeWidth: 0,
});
-const almostWhite = '#c3c3c3';
-const lightGray = '#151515';
-const gray = '#212121';
-
const centeredLabelStyles = isDark => assign({ textAnchor: 'middle' }, baseLabelStyles(isDark));
// *
// * Strokes
// *
-const strokeDasharray = '10, 5';
-const strokeLinecap = 'round';
-const strokeLinejoin = 'round';
export const getChartTheme = isDark => ({
isDark,
@@ -73,33 +68,23 @@ export const getChartTheme = isDark => ({
style: {
axis: {
fill: 'transparent',
- stroke: isDark ? gray : blueGrey300,
- strokeWidth: 1,
- strokeLinecap,
- strokeLinejoin,
+ strokeWidth: 0,
},
axisLabel: assign({}, centeredLabelStyles(isDark), {
padding,
stroke: 'transparent',
}),
grid: {
- fill: 'none',
- stroke: isDark ? lightGray : blueGrey50,
- strokeDasharray,
- strokeLinecap,
- strokeLinejoin,
+ stroke: isDark ? gray : bluegray300,
pointerEvents: 'painted',
},
ticks: {
fill: 'transparent',
size: 5,
- stroke: isDark ? gray : blueGrey300,
- strokeWidth: 1,
- strokeLinecap,
- strokeLinejoin,
+ strokeWidth: 0,
},
tickLabels: assign({}, baseLabelStyles(isDark), {
- fill: isDark ? almostWhite : blueGrey700,
+ fill: isDark ? almostWhite : bluegray700,
}),
},
},
@@ -109,7 +94,7 @@ export const getChartTheme = isDark => ({
{
style: {
data: {
- fill: isDark ? almostWhite : blueGrey700,
+ fill: isDark ? almostWhite : bluegray700,
padding,
strokeWidth: 0,
},
@@ -120,9 +105,12 @@ export const getChartTheme = isDark => ({
),
tooltip: {
- style: assign({}, centeredLabelStyles(isDark), { padding: 5, pointerEvents: 'none' }),
+ style: assign({}, centeredLabelStyles(isDark), {
+ padding: 15,
+ pointerEvents: 'none',
+ }),
flyoutStyle: {
- stroke: isDark ? almostWhite : blueGrey300,
+ stroke: isDark ? almostWhite : bluegray300,
strokeWidth: 0.5,
fill: isDark ? '#000000' : '#ffffff',
pointerEvents: 'none',
@@ -137,7 +125,7 @@ export const getChartTheme = isDark => ({
style: {
data: {
padding,
- stroke: blueGrey50,
+ stroke: bluegray300,
strokeWidth: 1,
},
labels: assign({}, baseLabelStyles(isDark), { padding: 20 }),
@@ -150,7 +138,7 @@ export const getChartTheme = isDark => ({
{
style: {
data: {
- fill: grey900,
+ fill: gray900,
},
labels: centeredLabelStyles(isDark),
},
@@ -160,15 +148,15 @@ export const getChartTheme = isDark => ({
boxplot: assign(
{
style: {
- max: { padding, stroke: blueGrey700, strokeWidth: 1 },
+ max: { padding, stroke: bluegray700, strokeWidth: 1 },
maxLabels: baseLabelStyles(isDark),
- median: { padding, stroke: blueGrey700, strokeWidth: 1 },
+ median: { padding, stroke: bluegray700, strokeWidth: 1 },
medianLabels: baseLabelStyles(isDark),
- min: { padding, stroke: blueGrey700, strokeWidth: 1 },
+ min: { padding, stroke: bluegray700, strokeWidth: 1 },
minLabels: baseLabelStyles(isDark),
- q1: { padding, fill: blueGrey700 },
+ q1: { padding, fill: bluegray700 },
q1Labels: baseLabelStyles(isDark),
- q3: { padding, fill: blueGrey700 },
+ q3: { padding, fill: bluegray700 },
q3Labels: baseLabelStyles(isDark),
},
boxWidth: 20,
@@ -179,13 +167,13 @@ export const getChartTheme = isDark => ({
{
style: {
data: {
- stroke: blueGrey700,
+ stroke: bluegray700,
},
labels: centeredLabelStyles(isDark),
},
candleColors: {
positive: '#ffffff',
- negative: blueGrey700,
+ negative: bluegray700,
},
},
baseProps,
@@ -198,7 +186,7 @@ export const getChartTheme = isDark => ({
data: {
fill: 'transparent',
opacity: 1,
- stroke: blueGrey700,
+ stroke: bluegray700,
strokeWidth: 2,
},
labels: centeredLabelStyles(isDark),
@@ -231,7 +219,7 @@ export const getChartTheme = isDark => ({
data: {
fill: 'transparent',
opacity: 1,
- stroke: blueGrey700,
+ stroke: bluegray700,
strokeWidth: 2,
},
labels: centeredLabelStyles(isDark),
@@ -244,7 +232,7 @@ export const getChartTheme = isDark => ({
{
style: {
data: {
- fill: blueGrey700,
+ fill: bluegray700,
opacity: 1,
stroke: 'transparent',
strokeWidth: 0,
@@ -274,7 +262,7 @@ export const getChartTheme = isDark => ({
pointerEvents: 'none',
}),
flyout: {
- stroke: grey900,
+ stroke: gray900,
strokeWidth: 1,
fill: '#f0f0f0',
pointerEvents: 'none',
diff --git a/client/src/components/Timeline/ColorChangerModal.tsx b/client/src/components/Timeline/ColorChangerModal.tsx
deleted file mode 100644
index 4e83ce36..00000000
--- a/client/src/components/Timeline/ColorChangerModal.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import React from 'react';
-
-import { Button, Modal } from 'antd';
-import { Logger } from '../../logger';
-
-interface IProps {
- modalVisible: boolean;
- selectColorOption: any;
-}
-
-const btnStyle = { width: '100%', marginBottom: 5 };
-export class ColorChangerModal extends React.PureComponent {
- constructor(props) {
- super(props);
- Logger.debug('ColorChangerModal', props);
- }
-
- public changeColorHandler = color => {
- Logger.debug('Changed color:', color);
- this.setState({ color });
- };
-
- public saveItemHandler = opts => {
- this.props.selectColorOption(opts);
- };
-
- public saveOnlyThis = () => this.saveItemHandler('ONLY_THIS');
-
- public saveNew = () => this.saveItemHandler('NEW_ITEMS');
-
- public saveAll = () => this.saveItemHandler('ALL_ITEMS');
-
- public render() {
- const { modalVisible }: IProps = this.props;
-
- return (
-
-
- Only this
-
-
-
- This and new items
-
-
-
- All items
-
-
- );
- }
-}
diff --git a/client/src/components/Timeline/ColorPicker.tsx b/client/src/components/Timeline/ColorPicker.tsx
index 6b131c3e..1bd7e275 100644
--- a/client/src/components/Timeline/ColorPicker.tsx
+++ b/client/src/components/Timeline/ColorPicker.tsx
@@ -1,83 +1,63 @@
-import React, { useState, useEffect } from 'react';
-import { SketchPicker } from 'react-color';
-import reactCSS from 'reactcss';
+import {
+ Box,
+ Button,
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+ useColorMode,
+ useMultiStyleConfig,
+} from '@chakra-ui/react';
+import { useEffect } from 'react';
+import { ColorPicker as ReactPicker, toColor, useColor } from 'react-color-palette';
+import 'react-color-palette/lib/css/styles.css';
+import { THEMES } from '../../store/theme.util';
interface IProps {
color: any;
onChange: any;
+ readOnly?: boolean;
}
const defaultColor = '#000000';
-export const ColorPicker = ({ color = defaultColor, onChange }: IProps) => {
- const [pickerColor, setPickerColor] = useState(color);
- const [displayColorPicker, setSisplayColorPicker] = useState(false);
+export const ColorPicker = ({ color = defaultColor, onChange }: IProps) => {
+ const { colorMode } = useColorMode();
+ const [pickerColor, setPickerColor] = useColor('hex', color || defaultColor);
useEffect(() => {
if (color) {
- setPickerColor(color);
+ setPickerColor(toColor('hex', color));
} else {
- setPickerColor(defaultColor);
+ setPickerColor(toColor('hex', defaultColor));
}
- }, [color]);
-
- const handleClick = () => {
- setSisplayColorPicker(!displayColorPicker);
- };
-
- const handleClose = () => {
- setSisplayColorPicker(false);
- };
+ }, [setPickerColor, color]);
- const handleChange = color => {
- setPickerColor(color.hex);
- onChange(color.hex);
+ const handleChange = (value) => {
+ setPickerColor(value);
+ onChange(value.hex);
};
- const styles = reactCSS({
- default: {
- color: {
- width: '20px',
- height: '20px',
- borderRadius: '2px',
- background: pickerColor,
- },
- swatch: {
- marginTop: '1px',
- padding: '5px',
- background: '#fff',
- borderRadius: '3px',
- boxShadow: '0 0 0 1px rgba(0,0,0,.1)',
- display: 'inline-block',
- cursor: 'pointer',
- position: 'relative',
- },
- popover: {
- position: 'absolute',
- zIndex: '2',
- right: '0',
- },
- cover: {
- position: 'fixed',
- top: '0px',
- right: '0px',
- bottom: '0px',
- left: '0px',
- },
- },
- });
+ const styles = useMultiStyleConfig('ColorPicker', { bgColor: pickerColor.hex });
return (
-
-
-
- {displayColorPicker ? (
-
- ) : null}
-
-
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
);
};
diff --git a/client/src/components/Timeline/Loader.tsx b/client/src/components/Timeline/Loader.tsx
new file mode 100644
index 00000000..9442166d
--- /dev/null
+++ b/client/src/components/Timeline/Loader.tsx
@@ -0,0 +1,28 @@
+import { Spinner } from '@chakra-ui/react';
+import { useColorModeValue } from '@chakra-ui/react';
+import { chakra } from '@chakra-ui/react';
+
+const SpinnerContainer = chakra('div', {
+ baseStyle: {
+ position: 'absolute',
+ textAlign: 'center',
+
+ opacity: '75%',
+ borderRadius: 'md',
+ padding: 6,
+ width: '100%',
+ height: '100%',
+ minHeight: '100px',
+
+ top: 0,
+ left: 0,
+ zIndex: 9,
+ margin: 'auto auto',
+ },
+});
+
+export const Loader = () => (
+
+
+
+);
diff --git a/client/src/components/Timeline/MainTimelineChart.tsx b/client/src/components/Timeline/MainTimelineChart.tsx
new file mode 100644
index 00000000..a1870119
--- /dev/null
+++ b/client/src/components/Timeline/MainTimelineChart.tsx
@@ -0,0 +1,271 @@
+import { memo, useRef } from 'react';
+import {
+ DomainPaddingPropType,
+ VictoryAxis,
+ VictoryBar,
+ VictoryBrushLine,
+ VictoryChart,
+ VictoryStyleInterface,
+} from 'victory';
+import { convertDate, TIME_FORMAT } from '../../constants';
+import { TrackItemType } from '../../enum/TrackItemType';
+import { Logger } from '../../logger';
+import { BarWithTooltip } from './BarWithTooltip';
+
+import { Box, IconButton, Tooltip } from '@chakra-ui/react';
+import { RxZoomIn, RxZoomOut } from 'react-icons/rx';
+import { useDebouncedCallback } from 'use-debounce';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { useStoreActions, useStoreState } from '../../store/easyPeasy';
+import { rangeToDate } from '../../timeline.util';
+import { formatDurationInternal } from '../../utils';
+import { colorProp } from '../charts.utils';
+import { BrushHandle } from './BrushHandle';
+import { BAR_WIDTH, CHART_PADDING, CHART_SCALE } from './timeline.constants';
+import { getDynamicTimeFormat } from './timeline.format.utils';
+import { getTrackItemOrderFn } from './timeline.utils';
+
+import { useMeasure } from '@uidotdev/usehooks';
+import { shortTime } from '../../time.util';
+
+const domainPadding: DomainPaddingPropType = { y: 35, x: 10 };
+
+export const barStyle = (isDark: boolean): VictoryStyleInterface => ({
+ data: {
+ width: BAR_WIDTH,
+ fill: colorProp,
+ stroke: isDark ? 'black' : 'white',
+ strokeWidth: 0.5,
+ fillOpacity: 1,
+ clipPath: 'none',
+ },
+});
+
+const EMPTY_ARRAY: ITrackItem[] = [];
+
+// Minimum timerange duration in milliseconds (5 minutes)
+const MIN_TIMERANGE_DURATION_MS = 5 * 60 * 1000;
+
+export const MainTimelineChart = memo(() => {
+ const [ref, { width }] = useMeasure();
+
+ const { chartTheme } = useChartThemeState();
+ const popoverTriggerRef = useRef(null);
+
+ const timerange = useStoreState((state) => state.timerange);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+ const timeItems = useStoreState((state) => state.timeItems);
+
+ const setVisibleTimerange = useStoreActions((actions) => actions.setVisibleTimerange);
+
+ const selectedTimelineItem = useStoreState((state) => state.selectedTimelineItem);
+ const setSelectedTimelineItem = useStoreActions((actions) => actions.setSelectedTimelineItem);
+
+ const handleSelectionChanged = (item) => {
+ if (item) {
+ Logger.debug('Selected item:', item);
+ setSelectedTimelineItem(item);
+ } else {
+ Logger.error('No item selected');
+ }
+ };
+
+ // Zoom handlers for manual zoom buttons
+ const zoomIn = () => {
+ if (visibleTimerange) {
+ const [start, end] = visibleTimerange;
+ const currentDuration = end.diff(start).milliseconds;
+
+ // Calculate the new duration, but don't go below minimum
+ const newDuration = Math.max(currentDuration / 2, MIN_TIMERANGE_DURATION_MS);
+
+ const midpoint = start.plus({ milliseconds: currentDuration / 2 });
+ const newStart = midpoint.minus({ milliseconds: newDuration / 2 });
+ const newEnd = midpoint.plus({ milliseconds: newDuration / 2 });
+
+ setVisibleTimerange([newStart, newEnd]);
+ }
+ };
+
+ const zoomOut = () => {
+ if (visibleTimerange && timerange) {
+ const [start, end] = visibleTimerange;
+ const midpoint = start.plus({ milliseconds: end.diff(start).milliseconds / 2 });
+ const newDuration = end.diff(start).milliseconds * 2;
+
+ // Constrain to original timerange
+ const [fullStart, fullEnd] = timerange;
+ const maxDuration = fullEnd.diff(fullStart).milliseconds;
+ const actualDuration = Math.min(newDuration, maxDuration);
+
+ const newStart = midpoint.minus({ milliseconds: actualDuration / 2 });
+ const newEnd = midpoint.plus({ milliseconds: actualDuration / 2 });
+
+ setVisibleTimerange([newStart, newEnd]);
+ }
+ };
+
+ const handleEditBrush = useDebouncedCallback((domain, brushProps) => {
+ if (domain) {
+ console.info('EditBrush event received:', domain, brushProps);
+
+ const beginDate = convertDate(domain[0]).valueOf();
+ const endDate = convertDate(domain[1]).valueOf();
+
+ Logger.debug('EditBrush changed:', beginDate, endDate);
+
+ if (selectedTimelineItem) {
+ setSelectedTimelineItem({ ...selectedTimelineItem, beginDate, endDate });
+ } else {
+ Logger.error('No item selected');
+ }
+ }
+ }, 200);
+
+ const getTooltipLabel = (d) => {
+ const diff = convertDate(d.endDate).diff(convertDate(d.beginDate));
+ const now = convertDate(new Date());
+ const ago = now.diff(convertDate(d.endDate));
+
+ const type = d.taskName === TrackItemType.StatusTrackItem ? 'STATUS' : d.app;
+ const beginTime = convertDate(d.beginDate).toFormat(TIME_FORMAT);
+ const endTime = convertDate(d.endDate).toFormat(TIME_FORMAT);
+ const agoText = `${shortTime(ago.milliseconds, { largest: 2 })} ago`;
+
+ const url = d.url ? `${d.url}\r\n` : '';
+ return `${type}\r\n${d.title}\r\n${url}${beginTime} - ${endTime}\r\n${formatDurationInternal(
+ diff.milliseconds,
+ )}\r\n${agoText}`;
+ };
+
+ const appItems = timeItems[TrackItemType.AppTrackItem] || EMPTY_ARRAY;
+ const logItems = timeItems[TrackItemType.LogTrackItem] || EMPTY_ARRAY;
+ const statusItems = timeItems[TrackItemType.StatusTrackItem] || EMPTY_ARRAY;
+
+ if (!timerange && appItems.length === 0) {
+ return No data
;
+ }
+
+ const timelineData = [...statusItems, ...logItems, ...appItems];
+
+ Logger.debug(`Rendering ${timelineData.length} items`);
+
+ const axisStyle = {
+ grid: { strokeWidth: 0 },
+ ticks: { stroke: 'gray', size: 5 },
+ };
+
+ return (
+
+
+
+ }
+ onClick={zoomIn}
+ size="sm"
+ colorScheme="gray"
+ variant="ghost"
+ />
+
+
+ }
+ onClick={zoomOut}
+ size="sm"
+ colorScheme="gray"
+ variant="ghost"
+ />
+
+
+
+ getDynamicTimeFormat(timestamp, visibleTimerange)}
+ />
+
+ d.beginDate}
+ y0={(d) => d.endDate}
+ data={timelineData}
+ barWidth={BAR_WIDTH}
+ horizontal={true}
+ dataComponent={
+
+ }
+ />
+ (active ? 0.7 : 0.5),
+ cursor: 'move',
+ strokeWidth: 1.5,
+ }}
+ handleComponent={ }
+ handleWidth={10}
+ brushAreaWidth={BAR_WIDTH * 2}
+ brushAreaStyle={{
+ stroke: 'none',
+ fill: 'transparent',
+ opacity: 0.4,
+ cursor: 'move',
+ pointerEvents: 'all',
+ }}
+ allowDrag={true}
+ allowResize={true}
+ allowDraw={true}
+ />
+ }
+ />
+
+
+ );
+});
diff --git a/client/src/components/Timeline/NewLogButton.tsx b/client/src/components/Timeline/NewLogButton.tsx
new file mode 100644
index 00000000..c02a2f92
--- /dev/null
+++ b/client/src/components/Timeline/NewLogButton.tsx
@@ -0,0 +1,31 @@
+import { Button, Tooltip } from '@chakra-ui/react';
+import randomcolor from 'randomcolor';
+import { memo } from 'react';
+import { TrackItemType } from '../../enum/TrackItemType';
+import { useStoreActions, useStoreState } from '../../store/easyPeasy';
+
+export const NewLogButton = memo(() => {
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+ const selectedTimelineItem = useStoreState((state) => state.selectedTimelineItem);
+
+ const setSelectedTimelineItem = useStoreActions((actions) => actions.setSelectedTimelineItem);
+
+ const createNewItem = () => {
+ setSelectedTimelineItem({
+ app: '',
+ title: '',
+ taskName: TrackItemType.LogTrackItem,
+ color: randomcolor(),
+ beginDate: selectedTimelineItem?.beginDate || visibleTimerange[0].valueOf(),
+ endDate: selectedTimelineItem?.endDate || visibleTimerange[1].valueOf(),
+ });
+ };
+
+ return (
+
+
+ New Task
+
+
+ );
+});
diff --git a/client/src/components/Timeline/Search.tsx b/client/src/components/Timeline/Search.tsx
index 10a52079..43a0a497 100644
--- a/client/src/components/Timeline/Search.tsx
+++ b/client/src/components/Timeline/Search.tsx
@@ -1,52 +1,66 @@
-import { Box, Flex } from 'reflexbox';
-import { Button, DatePicker, Tooltip } from 'antd';
-import { LeftOutlined, RightOutlined, PauseOutlined, CaretRightOutlined } from '@ant-design/icons';
-import moment from 'moment';
-import randomcolor from 'randomcolor';
-import React, { memo } from 'react';
-import { getTodayTimerange } from './timeline.utils';
+import { Box, Button, Tooltip, useToast } from '@chakra-ui/react';
+import { OnDatesChangeProps } from '@datepicker-react/hooks';
+import { DateTime } from 'luxon';
+import { memo, useEffect } from 'react';
+import { AiOutlineLeft, AiOutlineRight } from 'react-icons/ai';
+import { FaPlay, FaStop } from 'react-icons/fa';
import { Logger } from '../../logger';
import { useStoreActions, useStoreState } from '../../store/easyPeasy';
import { TIMERANGE_MODE_TODAY } from '../../store/mainStore';
+import { DateRangeInput } from '../Datepicker';
+import { getTodayTimerange } from './timeline.utils';
-const { RangePicker } = DatePicker;
-
-const getDayBefore = d => moment(d).subtract(1, 'days');
-const getDayAfter = d => moment(d).add(1, 'days');
+const getDayBefore = (d: DateTime) => d.minus({ days: 1 });
+const getDayAfter = (d: DateTime) => d.plus({ days: 1 });
export const Search = memo(() => {
- const timerange = useStoreState(state => state.timerange);
- const visibleTimerange = useStoreState(state => state.visibleTimerange);
- const timerangeMode = useStoreState(state => state.timerangeMode);
- const liveView = useStoreState(state => state.liveView);
-
- const setSelectedTimelineItem = useStoreActions(actions => actions.setSelectedTimelineItem);
- const loadTimerange = useStoreActions(actions => actions.loadTimerange);
- const setVisibleTimerange = useStoreActions(actions => actions.setVisibleTimerange);
- const setLiveView = useStoreActions(actions => actions.setLiveView);
-
- const createNewItem = () => {
- setSelectedTimelineItem({
- color: randomcolor(),
- beginDate: visibleTimerange[0].valueOf(),
- endDate: visibleTimerange[1].valueOf(),
+ const timerange = useStoreState((state) => state.timerange);
+ const toast = useToast();
+
+ const timerangeMode = useStoreState((state) => state.timerangeMode);
+ const liveView = useStoreState((state) => state.liveView);
+ const setLiveView = useStoreActions((actions) => actions.setLiveView);
+ const loadTimerange = useStoreActions((actions) => actions.loadTimerange);
+
+ // Show a toast notification for live view status changes
+ const showLiveViewToast = (isEnabled: boolean) => {
+ toast({
+ title: `Live view ${isEnabled ? 'enabled' : 'disabled'}`,
+ status: isEnabled ? 'success' : 'info',
+ duration: 2000,
+ isClosable: true,
+ position: 'top',
});
};
+ // Monitor liveView state changes and show toast
+ useEffect(() => {
+ if (timerangeMode === TIMERANGE_MODE_TODAY) {
+ showLiveViewToast(liveView);
+ }
+ }, [liveView, showLiveViewToast]);
+
const showLiveViewButton = timerangeMode === TIMERANGE_MODE_TODAY;
+
const toggleLiveView = () => {
- setLiveView(!liveView);
+ const newLiveView = !liveView;
+ setLiveView(newLiveView);
+
+ // Log the user action
+ Logger.debug(`User toggled live view to: ${newLiveView ? 'enabled' : 'disabled'}`);
};
- const onChange = (dates: any) => {
- Logger.debug('TIMERANGE:', dates);
- if (dates != null) {
- const beginDate = dates[0];
- const endDate = dates[1];
- const newTimerange = [beginDate, endDate];
- loadTimerange(newTimerange);
- } else {
- Logger.error('No dates selected');
+
+ const handleOnDatesChange = (data: OnDatesChangeProps) => {
+ Logger.debug('TIMERANGE:', data);
+
+ const { startDate, endDate } = data;
+
+ if (!startDate || !endDate) {
+ console.error('NO startDate or endDate');
+ return;
}
+ const newTimerange = [DateTime.fromJSDate(startDate).startOf('day'), DateTime.fromJSDate(endDate).endOf('day')];
+ loadTimerange(newTimerange);
};
const selectToday = () => {
@@ -55,131 +69,65 @@ export const Search = memo(() => {
};
const selectYesterday = () => {
- const beginDate = getDayBefore(moment().startOf('day'));
- const endDate = getDayBefore(moment().endOf('day'));
+ const beginDate = getDayBefore(DateTime.now().startOf('day'));
+ const endDate = getDayBefore(DateTime.now().endOf('day'));
loadTimerange([beginDate, endDate]);
};
const goBackOneDay = () => {
- const beginDate = getDayBefore(moment(timerange[0]));
- const endDate = getDayBefore(moment(timerange[1]));
+ const beginDate = getDayBefore(timerange[0]);
+ const endDate = getDayBefore(timerange[1]);
loadTimerange([beginDate, endDate]);
};
const goForwardOneDay = () => {
- const beginDate = getDayAfter(moment(timerange[0]));
- const endDate = getDayAfter(moment(timerange[1]));
+ const beginDate = getDayAfter(timerange[0]);
+ const endDate = getDayAfter(timerange[1]);
loadTimerange([beginDate, endDate]);
};
- const showDay = () => {
- const beginDate = moment(timerange[0]).startOf('day');
- const endDate = moment(timerange[0]).endOf('day');
- setVisibleTimerange([beginDate, endDate]);
- };
-
- const showHour = () => {
- const beginDate = moment(visibleTimerange[1]).startOf('hour');
- const endDate = moment(visibleTimerange[1]).endOf('hour');
- setVisibleTimerange([beginDate, endDate]);
- };
-
- const showAM = () => {
- const beginDate = moment(timerange[0]).startOf('day');
- const endDate = moment(timerange[0])
- .startOf('day')
- .hour(12);
- setVisibleTimerange([beginDate, endDate]);
- };
-
- const showPM = () => {
- const beginDate = moment(timerange[0])
- .startOf('day')
- .hour(12);
- const endDate = moment(timerange[0]).endOf('day');
- setVisibleTimerange([beginDate, endDate]);
- };
-
- const showEvening = () => {
- const beginDate = moment(visibleTimerange[0])
- .startOf('day')
- .hour(17);
- const endDate = moment(visibleTimerange[0]).endOf('day');
- setVisibleTimerange([beginDate, endDate]);
- };
+ const isYesterday = DateTime.now().minus({ days: 1 }).hasSame(timerange[1], 'day');
- Logger.debug('Have timerange in Search:', timerange);
return (
-
+ <>
- Yesterday
+
+ Yesterday
+
-
-
+
+
-
+
-
-
+
+
-
-
+
+
Today
{showLiveViewButton && (
-
-
- {liveView ? : }
+
+
+ {liveView ? : }
)}
-
-
-
- New Log
-
-
-
-
- All Day
-
-
-
-
- AM
-
-
-
-
- PM
-
-
-
-
- Evening
-
-
-
-
- Hour
-
-
-
+ >
);
});
diff --git a/client/src/components/Timeline/SmallTimelineChart.tsx b/client/src/components/Timeline/SmallTimelineChart.tsx
new file mode 100644
index 00000000..17088db6
--- /dev/null
+++ b/client/src/components/Timeline/SmallTimelineChart.tsx
@@ -0,0 +1,133 @@
+import { debounce } from 'lodash';
+import { DateTime } from 'luxon';
+import { memo } from 'react';
+import {
+ DomainPaddingPropType,
+ DomainTuple,
+ ForAxes,
+ VictoryAxis,
+ VictoryBar,
+ VictoryBrushContainer,
+ VictoryChart,
+ VictoryStyleInterface,
+} from 'victory';
+
+import { Logger } from '../../logger';
+
+import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { useStoreActions, useStoreState } from '../../store/easyPeasy';
+import { rangeToDate } from '../../timeline.util';
+import { colorProp } from '../charts.utils';
+
+import { useMeasure } from '@uidotdev/usehooks';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { TrackItemType } from '../../enum/TrackItemType';
+import { clampRange } from '../PieCharts/MetricTiles.utils';
+import { BrushHandle } from './BrushHandle';
+import { CHART_PADDING, CHART_SCALE } from './timeline.constants';
+import { getDynamicTimeFormat } from './timeline.format.utils';
+import { getTrackItemOrderFn } from './timeline.utils';
+
+const domainPaddingBrush: DomainPaddingPropType = { y: 35, x: 5 };
+
+const brushChartBarStyle = (isDark: boolean): VictoryStyleInterface => ({
+ data: {
+ width: 7,
+ fill: colorProp,
+ stroke: isDark ? 'black' : 'white',
+ strokeWidth: 0.5,
+ fillOpacity: 1,
+ },
+});
+
+const xDomain: DomainTuple = [1, 3];
+
+const EMPTY_ARRAY: ITrackItem[] = [];
+
+export const SmallTimelineChart = memo(() => {
+ const { chartTheme } = useChartThemeState();
+
+ const [ref, { width }] = useMeasure();
+
+ const timerange = useStoreState((state) => state.timerange);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+ const timeItems = useStoreState((state) => state.timeItems);
+ const setVisibleTimerange = useStoreActions((actions) => actions.setVisibleTimerange);
+ const setLiveView = useStoreActions((actions) => actions.setLiveView);
+ const changeVisibleTimerange = (range: [Date, Date]) => {
+ if (DateTime.now().hasSame(timerange[1], 'day')) {
+ setLiveView(false);
+ }
+ setVisibleTimerange(clampRange(timerange, [DateTime.fromJSDate(range[0]), DateTime.fromJSDate(range[1])]));
+ };
+
+ const handleBrush = (domain: { y: [Date, Date] }) => {
+ Logger.debug('Selected with brush:', domain.y);
+
+ changeVisibleTimerange(domain.y);
+ };
+
+ const appTrackItems = timeItems[TrackItemType.AppTrackItem] || EMPTY_ARRAY;
+ const logTrackItems = timeItems[TrackItemType.LogTrackItem] || EMPTY_ARRAY;
+ const statusTrackItems = timeItems[TrackItemType.StatusTrackItem] || EMPTY_ARRAY;
+
+ if (!timerange && appTrackItems.length === 0) {
+ return No data
;
+ }
+
+ const brushData = [...statusTrackItems, ...logTrackItems];
+
+ const handleBrushDebounced = debounce(handleBrush, 300);
+
+ const domain: ForAxes = {
+ y: [timerange[0].toMillis(), timerange[1].toMillis()],
+ x: xDomain,
+ };
+
+ return (
+
+ }
+ onBrushDomainChange={(domain) => {
+ // Convert domain.y from numbers to Dates before passing to handler
+ handleBrushDebounced({ y: [new Date(domain.y[0]), new Date(domain.y[1])] });
+ }}
+ />
+ }
+ >
+ getDynamicTimeFormat(timestamp, visibleTimerange)}
+ />
+
+ d.beginDate}
+ y0={(d) => d.endDate}
+ data={brushData}
+ />
+
+
+ );
+});
diff --git a/client/src/components/Timeline/Timeline.styles.ts b/client/src/components/Timeline/Timeline.styles.ts
deleted file mode 100644
index 9db8d468..00000000
--- a/client/src/components/Timeline/Timeline.styles.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import styled from 'styled-components';
-
-import { Box } from 'reflexbox';
-
-export const MainChart = styled(Box).attrs({ mb: 2, py: 2 })`
- border-top: 1px solid ${({ theme: { variables } }) => variables['@normal-color']};
-
- background-color: ${({ theme: { variables } }) => variables['@normal-color']};
- box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
-`;
-
-export const BrushChart = styled(Box).attrs({ mb: 2, py: 1 })`
- border-top: 1px solid ${({ theme: { variables } }) => variables['@normal-color']};
- border-bottom: 1px solid ${({ theme: { variables } }) => variables['@normal-color']};
- background: ${({ theme: { variables } }) => variables['@component-background']};
- box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.1);
-`;
-
-export const Spinner = styled.div`
- position: absolute;
- text-align: center;
- background: ${({ theme: { variables } }) => variables['@normal-color']};
- opacity: 0.85;
- border-radius: 4px;
- padding: 50px;
- width: 100%;
- height: 100%;
- z-index: 10000;
- margin: auto auto;
-`;
diff --git a/client/src/components/Timeline/Timeline.tsx b/client/src/components/Timeline/Timeline.tsx
index deb9e2f7..5b99498b 100644
--- a/client/src/components/Timeline/Timeline.tsx
+++ b/client/src/components/Timeline/Timeline.tsx
@@ -1,343 +1,57 @@
-import React, { memo, useState } from 'react';
-
-import { useWindowWidth } from '@react-hook/window-size/throttled';
-import { Popover, Spin } from 'antd';
-import { debounce } from 'lodash';
-import moment from 'moment';
-import 'moment-duration-format';
import {
- VictoryAxis,
- VictoryBar,
- VictoryBrushContainer,
- VictoryChart,
- VictoryZoomContainer,
- VictoryBrushLine,
-} from 'victory';
-import { convertDate, TIME_FORMAT } from '../../constants';
-import { TimelineRowType } from '../../enum/TimelineRowType';
-import { TrackItemType } from '../../enum/TrackItemType';
-import { BarWithTooltip } from './BarWithTooltip';
-import { getLabelColor } from './ChartTheme';
-import { BrushChart, MainChart, Spinner } from './Timeline.styles';
-import { TimelineItemEditContainer } from './TimelineItemEditContainer';
-import { Logger } from '../../logger';
-import { formatDuration } from '../SummaryCalendar/SummaryCalendar.util';
-import { colorProp } from '../charts.utils';
-import { useChartThemeState } from '../../routes/ChartThemeProvider';
-import { useStoreActions, useStoreState } from '../../store/easyPeasy';
-import { rangeToDate } from '../../timeline.util';
-
-const getTrackItemOrder = (type: string) => {
- if (type === TrackItemType.AppTrackItem) {
- return 1;
- }
- if (type === TrackItemType.StatusTrackItem) {
- return 2;
- }
- if (type === TrackItemType.LogTrackItem) {
- return 3;
- }
- return 0;
-};
-
-const getTrackItemOrderFn = d => getTrackItemOrder(d.taskName);
-const convertDateForY = d => convertDate(d.beginDate);
-const convertDateForY0 = d => convertDate(d.endDate);
-const brushChartBarStyle: any = {
- data: {
- width: 7,
- fill: colorProp,
- stroke: colorProp,
- strokeWidth: 0.5,
- fillOpacity: 0.75,
- },
-};
-
-const barWidth = 25;
-
-const scale: any = { y: 'time', x: 'linear' };
-const padding: any = { left: 50, top: 0, bottom: 20, right: 10 };
-const domainPadding: any = { y: 35, x: 10 };
-const domainPaddingBrush: any = { y: 35, x: 5 };
-
-const barStyle: any = {
- data: {
- width: barWidth,
- fill: colorProp,
- stroke: colorProp,
- strokeWidth: 0.5,
- fillOpacity: 0.75,
- },
-};
-
-const rowEnabledDefaults = {
- [TimelineRowType.App]: true,
- [TimelineRowType.Log]: true,
- [TimelineRowType.Status]: true,
-};
+ Box,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ useColorModeValue,
+} from '@chakra-ui/react';
+import { memo, useRef } from 'react';
+import { useStoreState } from '../../store/easyPeasy';
+import { BlackBox } from '../BlackBox';
+import { Loader } from './Loader';
+import { MainTimelineChart } from './MainTimelineChart';
+import { SmallTimelineChart } from './SmallTimelineChart';
+import { TimelineItemEdit } from './TimelineItemEdit';
export const Timeline = memo(() => {
- const { chartTheme } = useChartThemeState();
-
- const timerange = useStoreState(state => state.timerange);
- const visibleTimerange = useStoreState(state => state.visibleTimerange);
- const isLoading = useStoreState(state => state.isLoading);
- const timeItems = useStoreState(state => state.timeItems);
-
- const setVisibleTimerange = useStoreActions(actions => actions.setVisibleTimerange);
-
- const selectedTimelineItem = useStoreState(state => state.selectedTimelineItem);
- const setSelectedTimelineItem = useStoreActions(actions => actions.setSelectedTimelineItem);
- const [isRowEnabled, setIsRowEnabled] = useState(rowEnabledDefaults);
-
- const chartWidth = useWindowWidth();
- const toggleRow = (rowId: any) => {
- setIsRowEnabled({ ...isRowEnabled, [rowId - 1]: !isRowEnabled[rowId - 1] });
- };
-
- const handleSelectionChanged = item => {
- if (item) {
- Logger.debug('Selected item:', item);
- setSelectedTimelineItem(item);
- } else {
- Logger.error('No item selected');
- }
- };
-
- const changeVisibleTimerange = range => {
- setVisibleTimerange([moment(range[0]), moment(range[1])]);
- };
-
- const handleZoom = domain => {
- changeVisibleTimerange(domain.y);
- };
-
- const handleBrush = domain => {
- Logger.debug('Selected with brush:', domain.y);
-
- changeVisibleTimerange(domain.y);
- };
+ const popoverTriggerRef = useRef(null);
- const handleEditBrush = (domain, props) => {
- if (domain) {
- console.info('props', props);
- if (props.index === TimelineRowType.Log) {
- const beginDate = convertDate(domain[0]).valueOf();
- const endDate = convertDate(domain[1]).valueOf();
+ const isLoading = useStoreState((state) => state.isLoading);
- Logger.debug('EditBrush changed:', beginDate, endDate);
-
- setSelectedTimelineItem({ ...selectedTimelineItem, beginDate, endDate });
- }
- }
- };
-
- const calcRowEnabledColor = ({ index }) => {
- return getLabelColor(chartTheme.isDark, isRowEnabled[index]);
- };
-
- const calcRowEnabledColorFlipped = index => {
- return getLabelColor(chartTheme.isDark, !isRowEnabled[index]);
- };
-
- const getTooltipLabel = d => {
- const diff = convertDate(d.endDate).diff(convertDate(d.beginDate));
-
- const type = d.taskName === TrackItemType.StatusTrackItem ? 'STATUS' : d.app;
- const beginTime = convertDate(d.beginDate).format(TIME_FORMAT);
- const endTime = convertDate(d.endDate).format(TIME_FORMAT);
-
- const url = d.url ? `${d.url}\r\n` : '';
- return `${type}\r\n${d.title}\r\n${url}${beginTime} - ${endTime}\r\n${formatDuration(
- moment.duration(diff),
- )}`;
- };
-
- const { appItems, logItems, statusItems } = timeItems;
-
- if (!timerange && !appItems && appItems.length === 0) {
- return No data
;
- }
-
- let timelineData = [];
- let brushData = [];
- if (isRowEnabled[TimelineRowType.Status]) {
- // Logger.log('Adding statusItems:', statusItems);
- timelineData = timelineData.concat(statusItems);
- brushData = brushData.concat(statusItems);
- }
- if (isRowEnabled[TimelineRowType.Log]) {
- // Logger.log('Adding logItems:', logItems);
- timelineData = timelineData.concat(logItems);
- brushData = brushData.concat(logItems);
- }
- if (isRowEnabled[TimelineRowType.App]) {
- // Logger.log('Adding appItems:', appItems);
- timelineData = timelineData.concat(appItems);
- }
-
- Logger.debug(`Rendering ${timelineData.length} items`);
-
- const axisEvents: any = [
- {
- target: 'tickLabels',
- eventHandlers: {
- onClick: () => {
- return [
- {
- target: 'tickLabels',
- mutation: props => {
- toggleRow(props.datum);
- return {
- style: {
- ...props.style,
- fill: calcRowEnabledColorFlipped(props.datum),
- },
- };
- },
- },
- ];
- },
- },
- },
- ];
-
- const axisStyle = {
- grid: { strokeWidth: 0 },
- ticks: { stroke: 'grey', size: 5 },
- tickLabels: {
- fill: calcRowEnabledColor,
- },
- };
-
- const handleBrushDebounced = debounce(handleBrush, 300);
- const handleEditBrushDebounced = debounce(handleEditBrush, 300);
-
- const domain: any = {
- y: [timerange[0], timerange[1]],
- x: [1, 3],
- };
+ const selectedTimelineItem = useStoreState((state) => state.selectedTimelineItem);
return (
-
-
- {isLoading && (
-
-
-
- )}
- }
- visible={!!selectedTimelineItem}
- trigger="click"
- >
-
- }
+
+
+
+ {isLoading && }
+
+
+
+
+
+
+
-
-
-
- }
- />
- (active ? 0.5 : 0.4),
- }}
- brushAreaStyle={{
- stroke: 'none',
- fill: 'transparent',
- opacity: 0,
- }}
- />
- }
- />
-
+
+
+
+
+
-
-
-
- }
- >
-
-
-
-
-
-
+
+
+
+ {isLoading && }
+
+
+
+
);
});
diff --git a/client/src/components/Timeline/TimelineItemEdit.styles.ts b/client/src/components/Timeline/TimelineItemEdit.styles.ts
deleted file mode 100644
index f6d83c4f..00000000
--- a/client/src/components/Timeline/TimelineItemEdit.styles.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import styled from 'styled-components';
-
-export const EditForm = styled.div``;
diff --git a/client/src/components/Timeline/TimelineItemEdit.tsx b/client/src/components/Timeline/TimelineItemEdit.tsx
index a8ba6984..684ca67b 100644
--- a/client/src/components/Timeline/TimelineItemEdit.tsx
+++ b/client/src/components/Timeline/TimelineItemEdit.tsx
@@ -1,292 +1,264 @@
-import { Box, Flex } from 'reflexbox';
-import { Typography, Button, Divider, Input, Modal, Select, TimePicker, Tooltip } from 'antd';
-import {
- PlayCircleOutlined,
- SaveOutlined,
- DeleteOutlined,
- CloseOutlined,
- ExclamationCircleOutlined,
-} from '@ant-design/icons';
-import React, { useState, useEffect, memo } from 'react';
-import { ColorPicker } from './ColorPicker';
+import { Box, Button, Divider, Heading, Input, Select, Text, Tooltip } from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import randomcolor from 'randomcolor';
+import { memo, useEffect, useState } from 'react';
+import { AiOutlineClose, AiOutlineSave } from 'react-icons/ai';
+import { TIME_FORMAT_SHORT } from '../../constants';
import { Logger } from '../../logger';
-import moment from 'moment';
+import { ColorPicker } from './ColorPicker';
+import { TimelineItemEditDeleteButton } from './TimelineItemEditDeleteButton';
+
+import { HStack, VStack } from '@chakra-ui/react';
+import { ITrackItem, SelectedTrackItem } from '../../@types/ITrackItem';
import { TrackItemType } from '../../enum/TrackItemType';
+import { changeColorForApp } from '../../services/appSettings.api';
+import { deleteByIds, saveTrackItem, updateTrackItemColor } from '../../services/trackItem.api';
+import { useStoreActions, useStoreState } from '../../store/easyPeasy';
+import { ITEM_TYPES } from '../../utils';
+
+type ColorScope = 'ONLY_THIS' | 'NEW_ITEMS' | 'ALL_ITEMS';
+
+const COLOR_SCOPE_ONLY_THIS: ColorScope = 'ONLY_THIS';
+
+export const TimelineItemEdit = memo(() => {
+ const selectedTimelineItem = useStoreState((state) => state.selectedTimelineItem);
+ const setSelectedTimelineItem = useStoreActions((actions) => actions.setSelectedTimelineItem);
+ const fetchTimerange = useStoreActions((actions) => actions.fetchTimerange);
+
+ const [state, setState] = useState<{
+ trackItem: SelectedTrackItem | null;
+ colorScope: ColorScope;
+ }>({
+ trackItem: selectedTimelineItem,
+ colorScope: COLOR_SCOPE_ONLY_THIS,
+ });
-const { Title } = Typography;
-
-interface IProps {
- selectedTimelineItem: any;
- saveTimelineItem: any;
- changeColorForApp?: any;
- trayEdit?: boolean;
- clearTimelineItem?: any;
- deleteTimelineItem?: any;
-}
-
-const COLOR_SCOPE_ONLY_THIS = 'ONLY_THIS';
-/*
-function propsAreEqual(prev, next) {
- if (prev.selectedTimelineItem && next.selectedTimelineItem) {
- const equalById = prev.selectedTimelineItem.id === next.selectedTimelineItem.id;
- if (!next.selectedTimelineItem.id) {
- return prev.selectedTimelineItem === next.selectedTimelineItem;
+ const { trackItem, colorScope } = state;
+
+ const deleteTimelineItem = async () => {
+ const id = trackItem?.id;
+ Logger.debug('Delete timeline trackItem', id);
+
+ if (id) {
+ await deleteByIds([id]);
+ Logger.debug('Deleted timeline items', id);
+ fetchTimerange();
+ setSelectedTimelineItem(null);
+ } else {
+ Logger.error('No ids, not deleting from DB');
}
+ };
- return equalById;
- }
+ const readOnly = selectedTimelineItem?.taskName !== TrackItemType.LogTrackItem;
+
+ const saveTimelineItem = async () => {
+ Logger.debug('Updating color for trackItem', trackItem, colorScope);
+ if (!trackItem) {
+ Logger.error('No trackItem');
+ return;
+ }
+ try {
+ const color = trackItem.color || randomcolor();
+ if (colorScope === 'ALL_ITEMS') {
+ await changeColorForApp(trackItem.app, color);
+ await updateTrackItemColor(trackItem.app, color);
+ } else if (colorScope === 'NEW_ITEMS') {
+ await changeColorForApp(trackItem.app, color);
+ await saveTrackItem(trackItem as ITrackItem);
+ } else {
+ await saveTrackItem(trackItem as ITrackItem);
+ }
+ } catch (error) {
+ Logger.error('Saving track item failed', error);
+ }
- return false;
-}*/
-
-const statusName = {
- [TrackItemType.AppTrackItem]: 'App',
- [TrackItemType.StatusTrackItem]: 'Status',
- [TrackItemType.LogTrackItem]: 'Log',
-};
-
-export const TimelineItemEdit = memo(
- ({
- selectedTimelineItem,
- trayEdit,
- clearTimelineItem,
- saveTimelineItem,
- deleteTimelineItem,
- }) => {
- const [state, setState] = useState({
+ setSelectedTimelineItem(null);
+ fetchTimerange();
+ };
+
+ const clearTimelineItem = () => setSelectedTimelineItem(null);
+
+ useEffect(() => {
+ Logger.debug('Selected timelineitem changed:', selectedTimelineItem);
+
+ setState({
trackItem: selectedTimelineItem,
colorScope: COLOR_SCOPE_ONLY_THIS,
});
+ }, [selectedTimelineItem]);
- const { trackItem, colorScope } = state;
+ const changeColorHandler = (color) => {
+ Logger.debug('Changed color:', color);
- useEffect(() => {
- Logger.debug('Selected timelineitem changed:', selectedTimelineItem);
- if (selectedTimelineItem) {
- setState({
- trackItem: selectedTimelineItem,
- colorScope: COLOR_SCOPE_ONLY_THIS,
- });
- }
- }, [selectedTimelineItem]);
-
- const changeColorHandler = color => {
- Logger.debug('Changed color:', color);
-
- setState({
- ...state,
+ setState((oldState) => {
+ if (!oldState.trackItem) return oldState;
+ return {
+ ...oldState,
trackItem: {
- ...state.trackItem,
+ ...oldState.trackItem,
color,
- },
- });
- };
+ } as ITrackItem,
+ };
+ });
+ };
- const changeAppName = e => {
- const { value } = e.target;
- Logger.debug('Changed app name:', value);
+ const changeAppName = (e) => {
+ const { value }: { value: string } = e.target;
+ Logger.debug('Changed app name:', value);
- setState({
- ...state,
+ setState((oldState) => {
+ if (!oldState.trackItem) return oldState;
+ return {
+ ...oldState,
trackItem: {
- ...state.trackItem,
+ ...oldState.trackItem,
app: value,
- },
- });
- };
- const changeTime = attr => value => {
- Logger.debug('Changed app time:', value);
- const oldDate = moment(state.trackItem[attr]);
- const newDate = moment(
- value
- .toArray()
- .slice(0, 4)
- .concat(oldDate.toArray().slice(4)),
- );
- setState({
- ...state,
+ } as ITrackItem,
+ };
+ });
+ };
+
+ const changeTime = (attr) => (timeStr: string) => {
+ Logger.debug('Changed app time:', timeStr);
+ const [hours, minutes] = timeStr.split(':').map(Number);
+ const oldDate = DateTime.fromJSDate(new Date(state.trackItem?.[attr] || 0));
+ const newDate = oldDate.startOf('day').set({ hour: hours, minute: minutes });
+
+ setState((oldState) => {
+ if (!oldState.trackItem) return oldState;
+ return {
+ ...oldState,
trackItem: {
- ...state.trackItem,
- [attr]: newDate.valueOf(),
- },
- });
- };
-
- const changeAppTitle = e => {
- const { value } = e.target;
- Logger.debug('Changed app title:', value);
-
- setState({
- ...state,
+ ...oldState.trackItem,
+ [attr]: newDate.toMillis(),
+ } as ITrackItem,
+ };
+ });
+ };
+
+ const changeAppTitle = (e) => {
+ const { value } = e.target;
+ Logger.debug('Changed app title:', value);
+
+ setState((oldState) => {
+ if (!oldState.trackItem) return oldState;
+ return {
+ ...oldState,
trackItem: {
- ...state.trackItem,
+ ...oldState.trackItem,
title: value,
- },
- });
- };
-
- const closeEdit = () => {
- Logger.debug('Close TimelineItem');
- clearTimelineItem();
- };
-
- const changeColorScopeHandler = colorScope => {
- Logger.debug('Changed color scope:', colorScope);
-
- setState({
- ...state,
- colorScope,
- });
- };
-
- const saveBasedOnColorOptionHandler = () => {
- const { trackItem, colorScope } = state;
-
- saveTimelineItem(trackItem, colorScope);
- };
- const deleteItem = () => {
- const { trackItem } = state;
-
- deleteTimelineItem(trackItem);
- };
-
- const showDeleteConfirm = () => {
- Modal.confirm({
- title: 'Delete',
- icon: ,
- content: 'Sure you want to delete?',
- okText: 'Delete',
- cancelText: 'Cancel',
- onOk: deleteItem,
- zIndex: 10000,
- });
- };
-
- if (!selectedTimelineItem) {
- Logger.debug('No trackItem');
- return null;
- }
+ } as ITrackItem,
+ };
+ });
+ };
- const colorChanged = selectedTimelineItem.color !== trackItem.color;
+ const closeEdit = () => {
+ Logger.debug('Close TimelineItem');
+ clearTimelineItem();
+ };
- if (trayEdit) {
- return (
-
-
-
-
-
-
-
+ const changeColorScopeHandler = (e) => {
+ const { value } = e.target;
+ Logger.debug('Changed color scope:', value);
-
-
-
+ setState((oldState) => ({
+ ...oldState,
+ colorScope: value,
+ }));
+ };
-
- }
- onClick={saveBasedOnColorOptionHandler}
- />
-
+ const saveBasedOnColorOptionHandler = () => {
+ saveTimelineItem();
+ setState((oldState) => {
+ if (!oldState.trackItem) return oldState;
+ return {
+ ...oldState,
+ trackItem: {
+ ...oldState.trackItem,
+ app: '',
+ title: '',
+ color: randomcolor(),
+ } as ITrackItem,
+ };
+ });
+ };
-
- }
- onClick={closeEdit}
- />
-
-
- );
- }
+ if (!selectedTimelineItem || !trackItem) {
+ Logger.debug('No trackItem');
+ return null;
+ }
+
+ const colorChanged = selectedTimelineItem.color !== trackItem.color;
+ const isCreating = !selectedTimelineItem.id;
- return (
-
-
- {statusName[trackItem.taskName] || 'New Log item'}
-
-
-
-
+ console.log('trackItem', trackItem);
+
+ return (
+
+
+
+ {trackItem.id && trackItem.taskName ? ITEM_TYPES[trackItem.taskName] : 'New Task'}
+
+
+
+
-
+
changeTime('beginDate')(e.target.value)}
+ readOnly={readOnly}
/>
-
-
-
-
-
-
- {colorChanged && (
-
-
- This trackItem
- Future items
- All items
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- } onClick={closeEdit}>
- Close
-
-
-
-
- } onClick={showDeleteConfirm}>
- Delete
-
+ ➜
+
+ changeTime('endDate')(e.target.value)}
+ readOnly={readOnly}
+ />
-
- }
- onClick={saveBasedOnColorOptionHandler}
- >
- Save
-
+
+
+
+
+
+
+
-
+ {colorChanged && (
+
+
+ This trackItem
+ Future items
+ All items
+
+
+ )}
+
+
+
+
+
- );
- },
-);
+
+ {!isCreating && (
+
+
+
+ )}
+
+
+ } onClick={closeEdit}>
+ Cancel
+
+
+ } onClick={saveBasedOnColorOptionHandler}>
+ {isCreating ? 'Create' : 'Update'}
+
+
+
+ );
+});
diff --git a/client/src/components/Timeline/TimelineItemEditContainer.tsx b/client/src/components/Timeline/TimelineItemEditContainer.tsx
deleted file mode 100644
index aa6b1d65..00000000
--- a/client/src/components/Timeline/TimelineItemEditContainer.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React, { memo } from 'react';
-import { changeColorForApp } from '../../services/appSettings.api';
-import { saveTrackItem, deleteByIds, updateTrackItemColor } from '../../services/trackItem.api';
-import { TimelineItemEdit } from './TimelineItemEdit';
-import { Logger } from '../../logger';
-import { useStoreActions, useStoreState } from '../../store/easyPeasy';
-
-export const TimelineItemEditContainer = memo(() => {
- const selectedTimelineItem = useStoreState(state => state.selectedTimelineItem);
- const setSelectedTimelineItem = useStoreActions(actions => actions.setSelectedTimelineItem);
- const fetchTimerange = useStoreActions(actions => actions.fetchTimerange);
-
- const deleteTimelineItem = async trackItem => {
- const id = trackItem.id;
- Logger.debug('Delete timeline trackItem', id);
-
- if (id) {
- await deleteByIds([id]);
- Logger.debug('Deleted timeline items', id);
- fetchTimerange();
- setSelectedTimelineItem(null);
- } else {
- Logger.error('No ids, not deleting from DB');
- }
- };
-
- const saveTimelineItem = async (trackItem, colorScope) => {
- Logger.debug('Updating color for trackItem', trackItem, colorScope);
- if (colorScope === 'ALL_ITEMS') {
- await changeColorForApp(trackItem.app, trackItem.color);
- await updateTrackItemColor(trackItem.app, trackItem.color);
- } else if (colorScope === 'NEW_ITEMS') {
- await changeColorForApp(trackItem.app, trackItem.color);
- await saveTrackItem(trackItem);
- } else {
- await saveTrackItem(trackItem);
- }
-
- setSelectedTimelineItem(null);
- fetchTimerange();
- };
-
- const clearTimelineItem = () => setSelectedTimelineItem(null);
-
- const moreProps = {
- deleteTimelineItem,
- clearTimelineItem,
- saveTimelineItem,
- };
- return ;
-});
diff --git a/client/src/components/Timeline/TimelineItemEditDeleteButton.tsx b/client/src/components/Timeline/TimelineItemEditDeleteButton.tsx
new file mode 100644
index 00000000..123714bb
--- /dev/null
+++ b/client/src/components/Timeline/TimelineItemEditDeleteButton.tsx
@@ -0,0 +1,50 @@
+import { Button } from '@chakra-ui/react';
+import {
+ AlertDialog,
+ AlertDialogBody,
+ AlertDialogContent,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogOverlay,
+} from '@chakra-ui/react';
+import { useRef, useState } from 'react';
+import { AiOutlineDelete } from 'react-icons/ai';
+
+export function TimelineItemEditDeleteButton({ deleteItem }) {
+ const [isOpen, setIsOpen] = useState(false);
+ const onClose = () => setIsOpen(false);
+ const onDelete = () => {
+ deleteItem();
+ onClose();
+ };
+ const cancelRef = useRef(null!);
+
+ return (
+ <>
+ } onClick={() => setIsOpen(true)}>
+ Delete
+
+
+
+
+
+
+ Delete Timeline Item
+
+
+ Are you sure? You can't undo this action afterwards.
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+
+ >
+ );
+}
diff --git a/client/src/components/Timeline/VisibleRange.tsx b/client/src/components/Timeline/VisibleRange.tsx
new file mode 100644
index 00000000..c6fbe25e
--- /dev/null
+++ b/client/src/components/Timeline/VisibleRange.tsx
@@ -0,0 +1,219 @@
+import { Box, Button, Flex, HStack, Input, Text, useColorModeValue } from '@chakra-ui/react';
+import { memo, useEffect, useMemo, useState } from 'react';
+import { TIME_FORMAT_SHORT } from '../../constants';
+import { useStoreActions, useStoreState } from '../../store/easyPeasy';
+
+// Range types to identify different visible range options
+export enum RangeType {
+ ALL_DAY = 'ALL_DAY',
+ AM = 'AM',
+ PM = 'PM',
+ EVENING = 'EVENING',
+ HOUR = 'HOUR',
+ CUSTOM = 'CUSTOM',
+}
+
+export const VisibleRange = memo(() => {
+ const timerange = useStoreState((state) => state.timerange);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+ const setVisibleTimerange = useStoreActions((actions) => actions.setVisibleTimerange);
+
+ // State for time inputs
+ const [startTime, setStartTime] = useState('');
+ const [endTime, setEndTime] = useState('');
+
+ // Flag to prevent infinite update loops
+ const [updatingFromTimerange, setUpdatingFromTimerange] = useState(false);
+
+ // Update time inputs when visibleTimerange changes
+ useEffect(() => {
+ if (visibleTimerange && visibleTimerange.length === 2) {
+ setUpdatingFromTimerange(true);
+ setStartTime(visibleTimerange[0].toFormat(TIME_FORMAT_SHORT));
+ setEndTime(visibleTimerange[1].toFormat(TIME_FORMAT_SHORT));
+ // Reset the flag after state updates
+ setTimeout(() => setUpdatingFromTimerange(false), 0);
+ }
+ }, [visibleTimerange]);
+
+ // Determine which range type is currently active based on the visible timerange
+ const activeRange = useMemo(() => {
+ if (!visibleTimerange || visibleTimerange.length !== 2) {
+ return null;
+ }
+
+ const [start, end] = visibleTimerange;
+ const dayStart = timerange[0].startOf('day');
+ const dayEnd = timerange[0].endOf('day');
+ const noon = dayStart.set({ hour: 12 });
+ const evening = dayStart.set({ hour: 17 });
+
+ // Check if it's the whole day
+ if (start.hasSame(dayStart, 'minute') && end.hasSame(dayEnd, 'minute')) {
+ return RangeType.ALL_DAY;
+ }
+
+ // Check if it's AM
+ if (start.hasSame(dayStart, 'minute') && end.hasSame(noon, 'minute')) {
+ return RangeType.AM;
+ }
+
+ // Check if it's PM
+ if (start.hasSame(noon, 'minute') && end.hasSame(dayEnd, 'minute')) {
+ return RangeType.PM;
+ }
+
+ // Check if it's Evening
+ if (start.hasSame(evening, 'minute') && end.hasSame(dayEnd, 'minute')) {
+ return RangeType.EVENING;
+ }
+
+ const diff = end.diff(start);
+
+ // Check if it's an hourly view (one hour duration)
+ if (Math.abs(diff.as('hours') - 1) < 0.05) {
+ return RangeType.HOUR;
+ }
+
+ return RangeType.CUSTOM;
+ }, [visibleTimerange, timerange]);
+
+ const showDay = () => {
+ const beginDate = timerange[0].startOf('day');
+ const endDate = timerange[0].endOf('day');
+ setVisibleTimerange([beginDate, endDate]);
+ };
+
+ const showHour = () => {
+ const beginDate = visibleTimerange[0].startOf('hour');
+ const endDate = beginDate.plus({ hours: 1 });
+ setVisibleTimerange([beginDate, endDate]);
+ };
+
+ const showAM = () => {
+ const beginDate = timerange[0].startOf('day');
+ const endDate = timerange[0].startOf('day').set({ hour: 12 });
+ setVisibleTimerange([beginDate, endDate]);
+ };
+
+ const showPM = () => {
+ const beginDate = timerange[0].startOf('day').set({ hour: 12 });
+ const endDate = timerange[0].endOf('day');
+ setVisibleTimerange([beginDate, endDate]);
+ };
+
+ const showEvening = () => {
+ const beginDate = visibleTimerange[0].startOf('day').set({ hour: 17 });
+ const endDate = visibleTimerange[0].endOf('day');
+ setVisibleTimerange([beginDate, endDate]);
+ };
+
+ // Apply the custom time range
+ const updateCustomTimeRange = (newStartTime, newEndTime) => {
+ if (!newStartTime || !newEndTime) return;
+
+ try {
+ const [startHours, startMinutes] = newStartTime.split(':').map(Number);
+ const [endHours, endMinutes] = newEndTime.split(':').map(Number);
+
+ if (isNaN(startHours) || isNaN(startMinutes) || isNaN(endHours) || isNaN(endMinutes)) {
+ return;
+ }
+
+ const beginDate = timerange[0].startOf('day').set({
+ hour: startHours,
+ minute: startMinutes || 0,
+ });
+
+ const endDate = timerange[0].startOf('day').set({
+ hour: endHours,
+ minute: endMinutes || 0,
+ });
+
+ // If end time is earlier than start time, assume it's for the next day
+ const finalEndDate = endDate < beginDate ? endDate.plus({ days: 1 }) : endDate;
+
+ setVisibleTimerange([beginDate, finalEndDate]);
+ } catch (err) {
+ console.error('Error parsing time inputs:', err);
+ }
+ };
+
+ // Handler for time input changes - directly updates the timerange
+ const handleTimeChange = (timeType) => (e) => {
+ const timeStr = e.target.value;
+
+ // Don't update if we're currently syncing from timerange changes
+ if (updatingFromTimerange) return;
+
+ if (timeType === 'start') {
+ setStartTime(timeStr);
+ updateCustomTimeRange(timeStr, endTime);
+ } else {
+ setEndTime(timeStr);
+ updateCustomTimeRange(startTime, timeStr);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ ➜
+
+
+
+
+
+
+
+ Visible range
+
+
+
+
+ All Day
+
+
+
+ AM
+
+
+
+ PM
+
+
+
+ Evening
+
+
+ Hour
+
+
+
+
+ );
+});
diff --git a/client/src/components/Timeline/timeline.constants.ts b/client/src/components/Timeline/timeline.constants.ts
new file mode 100644
index 00000000..ea049073
--- /dev/null
+++ b/client/src/components/Timeline/timeline.constants.ts
@@ -0,0 +1,10 @@
+import { D3Scale, Padding, ScalePropType } from 'victory';
+
+export const BAR_WIDTH = 25;
+
+export const CHART_PADDING: Padding = { left: 0, top: 0, bottom: 20, right: 10 };
+
+export const CHART_SCALE: {
+ x?: ScalePropType | D3Scale;
+ y?: ScalePropType | D3Scale;
+} = { y: 'time', x: 'linear' };
diff --git a/client/src/components/Timeline/timeline.format.utils.ts b/client/src/components/Timeline/timeline.format.utils.ts
new file mode 100644
index 00000000..304970bb
--- /dev/null
+++ b/client/src/components/Timeline/timeline.format.utils.ts
@@ -0,0 +1,36 @@
+import { DateTime } from 'luxon';
+import { convertDate } from '../../constants';
+
+/**
+ * Gets a dynamic time format function based on the visible time range
+ *
+ * @param timestamp - The timestamp to format
+ * @param visibleTimerange - The current visible time range (optional)
+ * @returns Formatted time string appropriate for the current zoom level
+ */
+export const getDynamicTimeFormat = (timestamp: number, visibleTimerange?: DateTime[]) => {
+ // Get the current system locale for date/time formatting
+ const locale = DateTime.local().resolvedLocaleOptions().locale;
+ const dateTime = convertDate(timestamp);
+
+ // If no visible timerange is set yet, use the default format
+ if (!visibleTimerange || visibleTimerange.length !== 2) {
+ return dateTime.setLocale(locale).toLocaleString(DateTime.TIME_WITH_SECONDS);
+ }
+
+ // Calculate the duration of the visible range in milliseconds
+ const [start, end] = visibleTimerange;
+ const rangeDuration = end.diff(start).milliseconds;
+
+ // Adjust format based on zoom level
+ if (rangeDuration > 24 * 60 * 60 * 1000) {
+ // More than 24 hours - show date and time
+ return dateTime.setLocale(locale).toLocaleString({ ...DateTime.DATE_MED_WITH_WEEKDAY, year: undefined });
+ } else if (rangeDuration > 60 * 60 * 1000) {
+ // More than 1 hour - show hours and minutes
+ return dateTime.setLocale(locale).toLocaleString(DateTime.TIME_SIMPLE);
+ } else {
+ // Zoomed in - show hours, minutes and seconds
+ return dateTime.setLocale(locale).toLocaleString(DateTime.TIME_WITH_SECONDS);
+ }
+};
diff --git a/client/src/components/Timeline/timeline.utils.test.ts b/client/src/components/Timeline/timeline.utils.test.ts
new file mode 100644
index 00000000..b25431c1
--- /dev/null
+++ b/client/src/components/Timeline/timeline.utils.test.ts
@@ -0,0 +1,99 @@
+import { DateTime } from 'luxon';
+import { describe, expect, it } from 'vitest';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { filterItems } from './timeline.utils';
+
+describe('Timeline utils', () => {
+ it('filterItems in range items', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items: ITrackItem[] = [
+ {
+ id: 1,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T17:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T17:10:00.000Z').toMillis(),
+ },
+ {
+ id: 2,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ {
+ id: 3,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T18:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:00:00.000Z').toMillis(),
+ },
+ ];
+ const actual = filterItems(items, visibleTimerange);
+ expect(actual).toEqual(items);
+ });
+ it('filterItems does not include out of range items', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items = [
+ {
+ id: 1,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ ];
+ const outOfRangeItems: ITrackItem[] = [
+ {
+ id: 1,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T16:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T16:59:59.000Z').toMillis(),
+ },
+
+ {
+ id: 2,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T19:00:01.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:10:00.000Z').toMillis(),
+ },
+ ];
+ const actual = filterItems([...items, ...outOfRangeItems], visibleTimerange);
+ expect(actual).toEqual(items);
+ });
+
+ it('filterItems returns items that are partly in', () => {
+ const visibleTimerange = [
+ DateTime.fromISO('2021-06-19T17:00:00.000Z'),
+ DateTime.fromISO('2021-06-19T19:00:00.000Z'),
+ ];
+ const items = [
+ {
+ id: 1,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T16:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T17:10:00.000Z').toMillis(),
+ },
+ {
+ id: 2,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T18:00:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T18:10:00.000Z').toMillis(),
+ },
+ {
+ id: 3,
+ app: 'app',
+ beginDate: DateTime.fromISO('2021-06-19T18:50:00.000Z').toMillis(),
+ endDate: DateTime.fromISO('2021-06-19T19:10:00.000Z').toMillis(),
+ },
+ ];
+ const actual = filterItems(items, visibleTimerange);
+ expect(actual).toEqual(items);
+ });
+});
+
+export // Use an empty export to please Babel's single file emit.
+// https://github.com/Microsoft/TypeScript/issues/15230
+ {};
diff --git a/client/src/components/Timeline/timeline.utils.ts b/client/src/components/Timeline/timeline.utils.ts
index 37113d69..789f2389 100644
--- a/client/src/components/Timeline/timeline.utils.ts
+++ b/client/src/components/Timeline/timeline.utils.ts
@@ -1,20 +1,28 @@
import _ from 'lodash';
-import moment from 'moment';
+import { DateTime } from 'luxon';
+import { ITrackItem } from '../../@types/ITrackItem';
import { convertDate } from '../../constants';
+import { TrackItemType } from '../../enum/TrackItemType';
-export const filterItems = (timeItems, visibleTimerange) =>
- timeItems.filter(item => {
+export const filterItems = (timeItems: ITrackItem[], visibleTimerange: DateTime[]) => {
+ if (!timeItems) {
+ console.warn('No time items found');
+ return [] as ITrackItem[];
+ }
+
+ return timeItems.filter((item) => {
const itemBegin = convertDate(item.beginDate);
const itemEnd = convertDate(item.endDate);
- const visBegin = visibleTimerange[0];
- const visEnd = visibleTimerange[1];
- return itemBegin.isBetween(visBegin, visEnd) && itemEnd.isBetween(visBegin, visEnd);
+ const [visBegin, visEnd] = visibleTimerange;
+
+ return (itemBegin >= visBegin && itemBegin <= visEnd) || (itemEnd >= visBegin && itemEnd <= visEnd);
});
+};
-export const aggregateappItems = items => {
+export const aggregateappItems = (items) => {
_.reduce(
items,
- result => {
+ (result) => {
const currVal = result; // result[value.id](result[value.id] || (result[value.id] = [])).push(key);
return currVal;
},
@@ -22,55 +30,74 @@ export const aggregateappItems = items => {
);
};
-export const copyTime = (from, to) =>
- moment(from).set({
- hour: to.hour(),
- minute: to.minute(),
+export const copyTime = (from: DateTime, to: DateTime): DateTime =>
+ DateTime.fromJSDate(from.toJSDate()).set({
+ hour: to.hour,
+ minute: to.minute,
});
-export const setDayFromTimerange = (visibleTimerange, timerange) => [
+export const setDayFromTimerange = (visibleTimerange: DateTime[], timerange: DateTime[]): DateTime[] => [
copyTime(timerange[0], visibleTimerange[0]),
copyTime(timerange[1], visibleTimerange[1]),
];
-export const getTodayTimerange = () => [moment().startOf('day'), moment().endOf('day')];
+export const getTodayTimerange = (): DateTime[] => [DateTime.now().startOf('day'), DateTime.now().endOf('day')];
-export const getCenteredTimerange = (timerange, visibleTimerange, middleTime) => {
- const timeBetweenMs = moment(visibleTimerange[1]).diff(visibleTimerange[0]);
+export const getCenteredTimerange = (
+ timerange: DateTime[],
+ visibleTimerange: DateTime[],
+ middleTime: DateTime,
+): DateTime[] => {
+ const timeBetweenMs = visibleTimerange[1].diff(visibleTimerange[0]).milliseconds;
const middlePoint = timeBetweenMs / 5;
- let beginDate = moment(middleTime).subtract(timeBetweenMs - middlePoint, 'milliseconds');
- let endDate = moment(middleTime).add(middlePoint, 'milliseconds');
+ let beginDate = middleTime.minus({ milliseconds: timeBetweenMs - middlePoint });
+ let endDate = middleTime.plus({ milliseconds: middlePoint });
// if new beginDate is smaller than actual timerange, then cap it with timeranges beginDate
- const underTime = moment(timerange[0]).diff(beginDate);
+ const underTime = timerange[0].diff(beginDate).milliseconds;
if (underTime > 0) {
- beginDate = moment(timerange[0]);
- endDate = moment(endDate).add(underTime, 'milliseconds');
+ beginDate = timerange[0];
+ endDate = endDate.plus({ milliseconds: underTime });
}
// if new endDate is bigger than actual timeranges endDate, then cap it with timeranges endDate
- const overTime = moment(endDate).diff(timerange[1]);
+ const overTime = endDate.diff(timerange[1]).milliseconds;
if (overTime > 0) {
- endDate = moment(timerange[1]);
- beginDate = moment(beginDate).subtract(overTime, 'milliseconds');
+ endDate = timerange[1];
+ beginDate = beginDate.minus({ milliseconds: overTime });
//edge case, if we have 23h visible timerange, then cap it with timeranges beginDate
- if (moment(timerange[0]).diff(beginDate) > 0) {
- beginDate = moment(timerange[0]);
+ if (timerange[0].diff(beginDate).milliseconds > 0) {
+ beginDate = timerange[0];
}
}
return [beginDate, endDate];
};
-export const getUniqueAppNames = appItems =>
+export const getUniqueAppNames = (appItems) =>
_(appItems)
.map('app')
.uniq()
- .orderBy([app => app.toLowerCase()])
- .map(app => ({
+ .orderBy([(app) => app.toLowerCase()])
+ .map((app) => ({
text: app,
value: app,
}))
.value();
+
+export const getTrackItemOrder = (type: string) => {
+ if (type === TrackItemType.AppTrackItem) {
+ return 1;
+ }
+ if (type === TrackItemType.StatusTrackItem) {
+ return 2;
+ }
+ if (type === TrackItemType.LogTrackItem) {
+ return 3;
+ }
+ return 0;
+};
+
+export const getTrackItemOrderFn = (d) => getTrackItemOrder(d.taskName);
diff --git a/client/src/components/TrackItemTable/DefaultColumnFilter.tsx b/client/src/components/TrackItemTable/DefaultColumnFilter.tsx
new file mode 100644
index 00000000..4b7d14e9
--- /dev/null
+++ b/client/src/components/TrackItemTable/DefaultColumnFilter.tsx
@@ -0,0 +1,18 @@
+import { Input } from '@chakra-ui/react';
+import { ToggleColumnFilter } from './ToggleColumnFilter';
+
+export function DefaultColumnFilter({ column }) {
+ const filterValue = column.getFilterValue() || '';
+
+ return (
+
+ {
+ column.setFilterValue(e.target.value || undefined); // Set undefined to remove the filter entirely
+ }}
+ placeholder={``}
+ />
+
+ );
+}
diff --git a/client/src/components/TrackItemTable/IndeterminateCheckbox.tsx b/client/src/components/TrackItemTable/IndeterminateCheckbox.tsx
new file mode 100644
index 00000000..f7c32896
--- /dev/null
+++ b/client/src/components/TrackItemTable/IndeterminateCheckbox.tsx
@@ -0,0 +1,43 @@
+import { Box } from '@chakra-ui/react';
+import React, { useEffect, useRef } from 'react';
+
+interface IndeterminateCheckboxProps {
+ indeterminate?: boolean;
+ checked?: boolean;
+ disabled?: boolean;
+ onChange?: (e: React.ChangeEvent) => void;
+}
+
+export const IndeterminateCheckbox: React.FC = ({
+ indeterminate = false,
+ checked = false,
+ disabled = false,
+ onChange,
+ ...rest
+}) => {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.indeterminate = !!indeterminate;
+ }
+ }, [indeterminate]);
+
+ return (
+
+
+
+ );
+};
diff --git a/client/src/components/TrackItemTable/ItemsTable.tsx b/client/src/components/TrackItemTable/ItemsTable.tsx
new file mode 100644
index 00000000..3059f665
--- /dev/null
+++ b/client/src/components/TrackItemTable/ItemsTable.tsx
@@ -0,0 +1,351 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import { diffAndFormatShort, formatDurationInternal } from '../../utils';
+
+import { TriangleDownIcon, TriangleUpIcon } from '@chakra-ui/icons';
+import { Box, Button, Flex, Table, Tbody, Td, Th, Thead, Tr } from '@chakra-ui/react';
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ FilterFn,
+ flexRender,
+ getCoreRowModel,
+ getFacetedMinMaxValues,
+ getFacetedRowModel,
+ getFacetedUniqueValues,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ getSortedRowModel,
+ PaginationState,
+ SortingState,
+ useReactTable,
+} from '@tanstack/react-table';
+
+import { Portal } from '@chakra-ui/react';
+import { DATE_TIME_FORMAT } from '../../constants';
+import { DefaultColumnFilter } from './DefaultColumnFilter';
+import { IndeterminateCheckbox } from './IndeterminateCheckbox';
+import { OverflowTextCell } from './OverflowText';
+import { SelectColumnFilter } from './SelectColumnFilter';
+import { fuzzyTextFilterFn, TableButtonsProps } from './TrackItemTable.utils';
+import { TrackItemTablePager } from './TrackItemTablePager';
+
+import { differenceInMilliseconds, format } from 'date-fns';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { TIME_FORMAT } from '../../constants';
+
+interface ItemsTableProps {
+ data: ITrackItem[];
+ resetButtonsRef?: React.RefObject;
+ isOneDay: boolean;
+ isSearchTable: boolean;
+ pageCount?: number;
+ pageIndex?: number;
+ fetchData?: (options: { pageIndex: number; pageSize: number; sortBy: SortingState }) => void;
+ extraColumns?: ColumnDef[];
+ sumTotal: number;
+ manualSortBy: boolean;
+ customTableButtons?: React.ReactElement;
+}
+
+const defaultSorting: SortingState = [
+ {
+ id: 'endDate',
+ desc: true,
+ },
+];
+
+export const ItemsTable = ({
+ data,
+ resetButtonsRef,
+ isSearchTable,
+ pageCount: controlledPageCount,
+ pageIndex: controlledPageIndex,
+ fetchData,
+ extraColumns = [],
+ sumTotal,
+ manualSortBy = false,
+ isOneDay,
+ customTableButtons,
+}: ItemsTableProps) => {
+ const dateToValue = ({ cell }) => {
+ const value = cell.getValue() as number;
+ return format(value, isOneDay ? TIME_FORMAT : DATE_TIME_FORMAT);
+ };
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ id: 'selection',
+ header: ({ table }) => (
+ table.toggleAllRowsSelected(e.target.checked)}
+ />
+ ),
+ cell: ({ row }) => (
+ row.toggleSelected(e.target.checked)}
+ />
+ ),
+ enableSorting: false,
+ size: 10,
+ minSize: 10,
+ maxSize: 10,
+ },
+ {
+ header: 'App',
+ accessorKey: 'app',
+ filterFn: 'includesString',
+ enableColumnFilter: !isSearchTable,
+ meta: {
+ Filter: SelectColumnFilter,
+ },
+ size: 100,
+ minSize: 100,
+ maxSize: 120,
+ },
+ {
+ header: 'Title',
+ accessorKey: 'title',
+ cell: (info) => ,
+ enableColumnFilter: !isSearchTable,
+ size: 250,
+ minSize: 100,
+ maxSize: 500,
+ },
+ {
+ header: 'URL',
+ accessorKey: 'url',
+ cell: (info) => ,
+ enableColumnFilter: !isSearchTable,
+ size: 150,
+ minSize: 70,
+ maxSize: 400,
+ },
+ {
+ header: 'Begin',
+ accessorKey: 'beginDate',
+ cell: dateToValue,
+ enableColumnFilter: false,
+ size: 80,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ header: 'End',
+ accessorKey: 'endDate',
+ cell: dateToValue,
+ enableColumnFilter: false,
+ size: 80,
+ minSize: 80,
+ maxSize: 120,
+ },
+ {
+ header: 'Duration',
+ accessorFn: (record: ITrackItem) => diffAndFormatShort(record.beginDate, record.endDate),
+ enableColumnFilter: false,
+ enableSorting: !manualSortBy,
+ size: 60,
+ minSize: 60,
+ maxSize: 80,
+ },
+ ...extraColumns,
+ ],
+ [extraColumns, dateToValue, manualSortBy],
+ );
+
+ const [columnFilters, setColumnFilters] = useState([]);
+ const [sorting, setSorting] = useState(defaultSorting);
+ const [rowSelection, setRowSelection] = useState({});
+ const [pagination, setPagination] = useState({
+ pageIndex: controlledPageIndex || 0,
+ pageSize: 10,
+ });
+
+ // Use the state of the pagination when it is controlled from outside
+ useEffect(() => {
+ if (isSearchTable && typeof controlledPageIndex === 'number') {
+ setPagination((prev) => ({
+ ...prev,
+ pageIndex: controlledPageIndex,
+ }));
+ }
+ }, [isSearchTable, controlledPageIndex]);
+
+ // Configure the table
+ const table = useReactTable({
+ data,
+ columns,
+ filterFns: {
+ fuzzyText: fuzzyTextFilterFn as FilterFn,
+ },
+ state: {
+ sorting,
+ columnFilters,
+ rowSelection,
+ pagination,
+ },
+ enableRowSelection: true,
+ enableMultiRowSelection: true,
+ getRowId: (row: ITrackItem) => row.id?.toString(),
+ onRowSelectionChange: setRowSelection,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onPaginationChange: setPagination,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getPaginationRowModel: getPaginationRowModel(),
+ getFacetedRowModel: getFacetedRowModel(), //if you need a list of values for a column (other faceted row models depend on this one)
+ getFacetedMinMaxValues: getFacetedMinMaxValues(), //if you need min/max values
+ getFacetedUniqueValues: getFacetedUniqueValues(), //if you need a list o
+ manualPagination: isSearchTable,
+ manualSorting: manualSortBy,
+ manualFiltering: isSearchTable,
+ pageCount: isSearchTable
+ ? typeof controlledPageCount === 'number' && controlledPageCount > 0
+ ? Math.ceil(controlledPageCount / pagination.pageSize)
+ : 0
+ : undefined,
+ });
+
+ // Reset row selection when data changes
+ useEffect(() => {
+ setRowSelection({});
+ }, [data]);
+
+ // Handle server-side pagination
+ useEffect(() => {
+ if (isSearchTable) {
+ console.info('Change paging', {
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ sortBy: sorting,
+ });
+ fetchData?.({ pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, sortBy: sorting });
+ }
+ }, [isSearchTable, fetchData, pagination, sorting]);
+
+ // Handle server-side sorting
+ useEffect(() => {
+ if (manualSortBy && !isSearchTable) {
+ fetchData?.({ pageIndex: pagination.pageIndex, pageSize: pagination.pageSize, sortBy: sorting });
+ }
+ }, [manualSortBy, fetchData, sorting, pagination, isSearchTable]);
+
+ // Sum of all table data
+ const subTotal = table
+ .getFilteredRowModel()
+ .rows.map((row) => row.original)
+ .reduce((total, item) => total + differenceInMilliseconds(item.endDate, item.beginDate), 0);
+
+ // Get the selected rows information for buttons/actions
+ const tableButtonsProps: TableButtonsProps = {
+ selectedFlatRows: table.getSelectedRowModel().rows,
+ selectedRowIds: table.getState().rowSelection,
+ setAllFilters: () => setColumnFilters([]),
+ setSortBy: (sortByState: SortingState) => setSorting(sortByState),
+ pageIndex: pagination.pageIndex,
+ pageSize: pagination.pageSize,
+ fetchData,
+ };
+
+ return (
+ <>
+
+ {customTableButtons && React.cloneElement(customTableButtons, tableButtonsProps)}
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+ {header.column.id === 'selection' ? (
+ flexRender(header.column.columnDef.header, header.getContext())
+ ) : (
+
+ {header.column.getCanSort() ? (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+ {{
+ asc: ,
+ desc: ,
+ }[header.column.getIsSorted() as string] ?? null}
+
+
+ ) : (
+
+ {flexRender(header.column.columnDef.header, header.getContext())}
+
+ )}
+
+ {header.column.getCanFilter() ? (
+ // Use a safer type check and conditional rendering
+ // @ts-expect-error - We know this is correct based on our column definitions
+ header.column.columnDef.meta?.Filter ? (
+ // @ts-expect-error - We know this component exists
+
+ ) : (
+
+ )
+ ) : null}
+
+ )}
+
+ ))}
+
+ ))}
+
+
+ {table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))}
+
+
+
+
+ {formatDurationInternal(subTotal)} / {formatDurationInternal(sumTotal)}
+
+
+
+
+ >
+ );
+};
diff --git a/client/src/components/TrackItemTable/ItemsTable.utils.ts b/client/src/components/TrackItemTable/ItemsTable.utils.ts
new file mode 100644
index 00000000..1daa972a
--- /dev/null
+++ b/client/src/components/TrackItemTable/ItemsTable.utils.ts
@@ -0,0 +1,8 @@
+import { SortingState } from '@tanstack/react-table';
+
+export const defaultSorting: SortingState = [
+ {
+ id: 'endDate',
+ desc: true,
+ },
+];
diff --git a/client/src/components/TrackItemTable/OverflowText.tsx b/client/src/components/TrackItemTable/OverflowText.tsx
new file mode 100644
index 00000000..3a4a33ca
--- /dev/null
+++ b/client/src/components/TrackItemTable/OverflowText.tsx
@@ -0,0 +1,23 @@
+import { Tooltip, Text, TextProps } from '@chakra-ui/react';
+
+interface IProps extends TextProps {
+ value?: string;
+}
+
+export const OverflowText = ({ children, ...props }: TextProps) => {
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+export const OverflowTextCell = ({ value }: IProps) => {
+ return (
+
+ {value}
+
+ );
+};
diff --git a/client/src/components/TrackItemTable/SelectColumnFilter.tsx b/client/src/components/TrackItemTable/SelectColumnFilter.tsx
new file mode 100644
index 00000000..78789234
--- /dev/null
+++ b/client/src/components/TrackItemTable/SelectColumnFilter.tsx
@@ -0,0 +1,28 @@
+import { Select } from '@chakra-ui/react';
+import { Column } from '@tanstack/react-table';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { ToggleColumnFilter } from './ToggleColumnFilter';
+
+export function SelectColumnFilter({ column }: { column: Column }) {
+ const uniqueValues = column.getFacetedUniqueValues();
+ const options = Array.from(uniqueValues.keys());
+ const filterValue = column.getFilterValue() || '';
+
+ return (
+
+ {
+ column.setFilterValue(e.target.value || undefined);
+ }}
+ >
+ All
+ {options.map((option, i) => (
+
+ {option}
+
+ ))}
+
+
+ );
+}
diff --git a/client/src/components/TrackItemTable/ToggleColumnFilter.tsx b/client/src/components/TrackItemTable/ToggleColumnFilter.tsx
new file mode 100644
index 00000000..8daaf863
--- /dev/null
+++ b/client/src/components/TrackItemTable/ToggleColumnFilter.tsx
@@ -0,0 +1,30 @@
+import { cloneElement, useRef } from 'react';
+import { Popover, PopoverTrigger, useDisclosure, IconButton, PopoverContent, PopoverArrow } from '@chakra-ui/react';
+import { SearchIcon } from '@chakra-ui/icons';
+
+export function ToggleColumnFilter({ children }) {
+ const { onOpen, onClose, isOpen } = useDisclosure();
+ const firstFieldRef = useRef(null!);
+
+ const form = cloneElement(children, { ref: firstFieldRef, onCancel: onClose });
+
+ return (
+ }
+ onOpen={onOpen}
+ onClose={onClose}
+ placement="right"
+ closeOnBlur={true}
+ >
+
+ } />
+
+
+
+
+ {form}
+
+
+ );
+}
diff --git a/client/src/components/TrackItemTable/TrackItemTable.styles.ts b/client/src/components/TrackItemTable/TrackItemTable.styles.ts
deleted file mode 100644
index 30fd2d4f..00000000
--- a/client/src/components/TrackItemTable/TrackItemTable.styles.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { Box, Flex } from 'reflexbox';
-import styled from 'styled-components';
-
-export const FilterDropdown = styled(Flex).attrs({ p: 2 })`
- border-radius: 6px;
- background: #fff;
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.2);
-`;
-
-export const FilterInput = styled(Box).attrs({ mr: 1 })`
- width: 130px;
-`;
-export const Highlight = styled.span`
- color: #f50;
-`;
-
-export const TotalCount = styled.div`
- text-align: right;
- padding-right: 10px;
-`;
diff --git a/client/src/components/TrackItemTable/TrackItemTable.tsx b/client/src/components/TrackItemTable/TrackItemTable.tsx
index 52c62e8e..f1a4eaad 100644
--- a/client/src/components/TrackItemTable/TrackItemTable.tsx
+++ b/client/src/components/TrackItemTable/TrackItemTable.tsx
@@ -1,349 +1,51 @@
-import { Box, Flex } from 'reflexbox';
-import { Button, Input, Table } from 'antd';
-import { SearchOutlined } from '@ant-design/icons';
-// tslint:disable-next-line: no-submodule-imports
-
+import { Box } from '@chakra-ui/react';
+import { differenceInMilliseconds } from 'date-fns';
import { sumBy } from 'lodash';
-import moment from 'moment';
-import React, { useState, useRef, useEffect } from 'react';
-import Moment from 'react-moment';
-import { convertDate, DATE_TIME_FORMAT, TIME_FORMAT } from '../../constants';
-import { TrackItemType } from '../../enum/TrackItemType';
-import { diffAndFormatShort } from '../../utils';
-import { filterItems, getUniqueAppNames } from '../Timeline/timeline.utils';
-import { FilterDropdown, FilterInput, Highlight, TotalCount } from './TrackItemTable.styles';
-import { Logger } from '../../logger';
-import { deleteByIds } from '../../services/trackItem.api';
-import { checkIfOneDay } from '../../timeline.util';
+import { ITrackItem } from '../../@types/ITrackItem';
import { useStoreActions, useStoreState } from '../../store/easyPeasy';
-
-const calculateTotal = filteredData => {
- const totalMs = sumBy(filteredData, (c: any) =>
- convertDate(c.endDate).diff(convertDate(c.beginDate)),
- );
- const dur = moment.duration(totalMs);
-
- return Total {dur.format()} ;
-};
-
-const paginationConf = {
- showSizeChanger: true,
- pageSizeOptions: ['50', '100', '300', '500'],
-};
-
-export const TrackItemTable = () => {
- const timeItems = useStoreState(state => state.timeItems);
- const visibleTimerange = useStoreState(state => state.visibleTimerange);
-
- const fetchTimerange = useStoreActions(actions => actions.fetchTimerange);
-
- const [data, setData] = useState([]);
- const [state, setState] = useState({
- filteredInfo: {},
- sortedInfo: {},
- filterTitleDropdownVisible: false,
- filterUrlDropdownVisible: false,
- activeType: TrackItemType.AppTrackItem,
- searchText: '',
- filtered: false,
- selectedRowKeys: [],
+import { TIMERANGE_MODE_TODAY } from '../../store/mainStore';
+import { checkIfOneDay } from '../../timeline.util';
+import { filterItems } from '../Timeline/timeline.utils';
+import { ItemsTable } from './ItemsTable';
+import { defaultTableButtonsProps } from './TrackItemTable.utils';
+import { TrackItemTableButtons } from './TrackItemTableButtons';
+
+export const TrackItemTable = ({ type, resetButtonsRef }) => {
+ const timeItems = useStoreState((state) => state.timeItems);
+ const visibleTimerange = useStoreState((state) => state.visibleTimerange);
+ const timerangeMode = useStoreState((state) => state.timerangeMode);
+ const setLiveView = useStoreActions((actions) => actions.setLiveView);
+
+ // Use the first 10 items for the current page (since we're not doing server-side pagination here)
+ const data = filterItems(timeItems[type], visibleTimerange);
+
+ const isOneDay = checkIfOneDay(visibleTimerange);
+ const isToday = timerangeMode === TIMERANGE_MODE_TODAY;
+
+ // Calculate total duration in milliseconds for all the data
+ const totalDuration = sumBy(timeItems[type] as ITrackItem[], (trackItem) => {
+ return differenceInMilliseconds(trackItem.endDate, trackItem.beginDate);
});
- const deleteTimelineItems = async ids => {
- Logger.debug('Delete timeline items', ids);
-
- if (ids) {
- await deleteByIds(ids);
- Logger.debug('Deleted timeline items', ids);
- fetchTimerange();
- } else {
- Logger.error('No ids, not deleting from DB');
- }
- };
-
- const searchInput = useRef();
-
- const filterByAppType = type =>
- type === TrackItemType.AppTrackItem
- ? filterItems(timeItems.appItems, visibleTimerange)
- : filterItems(timeItems.logItems, visibleTimerange);
-
- useEffect(() => {
- const { activeType } = state;
-
- const filteredData = filterByAppType(activeType);
- setData(filteredData);
- setState({
- ...state,
- isOneDay: checkIfOneDay(visibleTimerange),
- });
-
- if (
- timeItems.appItems.length > 0 &&
- filteredData.length === 0 &&
- state.activeType === TrackItemType.AppTrackItem
- ) {
- const beginDate = timeItems.appItems[0].beginDate;
- const endDate = timeItems.appItems[0].endDate;
+ const handleClick = (e) => {
+ e.stopPropagation();
- Logger.error('No items filtered for table', {
- visibleTimerange,
- beginDate,
- endDate,
- });
+ if (isToday) {
+ setLiveView(false);
}
- }, [timeItems, visibleTimerange]); // eslint-disable-line react-hooks/exhaustive-deps
-
- useEffect(() => {
- if (searchInput.current) {
- searchInput.current.focus();
- }
- }, [state.filterTitleDropdownVisible]);
-
- const handleChange = (pagination: any, filters: any, sorter: any) => {
- setState({ ...state, filteredInfo: filters, sortedInfo: sorter });
- };
-
- const clearFilters = () => {
- setState({ ...state, filteredInfo: {} });
- };
-
- const clearAll = () => {
- setState({ ...state, filteredInfo: {}, sortedInfo: {} });
- };
-
- const toggleTask = () => {
- const { activeType } = state;
-
- clearAll();
-
- const newActiveType =
- activeType === TrackItemType.AppTrackItem
- ? TrackItemType.LogTrackItem
- : TrackItemType.AppTrackItem;
-
- setData(filterByAppType(newActiveType));
- setState({
- ...state,
-
- activeType: newActiveType,
- isOneDay: checkIfOneDay(visibleTimerange),
- });
- };
-
- const onInputChange = e => {
- setState({ ...state, searchText: e.target.value });
- };
-
- const onSearch = () => {
- const { searchText } = state;
- const reg = new RegExp(searchText, 'gi');
- const filteredData = data
- .map((record: any) => {
- const match = record.title.match(reg);
- if (!match) {
- return null;
- }
- return {
- ...record,
- name: (
-
- {record.title
- .split(reg)
- .map((text, i) =>
- i > 0
- ? [{match[0]} , text]
- : text,
- )}
-
- ),
- };
- })
- .filter(record => !!record);
-
- setData(filteredData);
- setState({
- ...state,
- filterTitleDropdownVisible: false,
- filterUrlDropdownVisible: false,
- filtered: !!searchText,
- });
- };
-
- const onSelectChange = selectedRowKeys => {
- Logger.debug('selectedRowKeys changed: ', selectedRowKeys);
- setState({ ...state, selectedRowKeys });
- };
-
- const deleteSelectedItems = () => {
- const { selectedRowKeys } = state;
- deleteTimelineItems(selectedRowKeys);
- setState({ ...state, selectedRowKeys: [] });
};
- const { isOneDay, activeType, sortedInfo, filteredInfo } = state;
-
- const FilterDropdownComp = () => (
-
-
-
-
-
- Search
-
-
- );
-
- const columns = [
- {
- title: 'App',
- dataIndex: 'app',
- key: 'app',
- width: 200,
- filters: getUniqueAppNames(timeItems.appItems),
- filteredValue: filteredInfo.app || null,
- onFilter: (value: any, record: any) => record.app.includes(value),
- sorter: (a: any, b: any) => a.app - b.app,
- sortOrder: sortedInfo.columnKey === 'app' && sortedInfo.order,
- },
- {
- title: 'Title',
- dataIndex: 'title',
- key: 'title',
- filterDropdown: FilterDropdownComp,
- filterIcon: ,
- filterTitleDropdownVisible: state.filterTitleDropdownVisible,
- onfilterTitleDropdownVisibleChange: visible => {
- setState({
- ...state,
- filterTitleDropdownVisible: visible,
- });
- if (visible) {
- setTimeout(() => (searchInput.current as any).select());
- }
- },
- sorter: (a: any, b: any) => a.title.length - b.title.length,
- sortOrder: sortedInfo.columnKey === 'title' && sortedInfo.order,
- },
-
- {
- title: 'URL',
- dataIndex: 'url',
- key: 'url',
- width: 300,
- ellipsis: true,
- filterDropdown: FilterDropdownComp,
- filterIcon: ,
- filterUrlDropdownVisible: state.filterUrlDropdownVisible,
- onFilterUrlDropdownVisibleChange: visible => {
- setState({
- ...state,
- filterUrlDropdownVisible: visible,
- });
- if (visible) {
- setTimeout(() => searchInput.current.select());
- }
- },
- sorter: (a: any, b: any) => a.url.length - b.url.length,
- sortOrder: sortedInfo.columnKey === 'url' && sortedInfo.order,
- },
- {
- title: 'Begin',
- dataIndex: 'beginDate',
- key: 'beginDate',
- width: 170,
-
- onFilter: (value: any, record: any) => convertDate(record.beginDate) > value,
- sorter: (a: any, b: any) =>
- convertDate(a.beginDate).valueOf() - convertDate(b.beginDate).valueOf(),
- sortOrder: sortedInfo.columnKey === 'beginDate' && sortedInfo.order,
- render: (text, record) => (
-
- {record.beginDate}
-
- ),
- },
- {
- title: 'End',
- dataIndex: 'endDate',
- key: 'endDate',
- width: 170,
- onFilter: (value: any, record: any) => convertDate(record.endDate) > value,
- sorter: (a: any, b: any) =>
- convertDate(a.endDate).valueOf() - convertDate(b.endDate).valueOf(),
- sortOrder: sortedInfo.columnKey === 'endDate' && sortedInfo.order,
-
- render: (text, record) => (
- {record.endDate}
- ),
- },
-
- {
- title: 'Dur',
- dataIndex: '',
- key: 'duration',
- width: 80,
- sorter: (a: any, b: any) =>
- convertDate(a.endDate).diff(convertDate(a.beginDate)) -
- convertDate(b.endDate).diff(convertDate(b.beginDate)),
- sortOrder: sortedInfo.columnKey === 'duration' && sortedInfo.order,
-
- render: (text, record) => (
- {diffAndFormatShort(record.beginDate, record.endDate)}
- ),
- },
- ];
-
- const { selectedRowKeys } = state;
- const rowSelection = {
- selectedRowKeys,
- onChange: onSelectChange,
- };
- const hasSelected = selectedRowKeys.length > 0;
return (
-
-
-
- {!hasSelected && (
-
- Showing {activeType === TrackItemType.AppTrackItem ? 'Apps' : 'Logs'}
-
- )}
- {hasSelected && (
-
- Delete {selectedRowKeys.length} items
-
- )}
-
-
- Clear filters
-
-
- Clear filters and sorters
-
-
-
-
-
-
`${record.id}`}
- columns={columns}
- pagination={paginationConf}
- dataSource={data}
- onChange={handleChange}
- footer={calculateTotal}
+
+ }
/>
-
+
);
};
diff --git a/client/src/components/TrackItemTable/TrackItemTable.utils.ts b/client/src/components/TrackItemTable/TrackItemTable.utils.ts
new file mode 100644
index 00000000..0ac35b8f
--- /dev/null
+++ b/client/src/components/TrackItemTable/TrackItemTable.utils.ts
@@ -0,0 +1,44 @@
+import { FilterFn, Row, SortingState } from '@tanstack/react-table';
+import { differenceInMilliseconds } from 'date-fns';
+import { sumBy } from 'lodash';
+import { matchSorter } from 'match-sorter';
+import { ITrackItem } from '../../@types/ITrackItem';
+
+export const calculateTotal = (filteredData: ITrackItem[]) => {
+ const totalMs = sumBy(filteredData, (trackItem: ITrackItem) => {
+ return differenceInMilliseconds(trackItem.endDate, trackItem.beginDate);
+ });
+
+ return totalMs;
+};
+
+// Use matchSorter for fuzzy matching
+export const fuzzyTextFilterFn: FilterFn = (row: Row, columnId: string, value: string) => {
+ // Rank the item
+ const itemRank = matchSorter([row.getValue(columnId)], value);
+
+ return itemRank.length > 0;
+};
+
+// Let the table remove the filter if the string is empty
+fuzzyTextFilterFn.autoRemove = (val: string) => !val;
+
+export interface TableButtonsProps {
+ selectedFlatRows: Row[];
+ selectedRowIds: Record;
+ setAllFilters: () => void;
+ setSortBy: (sortBy: SortingState) => void;
+ pageIndex: number;
+ pageSize: number;
+ fetchData?: (options: { pageIndex: number; pageSize: number; sortBy: SortingState }) => void;
+}
+
+export const defaultTableButtonsProps: TableButtonsProps = {
+ selectedFlatRows: [],
+ selectedRowIds: {},
+ setAllFilters: () => {},
+ setSortBy: () => {},
+ pageIndex: 0,
+ pageSize: 10,
+ fetchData: () => {},
+};
diff --git a/client/src/components/TrackItemTable/TrackItemTableButtons.tsx b/client/src/components/TrackItemTable/TrackItemTableButtons.tsx
new file mode 100644
index 00000000..b6125918
--- /dev/null
+++ b/client/src/components/TrackItemTable/TrackItemTableButtons.tsx
@@ -0,0 +1,63 @@
+import { Button, HStack, Text } from '@chakra-ui/react';
+import { SortingState } from '@tanstack/react-table';
+import { Logger } from '../../logger';
+import { deleteByIds } from '../../services/trackItem.api';
+import { useStoreActions } from '../../store/easyPeasy';
+import { defaultSorting } from './ItemsTable.utils';
+import { TableButtonsProps } from './TrackItemTable.utils';
+
+interface Props extends TableButtonsProps {
+ fetchData?: (options: { pageIndex: number; pageSize: number; sortBy: SortingState }) => void;
+}
+
+export const TrackItemTableButtons: React.FC = (props) => {
+ const { selectedFlatRows, selectedRowIds, setAllFilters, setSortBy } = props;
+
+ const fetchTimerange = useStoreActions((actions) => actions.fetchTimerange);
+
+ const deleteTimelineItems = async (ids: number[]) => {
+ Logger.debug('Delete timeline items', ids);
+
+ if (ids) {
+ await deleteByIds(ids);
+ Logger.debug('Deleted timeline items', ids);
+ fetchTimerange();
+ } else {
+ Logger.error('No ids, not deleting from DB');
+ }
+ };
+
+ const deleteSelectedItems = () => {
+ deleteTimelineItems(selectedFlatRows.map(({ original }) => original.id));
+ };
+
+ const hasSelected = Object.keys(selectedRowIds).length > 0;
+
+ return (
+
+ {hasSelected && (
+
+ Delete
+
+ {Object.keys(selectedRowIds).length}
+
+ items
+
+ )}
+
+ setAllFilters()}>
+ Clear filters
+
+
+ {
+ setSortBy(defaultSorting);
+ setAllFilters();
+ }}
+ >
+ Reset to defaults
+
+
+ );
+};
diff --git a/client/src/components/TrackItemTable/TrackItemTablePager.tsx b/client/src/components/TrackItemTable/TrackItemTablePager.tsx
new file mode 100644
index 00000000..e8a1b5a4
--- /dev/null
+++ b/client/src/components/TrackItemTable/TrackItemTablePager.tsx
@@ -0,0 +1,130 @@
+import { ArrowLeftIcon, ArrowRightIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons';
+import {
+ Flex,
+ IconButton,
+ NumberDecrementStepper,
+ NumberIncrementStepper,
+ NumberInput,
+ NumberInputField,
+ NumberInputStepper,
+ Select,
+ Text,
+ Tooltip,
+} from '@chakra-ui/react';
+
+interface TrackItemTablePagerProps {
+ gotoPage: (page: number) => void;
+ canPreviousPage: boolean;
+ previousPage: () => void;
+ pageIndex: number;
+ pageSize: number;
+ nextPage: () => void;
+ canNextPage: boolean;
+ pageCount: number;
+ setPageSize: (size: number) => void;
+}
+
+export const TrackItemTablePager = ({
+ gotoPage,
+ canPreviousPage,
+ previousPage,
+ pageIndex,
+ pageSize,
+ nextPage,
+ canNextPage,
+ pageCount,
+ setPageSize,
+}: TrackItemTablePagerProps) => {
+ if (pageCount === 0) return null;
+
+ return (
+
+
+
+ gotoPage(0)}
+ isDisabled={!canPreviousPage}
+ icon={ }
+ mr={4}
+ />
+
+
+ }
+ />
+
+
+
+
+
+ Page{' '}
+
+ {pageIndex + 1}
+ {' '}
+ of{' '}
+
+ {Math.max(pageCount, 1)}
+
+
+ Go to page: {' '}
+ {
+ const currentPage = Number(value);
+ const page = currentPage ? currentPage - 1 : 0;
+ gotoPage(page);
+ }}
+ defaultValue={pageIndex + 1}
+ value={pageIndex + 1}
+ >
+
+
+
+
+
+
+ {
+ setPageSize(Number(e.target.value));
+ }}
+ >
+ {[10, 20, 30, 40, 50].map((size) => (
+
+ Show {size}
+
+ ))}
+
+
+
+
+
+ }
+ />
+
+
+ gotoPage(Math.max(pageCount - 1, 0))}
+ isDisabled={!canNextPage}
+ icon={ }
+ ml={4}
+ />
+
+
+
+ );
+};
diff --git a/client/src/components/TrackItemTable/TrackItemTabs.tsx b/client/src/components/TrackItemTable/TrackItemTabs.tsx
new file mode 100644
index 00000000..afb5ebb7
--- /dev/null
+++ b/client/src/components/TrackItemTable/TrackItemTabs.tsx
@@ -0,0 +1,27 @@
+import { Box, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/react';
+import { useRef } from 'react';
+import { TrackItemType } from '../../enum/TrackItemType';
+import { TrackItemTable } from './TrackItemTable';
+
+export const TrackItemTabs = () => {
+ const resetButtonsRef = useRef(null);
+
+ return (
+
+
+ Apps
+ Logs
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/client/src/components/TrayLayout/ChartCircles.tsx b/client/src/components/TrayLayout/ChartCircles.tsx
new file mode 100644
index 00000000..1433a866
--- /dev/null
+++ b/client/src/components/TrayLayout/ChartCircles.tsx
@@ -0,0 +1,93 @@
+import { Box, Flex, Text, useColorModeValue } from '@chakra-ui/react';
+import { shortTime } from '../../time.util';
+
+const NO_VALUE = '-';
+
+export const ChartCircles = ({ width, innerWidth, onlineTimeMs, lastSessionMs, currentSession }) => {
+ const mainTextColor = useColorModeValue('gray.700', 'gray.300');
+
+ return (
+
+
+
+
+
+
+ Session Time
+
+
+ {currentSession}
+
+
+
+ Online Today
+
+
+ {onlineTimeMs}
+
+
+ Last Session
+
+
+ {lastSessionMs ? shortTime(lastSessionMs, { largest: 2, units: ['d', 'h', 'm', 's'] }) : NO_VALUE}
+
+
+
+ );
+};
diff --git a/client/src/components/TrayLayout/OnlineChart.tsx b/client/src/components/TrayLayout/OnlineChart.tsx
new file mode 100644
index 00000000..9f17c6bd
--- /dev/null
+++ b/client/src/components/TrayLayout/OnlineChart.tsx
@@ -0,0 +1,156 @@
+import { Box, Button, HStack, Text, useColorModeValue, VStack } from '@chakra-ui/react';
+import { DateTime } from 'luxon';
+import { useContext, useEffect, useState } from 'react';
+import { VictoryContainer, VictoryPie, VictoryStyleInterface } from 'victory';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { RootContext } from '../../RootContext';
+import { useChartThemeState } from '../../routes/ChartThemeProvider';
+import { colorProp } from '../charts.utils';
+import { getOnlineTime } from '../PieCharts/MetricTiles.utils';
+import { ShortTimeInterval } from '../TrayList/ShortTimeInterval';
+import { ChartCircles } from './ChartCircles';
+import { CLOCK_MODE, getOnlineTimesForChart, getQuarters, getTotalOnlineDuration } from './OnlineChart.util';
+import { OnlineChartTooltipLabel } from './OnlineChartTooltipLabel';
+
+const QuarterLabel = (props) => (
+
+
+
+);
+
+const MINUTES = 60 * 1000;
+
+export const OnlineChart = ({ items }: { items: ITrackItem[] }) => {
+ const { workSettings } = useContext(RootContext);
+ const { chartTheme } = useChartThemeState();
+ const [mode, setMode] = useState(CLOCK_MODE.HOURS_12);
+ const [currentSession, setCurrentSession] = useState(0);
+ const [lastSession, setLastSession] = useState();
+ const onlineSinceColor = useColorModeValue('var(--chakra-colors-blue-500)', 'var(--chakra-colors-blue-500)');
+ const overtimeColor = 'var(--chakra-colors-red-500)';
+
+ const { sessionLength, minBreakTime } = workSettings;
+
+ const MAX_TIMER = sessionLength * MINUTES;
+ const sessionIsOvertime = currentSession > MAX_TIMER;
+
+ useEffect(() => {
+ const grouped = getTotalOnlineDuration(DateTime.now(), items, minBreakTime);
+ const sessionTime = grouped[0];
+ setCurrentSession(sessionTime);
+
+ if (grouped.length > 1) {
+ setLastSession(grouped[1]);
+ } else {
+ setLastSession(undefined);
+ }
+ }, [items, minBreakTime]);
+
+ const width = 300;
+
+ const innerWidth = 230;
+ const [startDate, firstQuarter, secondQuarter, thirdQuarter, endDate] = getQuarters(DateTime.now(), mode);
+
+ const onlineTimeMs = getOnlineTime(items, [startDate, endDate]);
+
+ const pieData = getOnlineTimesForChart({
+ beginClamp: startDate,
+ endClamp: endDate,
+ items,
+ mode,
+ });
+
+ const style: VictoryStyleInterface = {
+ data: {
+ fill: colorProp,
+ stroke: colorProp,
+ strokeWidth: 0,
+ fillOpacity: 0.75,
+ },
+ };
+
+ const sessionLine = MAX_TIMER - currentSession;
+
+ return (
+
+ {startDate.hour}
+
+
+ {thirdQuarter.hour}
+
+
+ }
+ lastSessionMs={lastSession}
+ currentSession={ }
+ />
+ }
+ style={style}
+ labelComponent={
+
+ }
+ y={(datum) => datum.diff}
+ data={pieData}
+ />
+
+
+
+ }
+ style={style}
+ y={(datum) => datum.diff}
+ labels={() => null}
+ data={[
+ { x: 1, diff: sessionLine, color: 'transparent' },
+ {
+ x: 3,
+ diff: currentSession,
+ color: sessionIsOvertime ? overtimeColor : onlineSinceColor,
+ },
+ ]}
+ />
+
+
+
+
+ {firstQuarter.hour}
+
+
+ {secondQuarter.hour}
+ setMode(mode === CLOCK_MODE.HOURS_24 ? CLOCK_MODE.HOURS_12 : CLOCK_MODE.HOURS_24)}
+ >
+ {CLOCK_MODE.HOURS_12 === mode ? '12h' : '24h'}
+
+
+ );
+};
diff --git a/client/src/components/TrayLayout/OnlineChart.util.test.ts b/client/src/components/TrayLayout/OnlineChart.util.test.ts
new file mode 100644
index 00000000..19b6a6e5
--- /dev/null
+++ b/client/src/components/TrayLayout/OnlineChart.util.test.ts
@@ -0,0 +1,736 @@
+import { last } from 'lodash';
+import { DateTime } from 'luxon';
+import { describe, expect, it, test } from 'vitest';
+import {
+ getClampHours,
+ getOnlineTimesForChart,
+ getTotalOnlineDuration,
+ groupByBreaks,
+ isBetweenHours,
+ isLessThanHours,
+} from './OnlineChart.util';
+
+const ONLINE = 'ONLINE';
+const OFFLINE = 'OFFLINE';
+const IDLE = 'IDLE';
+
+const beginDate18_23_50 = DateTime.fromISO('2021-06-18T23:50:00').toMillis();
+const endDate19_00_10 = DateTime.fromISO('2021-06-19T00:10:00').toMillis();
+
+const beginDate19_10_00 = DateTime.fromISO('2021-06-19T10:00:00').toMillis();
+const endDate19_10_10 = DateTime.fromISO('2021-06-19T10:10:00').toMillis();
+
+const beginDate19_11_50 = DateTime.fromISO('2021-06-19T11:50:00').toMillis();
+const endDate_19_12_10 = DateTime.fromISO('2021-06-19T12:10:00').toMillis();
+
+const beginDate19_18_50 = DateTime.fromISO('2021-06-19T18:50:00').toMillis();
+const endDate19_19_10 = DateTime.fromISO('2021-06-19T19:10:00').toMillis();
+
+const beginDate_19_23_50 = DateTime.fromISO('2021-06-19T23:50:00').toMillis();
+const endDate20_00_10 = DateTime.fromISO('2021-06-20T00:10:00').toMillis();
+
+const realDate = DateTime.fromISO('2021-06-19T10:10:00');
+
+describe('OnlineChart getOnlineTimesForChart', () => {
+ it('uses only ONLINE items', () => {
+ const onlineItem = {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_10_00,
+ endDate: endDate19_10_10,
+ };
+
+ const offlineItems = [
+ {
+ id: 2,
+ app: OFFLINE,
+ beginDate: beginDate19_11_50,
+ endDate: endDate_19_12_10,
+ },
+ {
+ id: 3,
+ app: OFFLINE,
+ beginDate: beginDate19_18_50,
+ endDate: endDate19_19_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 0, endHour: 24 }),
+ items: [onlineItem, ...offlineItems],
+ });
+
+ expect(actual).toEqual([
+ {
+ beginDate: realDate.startOf('day').toMillis(),
+ endDate: beginDate19_10_00,
+ color: 'transparent',
+ diff: 600,
+ x: 0,
+ },
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_10_00,
+ endDate: endDate19_10_10,
+ diff: 10,
+ x: 1,
+ color: 'transparent',
+ },
+ {
+ beginDate: endDate19_10_10,
+ endDate: realDate.endOf('day').plus({ milliseconds: 1 }).toMillis(),
+ color: 'transparent',
+ diff: 830,
+ x: 2,
+ },
+ ]);
+ });
+
+ it('Sets first, last and in between empty items hours 0 to 24', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_10_00,
+ endDate: endDate19_10_10,
+ },
+
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: beginDate19_18_50,
+ endDate: endDate19_19_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 0, endHour: 24 }),
+ items: [...items],
+ });
+ expect(actual).toEqual([
+ {
+ beginDate: DateTime.fromISO('2021-06-19T00:00:00').toMillis(),
+ endDate: beginDate19_10_00,
+ color: 'transparent',
+ diff: 600,
+ x: 0,
+ },
+ {
+ app: ONLINE,
+ beginDate: beginDate19_10_00,
+ color: 'transparent',
+ endDate: endDate19_10_10,
+ diff: 10,
+ x: 1,
+ id: 1,
+ },
+
+ {
+ beginDate: endDate19_10_10,
+ endDate: beginDate19_18_50,
+ diff: 520,
+ color: 'transparent',
+ x: 2,
+ },
+
+ {
+ app: ONLINE,
+ beginDate: beginDate19_18_50,
+ endDate: endDate19_19_10,
+ diff: 20,
+ color: 'transparent',
+ x: 3,
+ id: 2,
+ },
+ {
+ beginDate: endDate19_19_10,
+ endDate: DateTime.fromISO('2021-06-19T24:00:00').toMillis(),
+ color: 'transparent',
+ diff: 290,
+ x: 4,
+ },
+ ]);
+ });
+
+ it('Sets first, last and in between empty items for hours 12 to 24', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_10_00,
+ endDate: endDate19_10_10,
+ },
+
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: beginDate19_18_50,
+ endDate: endDate19_19_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 12, endHour: 24 }),
+
+ items: [...items],
+ });
+
+ expect(actual).toEqual([
+ {
+ beginDate: DateTime.fromISO('2021-06-19T12:00:00').toMillis(),
+ endDate: beginDate19_18_50,
+ color: 'transparent',
+ diff: 410,
+ x: 0,
+ },
+
+ {
+ app: ONLINE,
+ beginDate: beginDate19_18_50,
+ endDate: endDate19_19_10,
+ color: 'transparent',
+ diff: 20,
+ x: 1,
+ id: 2,
+ },
+ {
+ beginDate: endDate19_19_10,
+ endDate: DateTime.fromISO('2021-06-19T24:00:00').toMillis(),
+ color: 'transparent',
+ diff: 290,
+ x: 2,
+ },
+ ]);
+ });
+
+ it('Clamps items for hours 12 to 24 beginDate', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_11_50,
+ endDate: endDate_19_12_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 12, endHour: 24 }),
+
+ items: [...items],
+ });
+
+ expect(actual).toEqual([
+ {
+ app: ONLINE,
+ beginDate: DateTime.fromISO('2021-06-19T12:00:00').toMillis(),
+ color: 'transparent',
+ endDate: endDate_19_12_10,
+ diff: 10,
+ x: 0,
+ id: 1,
+ },
+ {
+ beginDate: endDate_19_12_10,
+ endDate: DateTime.fromISO('2021-06-19T24:00:00').toMillis(),
+ color: 'transparent',
+ diff: 710,
+ x: 1,
+ },
+ ]);
+ });
+
+ it('Clamps items for hours 12 to 24 endDate', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_11_50,
+ endDate: endDate_19_12_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 0, endHour: 12 }),
+ items: [...items],
+ });
+
+ expect(actual).toEqual([
+ {
+ beginDate: DateTime.fromISO('2021-06-19T00:00:00').toMillis(),
+ endDate: beginDate19_11_50,
+ color: 'transparent',
+ diff: 710,
+ x: 0,
+ },
+ {
+ app: ONLINE,
+ beginDate: beginDate19_11_50,
+ color: 'transparent',
+ endDate: DateTime.fromISO('2021-06-19T12:00:00').toMillis(),
+ diff: 10,
+ id: 1,
+ x: 1,
+ },
+ ]);
+ });
+ it('Clamps items for hours 12 to 24 endDate next day', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate_19_23_50,
+ endDate: endDate20_00_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 12, endHour: 24 }),
+ items: [...items],
+ });
+
+ expect(actual).toEqual([
+ {
+ beginDate: DateTime.fromISO('2021-06-19T12:00:00').toMillis(),
+ endDate: beginDate_19_23_50,
+ color: 'transparent',
+ diff: 710,
+ x: 0,
+ },
+ {
+ app: ONLINE,
+ beginDate: beginDate_19_23_50,
+ color: 'transparent',
+ endDate: DateTime.fromISO('2021-06-19T24:00:00').toMillis(),
+ diff: 10,
+ id: 1,
+ x: 1,
+ },
+ ]);
+ });
+
+ it('Clamps items for hours 0 to 12 beginDate previous day', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate18_23_50,
+ endDate: endDate19_00_10,
+ },
+ ];
+
+ const actual = getOnlineTimesForChart({
+ ...getClampHours({ realDate, startHour: 0, endHour: 12 }),
+ items: [...items],
+ });
+
+ expect(actual).toEqual([
+ {
+ app: ONLINE,
+ beginDate: DateTime.fromISO('2021-06-19T00:00:00').toMillis(),
+ endDate: endDate19_00_10,
+ color: 'transparent',
+ diff: 10,
+ id: 1,
+ x: 0,
+ },
+ {
+ beginDate: endDate19_00_10,
+ endDate: DateTime.fromISO('2021-06-19T12:00:00').toMillis(),
+ color: 'transparent',
+ diff: 710,
+ x: 1,
+ },
+ ]);
+ });
+});
+
+const getMillisFromTime = (time: string) => {
+ return DateTime.fromISO(`2021-06-19T${time}`).toMillis();
+};
+const getDateFromTime = (time: string) => {
+ return DateTime.fromISO(`2021-06-19T${time}`);
+};
+
+const MINUTES = 60 * 1000;
+const minBreakTime = 5;
+
+describe('OnlineChart groupByBreaks', () => {
+ it('groupByBreaks take items until finds minimal break time.', () => {
+ const chunk1 = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:29:00'),
+ endDate: getMillisFromTime('08:39:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 1,
+ },
+ {
+ id: 3,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ color: 'transparent',
+ diff: 14,
+ x: 2,
+ },
+ ];
+ const chunk2 = [
+ {
+ id: 4,
+ app: ONLINE,
+ beginDate: getMillisFromTime('07:00:00'),
+ endDate: getMillisFromTime('07:10:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+ ];
+
+ const sorted = [...chunk1, ...chunk2];
+ const grouped = groupByBreaks(sorted, minBreakTime);
+
+ expect(grouped).toEqual([chunk1, chunk2]);
+ });
+
+ it('groupByBreaks groups all items', () => {
+ const chunk1 = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:29:00'),
+ endDate: getMillisFromTime('08:39:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 1,
+ },
+ {
+ id: 3,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ color: 'transparent',
+ diff: 14,
+ x: 2,
+ },
+ ];
+ const chunk2 = [
+ {
+ id: 4,
+ app: ONLINE,
+ beginDate: getMillisFromTime('07:00:00'),
+ endDate: getMillisFromTime('07:10:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+ ];
+
+ const chunk3 = [
+ {
+ id: 5,
+ app: ONLINE,
+ beginDate: getMillisFromTime('06:50:00'),
+ endDate: getMillisFromTime('06:51:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+ ];
+
+ const chunk4 = [
+ {
+ id: 6,
+ app: ONLINE,
+ beginDate: getMillisFromTime('06:00:00'),
+ endDate: getMillisFromTime('06:10:00'),
+ color: 'transparent',
+ diff: 10,
+ x: 0,
+ },
+ {
+ id: 7,
+ app: ONLINE,
+ beginDate: getMillisFromTime('05:00:00'),
+ endDate: getMillisFromTime('05:55:00'),
+ color: 'transparent',
+ diff: 55,
+ x: 1,
+ },
+ ];
+
+ const sorted = [...chunk1, ...chunk2, ...chunk3, ...chunk4];
+ const grouped = groupByBreaks(sorted, minBreakTime);
+
+ expect(grouped).toEqual([chunk1, chunk2, chunk3, chunk4]);
+ });
+});
+describe('OnlineChart getTotalOnlineDuration', () => {
+ it('getTotalOnlineDuration sums diffs', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:15:00'),
+ },
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ },
+ ];
+ const now = DateTime.fromMillis(last(items)?.endDate || 0);
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([25 * MINUTES]);
+ });
+
+ it('getTotalOnlineDuration returns 0 if no ONLINE items', () => {
+ const items = [
+ {
+ id: 1,
+ app: OFFLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:15:00'),
+ },
+ {
+ id: 2,
+ app: IDLE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ },
+ ];
+ const now = DateTime.fromMillis(last(items)?.endDate || 0);
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([0]);
+ });
+
+ it('getTotalOnlineDuration only sums ONLINE items', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:10:00'),
+ },
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:11:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ },
+ ];
+ const now = DateTime.fromMillis(last(items)?.endDate || 0);
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([13 * MINUTES]);
+ });
+
+ it('getTotalOnlineDuration take items until finds minimal break time.', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('07:00:00'),
+ endDate: getMillisFromTime('07:10:00'),
+ },
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ },
+
+ {
+ id: 3,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ },
+ {
+ id: 4,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:29:00'),
+ endDate: getMillisFromTime('08:39:00'),
+ },
+ ];
+
+ const now = DateTime.fromMillis(last(items)?.endDate || 0);
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([34 * MINUTES, 10 * MINUTES]);
+ });
+ it('getTotalOnlineDuration ignores items in creater then time specified', () => {
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('07:00:00'),
+ endDate: getMillisFromTime('07:10:00'),
+ },
+ {
+ id: 2,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ },
+
+ {
+ id: 3,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:25:00'),
+ },
+ {
+ id: 4,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:29:00'),
+ endDate: getMillisFromTime('08:39:00'),
+ },
+ ];
+
+ const now = getDateFromTime('08:15:00');
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([14 * MINUTES, 10 * MINUTES]);
+ });
+
+ it('getTotalOnlineDuration returns [0] if just taken a break', () => {
+ const now = getDateFromTime('08:25:00');
+ const items = [
+ {
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:00:00'),
+ endDate: getMillisFromTime('08:20:00'),
+ },
+ ];
+ const duration = getTotalOnlineDuration(now, items, minBreakTime);
+
+ expect(duration).toEqual([0]);
+ });
+});
+
+describe('OnlineChart isBetweenHours', () => {
+ it('isBetweenHours returns correctly if partly out of range in end part', () => {
+ const startHour = 12;
+ const endHour = 24;
+
+ const { beginClamp, endClamp } = getClampHours({ realDate, startHour, endHour });
+
+ expect(
+ isBetweenHours({ beginClamp, endClamp })({
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate_19_23_50,
+ endDate: endDate20_00_10,
+ }),
+ ).toBeTruthy();
+ });
+
+ it('isBetweenHours returns correctly if partly out of range in start part', () => {
+ const startHour = 0;
+ const endHour = 12;
+
+ const { beginClamp, endClamp } = getClampHours({ realDate, startHour, endHour });
+
+ expect(
+ isBetweenHours({ beginClamp, endClamp })({
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_11_50,
+ endDate: beginDate19_11_50,
+ }),
+ ).toBeTruthy();
+ });
+
+ it('isBetweenHours returns correctly false if date is out of range ', () => {
+ const startHour = 0;
+ const endHour = 12;
+
+ const { beginClamp, endClamp } = getClampHours({ realDate, startHour, endHour });
+
+ expect(
+ isBetweenHours({ beginClamp, endClamp })({
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate_19_23_50,
+ endDate: endDate20_00_10,
+ }),
+ ).toBeFalsy();
+ });
+
+ it('isBetweenHours returns correctly false if date is out of range in start part', () => {
+ const startHour = 12;
+ const endHour = 24;
+
+ const { beginClamp, endClamp } = getClampHours({ realDate, startHour, endHour });
+
+ expect(
+ isBetweenHours({ beginClamp, endClamp })({
+ id: 1,
+ app: ONLINE,
+ beginDate: beginDate19_11_50,
+ endDate: beginDate19_11_50,
+ }),
+ ).toBeFalsy();
+ });
+});
+
+test('isLessThanHours returns correctly', () => {
+ expect(
+ isLessThanHours(getDateFromTime('08:15:00'))({
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:15:00'),
+ endDate: getMillisFromTime('08:15:00'),
+ }),
+ ).toBeTruthy();
+
+ expect(
+ isLessThanHours(getDateFromTime('08:15:00'))({
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:14:00'),
+ endDate: getMillisFromTime('08:14:00'),
+ }),
+ ).toBeTruthy();
+
+ expect(
+ isLessThanHours(getDateFromTime('08:15:00'))({
+ id: 1,
+ app: ONLINE,
+ beginDate: getMillisFromTime('08:14:00'),
+ endDate: getMillisFromTime('08:16:00'),
+ }),
+ ).not.toBeTruthy();
+});
+
+export // Use an empty export to please Babel's single file emit.
+// https://github.com/Microsoft/TypeScript/issues/15230
+ {};
diff --git a/client/src/components/TrayLayout/OnlineChart.util.ts b/client/src/components/TrayLayout/OnlineChart.util.ts
new file mode 100644
index 00000000..3af3652c
--- /dev/null
+++ b/client/src/components/TrayLayout/OnlineChart.util.ts
@@ -0,0 +1,241 @@
+import { first, orderBy } from 'lodash';
+import { DateTime } from 'luxon';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { MAIN_THEME_COLOR } from '../../theme/theme.utils';
+
+const clampItem =
+ ({ beginClamp, endClamp }: { beginClamp: DateTime; endClamp: DateTime }) =>
+ (item: ITrackItem) => {
+ const beginDate = Math.max(beginClamp.toMillis(), item.beginDate);
+ const endDate = Math.min(endClamp.toMillis(), item.endDate);
+
+ return { ...item, beginDate, endDate };
+ };
+
+export const roundTo = (start: DateTime) => {
+ const roundToMin = 3;
+ const remainder = roundToMin - (start.hour % roundToMin);
+
+ return start.plus({ hours: remainder });
+};
+
+export enum CLOCK_MODE {
+ HOURS_12 = 12,
+ HOURS_24 = 24,
+}
+
+export const getQuarters = (date: DateTime, mode: CLOCK_MODE) => {
+ const startDate =
+ mode === CLOCK_MODE.HOURS_12 ? roundTo(date).minus({ hours: mode }).startOf('hour') : date.startOf('day');
+
+ const quarter = mode / 4;
+ return [
+ startDate,
+ startDate.plus({ hours: quarter }),
+ startDate.plus({ hours: quarter * 2 }),
+ startDate.plus({ hours: quarter * 3 }),
+ startDate.plus({ hours: quarter * 4 }),
+ ];
+};
+
+export const getClampHours = ({
+ realDate,
+ startHour,
+ endHour,
+}: {
+ realDate: DateTime;
+ startHour: number;
+ endHour: number;
+}) => {
+ const beginClamp = realDate.startOf('day').set({ hour: startHour });
+ const endClamp = realDate.startOf('day').set({ hour: endHour });
+ return { beginClamp, endClamp };
+};
+
+export const isBetweenHours =
+ ({ beginClamp, endClamp }: { beginClamp: DateTime; endClamp: DateTime }) =>
+ (item: ITrackItem) => {
+ const itemBegin = DateTime.fromMillis(item.beginDate);
+ const itemEnd = DateTime.fromMillis(item.endDate);
+
+ return (itemBegin >= beginClamp && itemBegin <= endClamp) || (itemEnd >= beginClamp && itemEnd <= endClamp);
+ };
+
+export const isLessThanHours = (now: DateTime) => (item: ITrackItem) => {
+ return DateTime.fromMillis(item.endDate) <= now;
+};
+
+export interface IOnlineChartItem {
+ beginDate: number;
+ id?: number;
+ endDate: number;
+ color: string;
+ diff: number;
+ x: number;
+}
+
+export const getOnlineTimesForChart = ({
+ beginClamp,
+ endClamp,
+ items,
+ mode,
+}: {
+ beginClamp: DateTime;
+ endClamp: DateTime;
+ items: ITrackItem[];
+ mode?: CLOCK_MODE;
+}) => {
+ const pieData: IOnlineChartItem[] = [];
+ const arr: { beginDate: number; endDate: number; diff: number; color?: string }[] = [];
+
+ const filtered = items.filter((item) => item.app === 'ONLINE').filter(isBetweenHours({ beginClamp, endClamp }));
+
+ if (filtered.length === 0) {
+ return [];
+ }
+
+ filtered.forEach((item) => {
+ const clampedItem = clampItem({ beginClamp, endClamp })(item);
+ const itemBegin = DateTime.fromMillis(clampedItem.beginDate);
+ const itemEnd = DateTime.fromMillis(clampedItem.endDate);
+ const diff = itemEnd.diff(itemBegin).as('minutes');
+ arr.push({ ...clampedItem, diff });
+ });
+
+ let nr = 0;
+ arr.forEach((item, idx) => {
+ const prev = 0 !== idx ? arr[idx - 1] : null;
+ const next = arr.length - 1 !== idx ? arr[idx + 1] : null;
+
+ if (!prev) {
+ const itemBegin = DateTime.fromMillis(item.beginDate);
+ const diff = itemBegin.diff(beginClamp).as('minutes');
+
+ if (diff > 0) {
+ pieData.push({
+ beginDate: beginClamp.toMillis(),
+ endDate: itemBegin.toMillis(),
+ color: 'transparent',
+ diff,
+ x: nr++,
+ });
+ }
+ }
+
+ pieData.push({ ...item, x: nr++, color: item.color || 'transparent' });
+
+ if (next) {
+ const itemEnd = DateTime.fromMillis(item.endDate);
+ const nextBegin = DateTime.fromMillis(next.beginDate);
+ const diff = nextBegin.diff(itemEnd).as('minutes');
+
+ if (diff > 0) {
+ pieData.push({
+ beginDate: itemEnd.toMillis(),
+ endDate: nextBegin.toMillis(),
+ color: 'transparent',
+ diff,
+ x: nr++,
+ });
+ }
+ }
+
+ if (!next) {
+ if (mode) {
+ const currentTimeItem: IOnlineChartItem = {
+ beginDate: DateTime.now().toMillis(),
+ endDate: DateTime.now().toMillis(),
+ color: MAIN_THEME_COLOR,
+ diff: mode === CLOCK_MODE.HOURS_12 ? 2 : 4,
+ x: nr++,
+ };
+ pieData.push(currentTimeItem);
+ }
+
+ const itemEnd = DateTime.fromMillis(item.endDate);
+ const diff = endClamp.diff(itemEnd).as('minutes');
+
+ if (diff > 0) {
+ pieData.push({
+ beginDate: itemEnd.toMillis(),
+ endDate: endClamp.toMillis(),
+ color: 'transparent',
+ diff,
+ x: nr++,
+ });
+ }
+ }
+ });
+
+ return pieData;
+};
+
+/*
+-On20-Br1-On20-Br1-On20 = On60
+-On20-Br5-On20 = On20
+-On20-Br4-On20 = On40
+-On20-Br4-On20-Br4-0n-20 = On60
+*/
+
+const getBeginEndDiff = (beginDate: DateTime, endDate: DateTime) => {
+ return endDate.diff(beginDate).milliseconds;
+};
+
+const MINUTES = 60 * 1000;
+
+export const groupByBreaks = (items: ITrackItem[], minBreakTime: number) => {
+ let newItems: ITrackItem[] = [];
+ let olderItem: ITrackItem;
+
+ const groups: ITrackItem[][] = [];
+
+ items.forEach((currentItem) => {
+ if (olderItem) {
+ const diff = getBeginEndDiff(
+ DateTime.fromMillis(currentItem.endDate),
+ DateTime.fromMillis(olderItem.beginDate),
+ );
+ const hasHadBreak = diff / MINUTES > minBreakTime;
+
+ if (hasHadBreak) {
+ groups.push(newItems);
+ newItems = [];
+ }
+ }
+ newItems.push(currentItem);
+ olderItem = currentItem;
+ });
+ groups.push(newItems);
+ return groups;
+};
+
+export const getTotalOnlineDuration = (now: DateTime, items: ITrackItem[], minBreakTime: number) => {
+ const filtered = items.filter((item) => item.app === 'ONLINE').filter(isLessThanHours(now));
+
+ if (filtered.length === 0) {
+ return [0];
+ }
+
+ const sorted = orderBy(filtered, ['beginDate'], ['desc']);
+
+ if (sorted.length === 0) {
+ return [0];
+ }
+
+ if (getBeginEndDiff(DateTime.fromMillis(first(sorted)?.endDate || 0), now) / MINUTES >= minBreakTime) {
+ return [0];
+ }
+
+ const onlyNeeded = groupByBreaks(sorted, minBreakTime);
+
+ if (onlyNeeded.length === 0) {
+ return [0];
+ }
+
+ return onlyNeeded.map((group) => {
+ const diffs = group.map(({ beginDate, endDate }) =>
+ getBeginEndDiff(DateTime.fromMillis(beginDate), DateTime.fromMillis(endDate)),
+ );
+ return diffs.reduce((a, b) => a + b, 0);
+ });
+};
diff --git a/client/src/components/TrayLayout/OnlineChartTooltipLabel.tsx b/client/src/components/TrayLayout/OnlineChartTooltipLabel.tsx
new file mode 100644
index 00000000..4c6b17f6
--- /dev/null
+++ b/client/src/components/TrayLayout/OnlineChartTooltipLabel.tsx
@@ -0,0 +1,98 @@
+import { DateTime } from 'luxon';
+import { PureComponent } from 'react';
+import { VictoryTooltip } from 'victory';
+import { shortTime } from '../../time.util';
+import { formatToTime } from '../LineCharts/LineChart.util';
+import { gray900 } from '../Timeline/ChartTheme';
+
+interface IProps {
+ text?: string;
+ width: number;
+ innerWidth: number;
+ color?: string;
+ datum?: { beginDate: number; endDate: number; diff: number };
+ theme: { tooltip: { style: { fontSize: number } }; isDark: boolean };
+}
+
+interface StyledLabelProps {
+ datum?: Record;
+ text?: string;
+ theme?: { isDark: boolean };
+ x?: number;
+ y?: number;
+ [key: string]: unknown;
+}
+
+// Custom label component for rich text formatting
+class StyledLabel extends PureComponent {
+ render() {
+ const { datum, text, theme, x = 0, y = 0 } = this.props;
+
+ if (!text || !datum) {
+ return null;
+ }
+
+ const { beginDate, endDate, diff } = datum as { beginDate: number; endDate: number; diff: number };
+ const now = DateTime.now();
+ const end = DateTime.fromMillis(endDate);
+ const timeAgoMs = now.diff(end).milliseconds;
+ const timeAgo = shortTime(timeAgoMs, { largest: 2 });
+ const timeText = `${formatToTime(beginDate)} - ${formatToTime(endDate)}`;
+ const diffText = shortTime(diff * 1000 * 60);
+ const timeAgoLabel = `${timeAgo} ago`;
+
+ // Set colors based on theme
+ const textColor = theme?.isDark ? 'white' : 'black';
+ const blueColor = 'var(--chakra-colors-blue-500)';
+
+ return (
+
+ {/* First line - large and bold */}
+
+ {diffText}
+
+
+ {/* Second line - smaller */}
+
+ {timeText}
+
+
+ {/* Third line - smaller and lighter */}
+
+ {timeAgoLabel}
+
+
+ );
+ }
+}
+
+export class OnlineChartTooltipLabel extends PureComponent {
+ public static defaultEvents = VictoryTooltip.defaultEvents;
+
+ public render() {
+ const { width, innerWidth, theme } = this.props;
+
+ return (
+
+ }
+ text={`${this.props.text}`}
+ orientation="top"
+ pointerLength={0}
+ cornerRadius={innerWidth / 2}
+ flyoutWidth={innerWidth}
+ flyoutHeight={innerWidth}
+ style={{ ...theme.tooltip.style, fontSize: 26 }}
+ flyoutStyle={{
+ fill: theme.isDark ? gray900 : 'white',
+ strokeWidth: 0,
+ fillOpacity: 1,
+ }}
+ />
+
+ );
+ }
+}
diff --git a/client/src/components/TrayLayout/TrayLayout.tsx b/client/src/components/TrayLayout/TrayLayout.tsx
index cdac5fdc..3838ad45 100644
--- a/client/src/components/TrayLayout/TrayLayout.tsx
+++ b/client/src/components/TrayLayout/TrayLayout.tsx
@@ -1,16 +1,17 @@
-import { Layout } from 'antd';
-import React from 'react';
+import { Box, Flex, useColorModeValue } from '@chakra-ui/react';
import { TrayMenu } from './TrayMenu';
-const { Content } = Layout;
+interface TrayLayoutProps {
+ children: React.ReactNode;
+}
-export function TrayLayout({ children }: any) {
+export function TrayLayout({ children }: TrayLayoutProps) {
return (
-
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
);
}
diff --git a/client/src/components/TrayLayout/TrayMenu.styles.ts b/client/src/components/TrayLayout/TrayMenu.styles.ts
deleted file mode 100644
index 14ee90ba..00000000
--- a/client/src/components/TrayLayout/TrayMenu.styles.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import styled from 'styled-components';
-
-import { Menu } from 'antd';
-
-export const Brand = styled.div`
- font-family: 'Berkshire Swash', cursive;
- text-align: left;
- font-size: 26px;
-`;
-
-export const MenuItem = styled(Menu.Item)`
- cursor: pointer;
-
- border-bottom: none !important;
-`;
-
-export const RightMenuItem = styled(MenuItem)`
- float: right !important;
-`;
-
-export const Img = styled.img`
- margin-right: 15px;
- margin-bottom: 6px;
-`;
diff --git a/client/src/components/TrayLayout/TrayMenu.tsx b/client/src/components/TrayLayout/TrayMenu.tsx
index 5ff451fe..4c532529 100644
--- a/client/src/components/TrayLayout/TrayMenu.tsx
+++ b/client/src/components/TrayLayout/TrayMenu.tsx
@@ -1,97 +1,45 @@
-import { Menu, Tooltip } from 'antd';
-import { ClockCircleOutlined, PoweroffOutlined, ArrowsAltOutlined } from '@ant-design/icons';
-import React, { useState, useEffect, memo } from 'react';
-import tocklerIcon from '../../assets/icons/tockler_icon.png';
-import { EventEmitter } from '../../services/EventEmitter';
-import { getOnlineStartTime } from '../../services/trackItem.api';
-import { Brand, Img, MenuItem, RightMenuItem } from './TrayMenu.styles';
-import moment from 'moment';
-import Moment from 'react-moment';
-import { Logger } from '../../logger';
-import { useWindowFocused } from '../../hooks/windowFocusedHook';
-
-const getNow = () => moment().subtract(1, 'seconds');
+import { Box, HStack, IconButton, Tooltip } from '@chakra-ui/react';
+import { memo } from 'react';
+import { AiOutlineArrowsAlt, AiOutlinePoweroff } from 'react-icons/ai';
+import { ElectronEventEmitter } from '../../services/ElectronEventEmitter';
+import { Header } from '../Header/Header';
export const TrayMenuPlain = () => {
- const { windowIsActive } = useWindowFocused();
- const [onlineSince, setOnlineSince] = useState();
const exitApp = () => {
- EventEmitter.send('close-app');
+ ElectronEventEmitter.send('close-app');
};
+
const toggleMainWindow = () => {
- EventEmitter.send('toggle-main-window');
+ ElectronEventEmitter.send('toggle-main-window');
};
- useEffect(() => {
- const systemIsOnline = () => {
- Logger.debug('system-is-online');
- setOnlineSince(getNow());
- };
- const systemIsNotOnline = () => {
- Logger.debug('system-is-not-online');
- setOnlineSince(null);
- };
-
- EventEmitter.on('system-is-online', systemIsOnline);
- EventEmitter.on('system-is-not-online', systemIsNotOnline);
-
- return () => {
- EventEmitter.off('system-is-online', systemIsOnline);
- EventEmitter.off('system-is-not-online', systemIsNotOnline);
- };
- }, []);
-
- useEffect(() => {
- if (windowIsActive) {
- const loadOnlineStartTime = async () => {
- const onlineStartTime = await getOnlineStartTime();
-
- setOnlineSince(onlineStartTime ? moment(onlineStartTime) : getNow());
- };
-
- loadOnlineStartTime();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [windowIsActive]);
-
return (
-
-
-
-
- Tockler
-
-
-
- {onlineSince && (
-
- <>
- {' '}
-
-
-
- >
-
- )}
-
-
-
-
+
-
-
-
+
+
+ }
+ />
-
-
+
+
);
};
-TrayMenuPlain.whyDidYouRender = true;
-
export const TrayMenu = memo(TrayMenuPlain);
diff --git a/client/src/components/TrayList/ShortTimeInterval.tsx b/client/src/components/TrayList/ShortTimeInterval.tsx
new file mode 100644
index 00000000..f4db6e01
--- /dev/null
+++ b/client/src/components/TrayList/ShortTimeInterval.tsx
@@ -0,0 +1,19 @@
+import { useEffect, useState } from 'react';
+import { useInterval } from '@chakra-ui/react';
+import { shortTime } from '../../time.util';
+
+const NO_VALUE = '-';
+
+export const ShortTimeInterval = ({ totalMs }) => {
+ const [time, setTime] = useState(totalMs);
+
+ useEffect(() => {
+ setTime(totalMs);
+ }, [totalMs]);
+
+ useInterval(() => {
+ setTime((oldTime) => oldTime + 1000);
+ }, 1000);
+
+ return shortTime(time, { largest: 2, units: ['d', 'h', 'm', 's'] }) || NO_VALUE;
+};
diff --git a/client/src/components/TrayList/TrayList.styles.ts b/client/src/components/TrayList/TrayList.styles.ts
deleted file mode 100644
index 1433c913..00000000
--- a/client/src/components/TrayList/TrayList.styles.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import styled from 'styled-components';
-
-export const Brand = styled.div`
- font-family: 'Berkshire Swash', cursive;
- text-align: left;
- font-size: 26px;
-`;
-
-export const Img = styled.img`
- margin-right: 15px;
- margin-bottom: 6px;
-`;
diff --git a/client/src/components/TrayList/TrayList.tsx b/client/src/components/TrayList/TrayList.tsx
index aeb3389c..7b21bc46 100644
--- a/client/src/components/TrayList/TrayList.tsx
+++ b/client/src/components/TrayList/TrayList.tsx
@@ -1,62 +1,42 @@
-import { List } from 'antd';
-import { groupBy, map, sortBy, sumBy } from 'lodash';
-import React, { memo } from 'react';
-import { convertDate } from '../../constants';
-import { TrayListItem } from './TrayListItem';
-
-const sumDiff = data =>
- sumBy(data, (c: any) => convertDate(c.endDate).diff(convertDate(c.beginDate)));
+import { StackDivider, VStack } from '@chakra-ui/react';
+import { memo } from 'react';
-const aggregateSameAppAndName = lastLogItems => {
- const grouped = groupBy(lastLogItems, item => `${item.app}_${item.title}`);
+import { orderBy } from 'lodash';
+import { ITrackItem } from '../../@types/ITrackItem';
+import { TrayListItem } from './TrayListItem';
- const mapped = map(grouped, items => {
- return {
- app: items[0].app,
- title: items[0].title,
- color: items[0].color,
- beginDate: sortBy(items, ['beginDate'])[0].beginDate,
- endDate: sortBy(items, ['endDate'])[items.length - 1].endDate,
- totalMs: sumDiff(items),
- };
- });
+export interface AggregatedTrackItem extends ITrackItem {
+ totalDuration: number;
+}
- return mapped;
-};
+const getKey = (item: ITrackItem) => `${item.app}_${item.title}`;
export function TrayListPlain({
lastLogItems,
- loading,
runningLogItem,
stopRunningLogItem,
startNewLogItem,
-}: any) {
- // Remove runningLogItem from aggregated values and show it as running Item
- let items;
- if (runningLogItem) {
- items = aggregateSameAppAndName(lastLogItems.filter(item => item.id !== runningLogItem.id));
- items.unshift(runningLogItem);
- } else {
- items = aggregateSameAppAndName(lastLogItems);
- }
+}: {
+ lastLogItems: AggregatedTrackItem[];
+ runningLogItem: ITrackItem | undefined;
+ stopRunningLogItem: () => void;
+ startNewLogItem: (item: AggregatedTrackItem) => void;
+}) {
+ const items: AggregatedTrackItem[] = orderBy(lastLogItems, ['endDate'], ['desc']);
return (
- (
+ } position="relative">
+ {items.map((item, index) => (
- )}
- />
+ ))}
+
);
}
-TrayListPlain.whyDidYouRender = true;
-
export const TrayList = memo(TrayListPlain);
diff --git a/client/src/components/TrayList/TrayListItem.tsx b/client/src/components/TrayList/TrayListItem.tsx
index d04bad5d..2cfd22b7 100644
--- a/client/src/components/TrayList/TrayListItem.tsx
+++ b/client/src/components/TrayList/TrayListItem.tsx
@@ -1,46 +1,24 @@
-import { Box, Flex } from 'reflexbox';
-import { Button, List } from 'antd';
-import { ClockCircleOutlined, PauseOutlined, CaretRightOutlined } from '@ant-design/icons';
+import { Box, Flex, HStack, IconButton, Text } from '@chakra-ui/react';
+import { memo } from 'react';
+import { FaPlay, FaStop } from 'react-icons/fa';
+import { convertDate, DATE_TIME_FORMAT } from '../../constants';
+import { shortTime } from '../../time.util';
+import { OverflowText } from '../TrackItemTable/OverflowText';
+import { ShortTimeInterval } from './ShortTimeInterval';
+import { AggregatedTrackItem } from './TrayList';
-import moment from 'moment';
-import React, { memo } from 'react';
-import Moment from 'react-moment';
-import TimeAgo from 'react-timeago';
-import styled from 'styled-components';
-import { convertDate } from '../../constants';
+const formatDate = (date: number) => convertDate(date).toFormat(DATE_TIME_FORMAT);
-const CustomListItem = styled(List.Item)`
- padding-left: 5px;
- margin-top: 5px;
- &:last-child {
- margin-bottom: 5px;
- }
- background-color: ${({ theme: { variables } }) => variables['@component-background']};
- border-left: 5px solid ${props => props.color};
-`;
-
-const Small = styled(Box)`
- font-size: 10px;
-`;
-
-const Medium = styled(Box)`
- font-size: 12px;
-`;
-
-const ActionBtn = styled(Flex)`
- margin: 2px 0;
-`;
-
-const CustomBox = styled(Box)`
- overflow: hidden;
-`;
-
-const formatDate = date => convertDate(date).format('YYYY-MM-DD HH:mm:ss');
-
-const FormattedTime = ({ item, isRunning }: any) => {
- const full = isRunning
- ? `${formatDate(item.beginDate)}`
- : `${formatDate(item.beginDate)} - ${formatDate(item.endDate)}`;
+const FormattedTime = ({
+ beginDate,
+ endDate,
+ isRunning,
+}: {
+ beginDate: number;
+ endDate: number;
+ isRunning: boolean;
+}) => {
+ const full = isRunning ? `${formatDate(beginDate)}` : `${formatDate(beginDate)} - ${formatDate(endDate)}`;
return {full} ;
};
@@ -50,78 +28,52 @@ export function TrayListItemPlain({
startNewLogItemFromOld,
stopRunningLogItem,
isRunning,
-}: any) {
+}: {
+ item: AggregatedTrackItem;
+ startNewLogItemFromOld: (item: AggregatedTrackItem) => void;
+ stopRunningLogItem: () => void;
+ isRunning: boolean;
+}) {
+ const { totalDuration, title, app, color } = item;
return (
-
-
-
-
-
- {item.app}
-
- {item.title}
-
+
+
+
+
+
-
-
-
-
- {' '}
-
- {' '}
-
- {!isRunning && (
-
- {item.endDate}
-
- )}
- {isRunning && (
-
- {item.beginDate}
-
- )}
-
-
-
-
-
- {item.startDate && }
-
-
-
-
- {item.totalMs > 1 && (
-
-
- Duration: {moment.duration(item.totalMs).format()}
-
-
- )}
+
+ {app}
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+
+ {totalDuration > 1 && (
+ <>
+ {!isRunning && shortTime(totalDuration)}
+ {isRunning && }
+ >
+ )}
+
-
- {isRunning && (
- }
- onClick={() => stopRunningLogItem()}
- />
- )}
- {!isRunning && (
- }
- onClick={() => startNewLogItemFromOld(item)}
- />
- )}
-
-
-
+
+ {isRunning && } onClick={() => stopRunningLogItem()} />}
+ {!isRunning && (
+ } onClick={() => startNewLogItemFromOld(item)} />
+ )}
+
+
);
}
-TrayListItemPlain.whyDidYouRender = true;
-
export const TrayListItem = memo(TrayListItemPlain);
diff --git a/client/src/components/TypeSelect.tsx b/client/src/components/TypeSelect.tsx
new file mode 100644
index 00000000..f88e3ae0
--- /dev/null
+++ b/client/src/components/TypeSelect.tsx
@@ -0,0 +1,22 @@
+import { Select } from '@chakra-ui/react';
+import { ITEM_TYPES } from '../utils';
+
+export const TypeSelect = ({ value, onChange }) => {
+ return (
+
+ {Object.keys(ITEM_TYPES).map((key) => (
+
+ {ITEM_TYPES[key]}
+
+ ))}
+
+ );
+};
diff --git a/client/src/components/charts.utils.ts b/client/src/components/charts.utils.ts
index fb8560c3..408c3841 100644
--- a/client/src/components/charts.utils.ts
+++ b/client/src/components/charts.utils.ts
@@ -1 +1,3 @@
-export const colorProp = ({ datum }) => datum.color;
+import { StringOrCallback } from 'victory';
+
+export const colorProp: StringOrCallback | undefined = ({ datum }: { datum?: { color: string } }) => datum?.color || '';
diff --git a/client/src/constants.ts b/client/src/constants.ts
index ed8b2306..68e71b13 100644
--- a/client/src/constants.ts
+++ b/client/src/constants.ts
@@ -1,14 +1,22 @@
-import moment from 'moment';
+import { DateTime } from 'luxon';
-export const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
-export const INPUT_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss ZZ';
+export const DATE_TIME_FORMAT = 'yyyy-MM-dd HH:mm:ss';
+export const INPUT_DATE_FORMAT = 'yyyy-MM-dd HH:mm:ss ZZ';
export const TIME_FORMAT = 'HH:mm:ss';
-export const DAY_MONTH_LONG_FORMAT = 'D MMM';
+export const DAY_MONTH_LONG_FORMAT = 'd MMM';
export const TIME_FORMAT_SHORT = 'HH:mm';
export const DURATION_FORMAT = 'w[w] d[d] h[h] m[m] s[s]';
export const DURATION_SETTINGS = { largest: 2 };
export const BREAKPOINT_TIME = '04:00';
-export const convertDate = (d: Date) => moment(d);
+export const convertDate = (d: Date | number | DateTime): DateTime => {
+ if (d instanceof DateTime) {
+ return d;
+ }
+ if (typeof d === 'number') {
+ return DateTime.fromMillis(d);
+ }
+ return DateTime.fromJSDate(d);
+};
export const COLORS = { green: '#8BC34A' };
diff --git a/client/src/dark.json b/client/src/dark.json
deleted file mode 100644
index ff203935..00000000
--- a/client/src/dark.json
+++ /dev/null
@@ -1 +0,0 @@
-{"@theme":"dark","@blue-1":"mix(color(~`colorPalette('@{blue-base}', 8) `), @component-background, 15%)","@blue-2":"mix(color(~`colorPalette('@{blue-base}', 7) `), @component-background, 25%)","@blue-3":"mix(@blue-base, @component-background, 30%)","@blue-4":"mix(@blue-base, @component-background, 45%)","@blue-5":"mix(@blue-base, @component-background, 65%)","@blue-6":"mix(@blue-base, @component-background, 85%)","@blue-7":"mix(color(~`colorPalette('@{blue-base}', 5) `), @component-background, 90%)","@blue-8":"mix(color(~`colorPalette('@{blue-base}', 4) `), @component-background, 95%)","@blue-9":"mix(color(~`colorPalette('@{blue-base}', 3) `), @component-background, 97%)","@blue-10":"mix(color(~`colorPalette('@{blue-base}', 2) `), @component-background, 98%)","@purple-1":"mix(color(~`colorPalette('@{purple-base}', 8) `), @component-background, 15%)","@purple-2":"mix(color(~`colorPalette('@{purple-base}', 7) `), @component-background, 25%)","@purple-3":"mix(@purple-base, @component-background, 30%)","@purple-4":"mix(@purple-base, @component-background, 45%)","@purple-5":"mix(@purple-base, @component-background, 65%)","@purple-6":"mix(@purple-base, @component-background, 85%)","@purple-7":"mix(color(~`colorPalette('@{purple-base}', 5) `), @component-background, 90%)","@purple-8":"mix(color(~`colorPalette('@{purple-base}', 4) `), @component-background, 95%)","@purple-9":"mix(color(~`colorPalette('@{purple-base}', 3) `), @component-background, 97%)","@purple-10":"mix(color(~`colorPalette('@{purple-base}', 2) `), @component-background, 98%)","@cyan-1":"mix(color(~`colorPalette('@{cyan-base}', 8) `), @component-background, 15%)","@cyan-2":"mix(color(~`colorPalette('@{cyan-base}', 7) `), @component-background, 25%)","@cyan-3":"mix(@cyan-base, @component-background, 30%)","@cyan-4":"mix(@cyan-base, @component-background, 45%)","@cyan-5":"mix(@cyan-base, @component-background, 65%)","@cyan-6":"mix(@cyan-base, @component-background, 85%)","@cyan-7":"mix(color(~`colorPalette('@{cyan-base}', 5) `), @component-background, 90%)","@cyan-8":"mix(color(~`colorPalette('@{cyan-base}', 4) `), @component-background, 95%)","@cyan-9":"mix(color(~`colorPalette('@{cyan-base}', 3) `), @component-background, 97%)","@cyan-10":"mix(color(~`colorPalette('@{cyan-base}', 2) `), @component-background, 98%)","@green-1":"mix(color(~`colorPalette('@{green-base}', 8) `), @component-background, 15%)","@green-2":"mix(color(~`colorPalette('@{green-base}', 7) `), @component-background, 25%)","@green-3":"mix(@green-base, @component-background, 30%)","@green-4":"mix(@green-base, @component-background, 45%)","@green-5":"mix(@green-base, @component-background, 65%)","@green-6":"mix(@green-base, @component-background, 85%)","@green-7":"mix(color(~`colorPalette('@{green-base}', 5) `), @component-background, 90%)","@green-8":"mix(color(~`colorPalette('@{green-base}', 4) `), @component-background, 95%)","@green-9":"mix(color(~`colorPalette('@{green-base}', 3) `), @component-background, 97%)","@green-10":"mix(color(~`colorPalette('@{green-base}', 2) `), @component-background, 98%)","@magenta-1":"mix(color(~`colorPalette('@{magenta-base}', 8) `), @component-background, 15%)","@magenta-2":"mix(color(~`colorPalette('@{magenta-base}', 7) `), @component-background, 25%)","@magenta-3":"mix(@magenta-base, @component-background, 30%)","@magenta-4":"mix(@magenta-base, @component-background, 45%)","@magenta-5":"mix(@magenta-base, @component-background, 65%)","@magenta-6":"mix(@magenta-base, @component-background, 85%)","@magenta-7":"mix(color(~`colorPalette('@{magenta-base}', 5) `), @component-background, 90%)","@magenta-8":"mix(color(~`colorPalette('@{magenta-base}', 4) `), @component-background, 95%)","@magenta-9":"mix(color(~`colorPalette('@{magenta-base}', 3) `), @component-background, 97%)","@magenta-10":"mix(color(~`colorPalette('@{magenta-base}', 2) `), @component-background, 98%)","@pink-1":"mix(color(~`colorPalette('@{pink-base}', 8) `), @component-background, 15%)","@pink-2":"mix(color(~`colorPalette('@{pink-base}', 7) `), @component-background, 25%)","@pink-3":"mix(@pink-base, @component-background, 30%)","@pink-4":"mix(@pink-base, @component-background, 45%)","@pink-5":"mix(@pink-base, @component-background, 65%)","@pink-6":"mix(@pink-base, @component-background, 85%)","@pink-7":"mix(color(~`colorPalette('@{pink-base}', 5) `), @component-background, 90%)","@pink-8":"mix(color(~`colorPalette('@{pink-base}', 4) `), @component-background, 95%)","@pink-9":"mix(color(~`colorPalette('@{pink-base}', 3) `), @component-background, 97%)","@pink-10":"mix(color(~`colorPalette('@{pink-base}', 2) `), @component-background, 98%)","@red-1":"mix(color(~`colorPalette('@{red-base}', 8) `), @component-background, 15%)","@red-2":"mix(color(~`colorPalette('@{red-base}', 7) `), @component-background, 25%)","@red-3":"mix(@red-base, @component-background, 30%)","@red-4":"mix(@red-base, @component-background, 45%)","@red-5":"mix(@red-base, @component-background, 65%)","@red-6":"mix(@red-base, @component-background, 85%)","@red-7":"mix(color(~`colorPalette('@{red-base}', 5) `), @component-background, 90%)","@red-8":"mix(color(~`colorPalette('@{red-base}', 4) `), @component-background, 95%)","@red-9":"mix(color(~`colorPalette('@{red-base}', 3) `), @component-background, 97%)","@red-10":"mix(color(~`colorPalette('@{red-base}', 2) `), @component-background, 98%)","@orange-1":"mix(color(~`colorPalette('@{orange-base}', 8) `), @component-background, 15%)","@orange-2":"mix(color(~`colorPalette('@{orange-base}', 7) `), @component-background, 25%)","@orange-3":"mix(@orange-base, @component-background, 30%)","@orange-4":"mix(@orange-base, @component-background, 45%)","@orange-5":"mix(@orange-base, @component-background, 65%)","@orange-6":"mix(@orange-base, @component-background, 85%)","@orange-7":"mix(color(~`colorPalette('@{orange-base}', 5) `), @component-background, 90%)","@orange-8":"mix(color(~`colorPalette('@{orange-base}', 4) `), @component-background, 95%)","@orange-9":"mix(color(~`colorPalette('@{orange-base}', 3) `), @component-background, 97%)","@orange-10":"mix(color(~`colorPalette('@{orange-base}', 2) `), @component-background, 98%)","@yellow-1":"mix(color(~`colorPalette('@{yellow-base}', 8) `), @component-background, 15%)","@yellow-2":"mix(color(~`colorPalette('@{yellow-base}', 7) `), @component-background, 25%)","@yellow-3":"mix(@yellow-base, @component-background, 30%)","@yellow-4":"mix(@yellow-base, @component-background, 45%)","@yellow-5":"mix(@yellow-base, @component-background, 65%)","@yellow-6":"mix(@yellow-base, @component-background, 85%)","@yellow-7":"mix(color(~`colorPalette('@{yellow-base}', 5) `), @component-background, 90%)","@yellow-8":"mix(color(~`colorPalette('@{yellow-base}', 4) `), @component-background, 95%)","@yellow-9":"mix(color(~`colorPalette('@{yellow-base}', 3) `), @component-background, 97%)","@yellow-10":"mix(color(~`colorPalette('@{yellow-base}', 2) `), @component-background, 98%)","@volcano-1":"mix(color(~`colorPalette('@{volcano-base}', 8) `), @component-background, 15%)","@volcano-2":"mix(color(~`colorPalette('@{volcano-base}', 7) `), @component-background, 25%)","@volcano-3":"mix(@volcano-base, @component-background, 30%)","@volcano-4":"mix(@volcano-base, @component-background, 45%)","@volcano-5":"mix(@volcano-base, @component-background, 65%)","@volcano-6":"mix(@volcano-base, @component-background, 85%)","@volcano-7":"mix(color(~`colorPalette('@{volcano-base}', 5) `), @component-background, 90%)","@volcano-8":"mix(color(~`colorPalette('@{volcano-base}', 4) `), @component-background, 95%)","@volcano-9":"mix(color(~`colorPalette('@{volcano-base}', 3) `), @component-background, 97%)","@volcano-10":"mix(color(~`colorPalette('@{volcano-base}', 2) `), @component-background, 98%)","@geekblue-1":"mix(color(~`colorPalette('@{geekblue-base}', 8) `), @component-background, 15%)","@geekblue-2":"mix(color(~`colorPalette('@{geekblue-base}', 7) `), @component-background, 25%)","@geekblue-3":"mix(@geekblue-base, @component-background, 30%)","@geekblue-4":"mix(@geekblue-base, @component-background, 45%)","@geekblue-5":"mix(@geekblue-base, @component-background, 65%)","@geekblue-6":"mix(@geekblue-base, @component-background, 85%)","@geekblue-7":"mix(color(~`colorPalette('@{geekblue-base}', 5) `), @component-background, 90%)","@geekblue-8":"mix(color(~`colorPalette('@{geekblue-base}', 4) `), @component-background, 95%)","@geekblue-9":"mix(color(~`colorPalette('@{geekblue-base}', 3) `), @component-background, 97%)","@geekblue-10":"mix(color(~`colorPalette('@{geekblue-base}', 2) `), @component-background, 98%)","@lime-1":"mix(color(~`colorPalette('@{lime-base}', 8) `), @component-background, 15%)","@lime-2":"mix(color(~`colorPalette('@{lime-base}', 7) `), @component-background, 25%)","@lime-3":"mix(@lime-base, @component-background, 30%)","@lime-4":"mix(@lime-base, @component-background, 45%)","@lime-5":"mix(@lime-base, @component-background, 65%)","@lime-6":"mix(@lime-base, @component-background, 85%)","@lime-7":"mix(color(~`colorPalette('@{lime-base}', 5) `), @component-background, 90%)","@lime-8":"mix(color(~`colorPalette('@{lime-base}', 4) `), @component-background, 95%)","@lime-9":"mix(color(~`colorPalette('@{lime-base}', 3) `), @component-background, 97%)","@lime-10":"mix(color(~`colorPalette('@{lime-base}', 2) `), @component-background, 98%)","@gold-1":"mix(color(~`colorPalette('@{gold-base}', 8) `), @component-background, 15%)","@gold-2":"mix(color(~`colorPalette('@{gold-base}', 7) `), @component-background, 25%)","@gold-3":"mix(@gold-base, @component-background, 30%)","@gold-4":"mix(@gold-base, @component-background, 45%)","@gold-5":"mix(@gold-base, @component-background, 65%)","@gold-6":"mix(@gold-base, @component-background, 85%)","@gold-7":"mix(color(~`colorPalette('@{gold-base}', 5) `), @component-background, 90%)","@gold-8":"mix(color(~`colorPalette('@{gold-base}', 4) `), @component-background, 95%)","@gold-9":"mix(color(~`colorPalette('@{gold-base}', 3) `), @component-background, 97%)","@gold-10":"mix(color(~`colorPalette('@{gold-base}', 2) `), @component-background, 98%)","@primary-1":"mix(color(~`colorPalette('@{primary-color}', 8) `), @component-background, 15%)","@primary-2":"mix(color(~`colorPalette('@{primary-color}', 7) `), @component-background, 25%)","@primary-3":"mix(@primary-color, @component-background, 30%)","@primary-4":"mix(@primary-color, @component-background, 45%)","@primary-5":"mix(@primary-color, @component-background, 65%)","@primary-6":"@primary-color","@primary-7":"mix(color(~`colorPalette('@{primary-color}', 5) `), @component-background, 90%)","@primary-8":"mix(color(~`colorPalette('@{primary-color}', 4) `), @component-background, 95%)","@primary-9":"mix(color(~`colorPalette('@{primary-color}', 3) `), @component-background, 97%)","@primary-10":"mix(color(~`colorPalette('@{primary-color}', 2) `), @component-background, 98%)","@popover-background":"#1f1f1f","@popover-customize-border-color":"#3a3a3a","@body-background":"@black","@component-background":"#141414","@text-color":"fade(@white, 85%)","@text-color-secondary":"fade(@white, 45%)","@text-color-inverse":"@white","@icon-color-hover":"fade(@white, 75%)","@heading-color":"fade(@white, 85%)","@item-active-bg":"@primary-1","@item-hover-bg":"fade(@white, 8%)","@border-color-base":"#434343","@border-color-split":"#303030","@background-color-light":"fade(@white, 4%)","@background-color-base":"fade(@white, 8%)","@disabled-color":"fade(@white, 30%)","@disabled-bg":"@background-color-base","@disabled-color-dark":"fade(@white, 30%)","@tree-bg":"transparent","@list-customize-card-bg":"transparent","@shadow-color":"rgba(0, 0, 0, 0.45)","@shadow-color-inverse":"@component-background","@box-shadow-base":"@shadow-2","@shadow-1-up":"0 -6px 16px -8px rgba(0, 0, 0, 0.32), 0 -9px 28px 0 rgba(0, 0, 0, 0.2),\n 0 -12px 48px 16px rgba(0, 0, 0, 0.12)","@shadow-1-down":"0 6px 16px -8px rgba(0, 0, 0, 0.32), 0 9px 28px 0 rgba(0, 0, 0, 0.2),\n 0 12px 48px 16px rgba(0, 0, 0, 0.12)","@shadow-1-right":"6px 0 16px -8px rgba(0, 0, 0, 0.32), 9px 0 28px 0 rgba(0, 0, 0, 0.2),\n 12px 0 48px 16px rgba(0, 0, 0, 0.12)","@shadow-2":"0 3px 6px -4px rgba(0, 0, 0, 0.48), 0 6px 16px 0 rgba(0, 0, 0, 0.32),\n 0 9px 28px 8px rgba(0, 0, 0, 0.2)","@btn-shadow":"0 2px 0 rgba(0, 0, 0, 0.015)","@btn-primary-shadow":"0 2px 0 rgba(0, 0, 0, 0.045)","@btn-text-shadow":"0 -1px 0 rgba(0, 0, 0, 0.12)","@btn-default-bg":"transparent","@btn-default-ghost-color":"@text-color","@btn-default-ghost-border":"fade(@white, 25%)","@btn-text-hover-bg":"rgba(255, 255, 255, 0.03)","@checkbox-check-bg":"transparent","@descriptions-bg":"@background-color-light","@divider-color":"rgba(255, 255, 255, 12%)","@modal-header-bg":"@popover-background","@modal-header-border-color-split":"@border-color-split","@modal-content-bg":"@popover-background","@modal-footer-border-color-split":"@border-color-split","@radio-solid-checked-color":"@white","@radio-dot-disabled-color":"fade(@white, 20%)","@radio-disabled-button-checked-bg":"fade(@white, 20%)","@radio-disabled-button-checked-color":"@disabled-color","@layout-body-background":"@body-background","@layout-header-background":"@popover-background","@layout-trigger-background":"#262626","@input-bg":"transparent","@input-placeholder-color":"fade(@white, 30%)","@input-icon-color":"fade(@white, 30%)","@input-number-handler-active-bg":"@item-hover-bg","@input-icon-hover-color":"fade(@white, 85%)","@select-background":"transparent","@select-dropdown-bg":"@popover-background","@select-clear-background":"@component-background","@select-selection-item-bg":"fade(@white, 8)","@select-selection-item-border-color":"@border-color-split","@select-multiple-disabled-background":"@component-background","@select-multiple-item-disabled-color":"#595959","@select-multiple-item-disabled-border-color":"@popover-background","@cascader-bg":"transparent","@cascader-menu-bg":"@popover-background","@cascader-menu-border-color-split":"@border-color-split","@tooltip-bg":"#434343","@menu-dark-submenu-bg":"@component-background","@menu-dark-bg":"@popover-background","@menu-popup-bg":"@popover-background","@message-notice-content-bg":"@popover-background","@notification-bg":"@popover-background","@link-hover-color":"@primary-5","@link-active-color":"@primary-7","@table-header-bg":"#1d1d1d","@table-body-sort-bg":"fade(@white, 1%)","@table-row-hover-bg":"#262626","@table-header-sort-bg":"#262626","@table-header-filter-active-bg":"#434343","@table-header-sort-active-bg":"#303030","@table-filter-btns-bg":"@popover-background","@table-expanded-row-bg":"@table-header-bg","@table-filter-dropdown-bg":"@popover-background","@table-expand-icon-bg":"transparent","@picker-basic-cell-hover-with-range-color":"darken(@primary-color, 35%)","@picker-basic-cell-disabled-bg":"#303030","@picker-border-color":"@border-color-split","@picker-bg":"transparent","@picker-date-hover-range-border-color":"darken(@primary-color, 20%)","@dropdown-menu-bg":"@popover-background","@dropdown-menu-submenu-disabled-bg":"transparent","@steps-nav-arrow-color":"fade(@white, 20%)","@steps-background":"transparent","@avatar-bg":"fade(@white, 30%)","@progress-steps-item-bg":"fade(@white, 8%)","@calendar-bg":"@popover-background","@calendar-input-bg":"@calendar-bg","@calendar-border-color":"transparent","@calendar-full-bg":"@component-background","@badge-text-color":"@white","@popover-bg":"@popover-background","@drawer-bg":"@popover-background","@card-actions-background":"@component-background","@card-skeleton-bg":"#303030","@card-shadow":"0 1px 2px -2px rgba(0, 0, 0, 0.64), 0 3px 6px 0 rgba(0, 0, 0, 0.48),\n 0 5px 12px 4px rgba(0, 0, 0, 0.36)","@transfer-item-hover-bg":"#262626","@comment-bg":"transparent","@comment-author-time-color":"fade(@white, 30%)","@comment-action-hover-color":"fade(@white, 65%)","@rate-star-bg":"fade(@white, 12%)","@switch-bg":"@white","@pagination-item-bg":"transparent","@pagination-item-bg-active":"transparent","@pagination-item-link-bg":"transparent","@pagination-item-disabled-bg-active":"fade(@white, 25%)","@pagination-item-disabled-color-active":"@black","@pagination-item-input-bg":"@pagination-item-bg","@page-header-back-color":"@icon-color","@page-header-ghost-bg":"transparent","@slider-rail-background-color":"#262626","@slider-rail-background-color-hover":"@border-color-base","@slider-dot-border-color":"@border-color-split","@slider-dot-border-color-active":"@primary-4","@skeleton-to-color":"fade(@white, 16%)","@alert-success-border-color":"@green-3","@alert-success-bg-color":"@green-1","@alert-success-icon-color":"@success-color","@alert-info-border-color":"@primary-3","@alert-info-bg-color":"@primary-1","@alert-info-icon-color":"@info-color","@alert-warning-border-color":"@gold-3","@alert-warning-bg-color":"@gold-1","@alert-warning-icon-color":"@warning-color","@alert-error-border-color":"@red-3","@alert-error-bg-color":"@red-1","@alert-error-icon-color":"@error-color","@timeline-color":"@border-color-split","@timeline-dot-color":"@primary-color","@mentions-dropdown-bg":"@popover-background","@primary-color":"@blue-6","@picker-basic-cell-active-with-range-color":"darken(@primary-color, 20%)"}
\ No newline at end of file
diff --git a/client/src/fontsource.d.ts b/client/src/fontsource.d.ts
new file mode 100644
index 00000000..dea1796e
--- /dev/null
+++ b/client/src/fontsource.d.ts
@@ -0,0 +1 @@
+declare module '@fontsource/inter';
diff --git a/client/src/ga-measurement-protocol.ts b/client/src/ga-measurement-protocol.ts
new file mode 100644
index 00000000..af6ccd01
--- /dev/null
+++ b/client/src/ga-measurement-protocol.ts
@@ -0,0 +1,93 @@
+import { getMachineId } from './services/settings.api';
+
+const trackingId: string = import.meta.env.VITE_TRACKING_ID || '';
+const secretKey: string = import.meta.env.VITE_SECRET_KEY || '';
+
+const config: {
+ measurementId: string;
+ measurementSecret: string;
+ measurementUrl: string;
+ measurementUrlDebug: string;
+ clientId: string | null;
+} = {
+ measurementId: trackingId,
+ measurementSecret: secretKey,
+
+ measurementUrl: 'https://www.google-analytics.com/mp/collect',
+ measurementUrlDebug: 'https://www.google-analytics.com/debug/mp/collect',
+ clientId: null,
+};
+
+const getUrl = () => {
+ const params = new URLSearchParams({
+ measurement_id: config.measurementId,
+ api_secret: config.measurementSecret,
+ });
+ return new URL(`${config.measurementUrl}?${params}`);
+};
+
+/**
+ * Tracks an analytics event via measurement protocol,
+ * i.e. via API.
+ *
+ * @param name the event name.
+ *
+ * @param params the event parameters.
+ *
+ * @param clientId the clientId for this event. Generated if not supplied.
+ */
+export const sendEvent = async ({ name, params }: { name: string; params }) => {
+ const url = getUrl();
+
+ if (!config.clientId) {
+ const machineId = await getMachineId();
+ config.clientId = machineId as string;
+ }
+
+ return fetch(url.toString(), {
+ // Ensure url is a string
+ method: 'POST',
+ body: JSON.stringify({
+ client_id: config.clientId,
+ non_personalized_ads: true,
+
+ events: [
+ {
+ name,
+ params,
+ },
+ ],
+ }),
+ });
+};
+
+export const setUserProperty = async (name, value) => {
+ const url = getUrl();
+
+ if (!config.clientId) {
+ const machineId = await getMachineId();
+ config.clientId = machineId as string;
+ }
+
+ return fetch(url, {
+ method: 'POST',
+ body: JSON.stringify({
+ client_id: config.clientId,
+ user_properties: {
+ [name]: {
+ value,
+ },
+ },
+ }),
+ });
+};
+
+export const pageView = ({ location, title }) => {
+ return sendEvent({
+ name: 'page_view',
+ params: {
+ page_title: title,
+ page_location: location,
+ },
+ });
+};
diff --git a/client/src/hooks/eventListenerHook.ts b/client/src/hooks/eventListenerHook.ts
index 111da286..c8209d00 100644
--- a/client/src/hooks/eventListenerHook.ts
+++ b/client/src/hooks/eventListenerHook.ts
@@ -27,9 +27,9 @@ function App(){
}*/
// Hook
-export function useEventListener(eventName, handler, element = window) {
+export function useEventListener(eventName: string, handler: any, element = window) {
// Create a ref that stores handler
- const savedHandler: any = useRef();
+ const savedHandler: any = useRef(null);
// Update ref.current value if handler changes.
// This allows our effect below to always get latest handler ...
@@ -47,7 +47,7 @@ export function useEventListener(eventName, handler, element = window) {
if (!isSupported) return;
// Create event listener that calls handler function stored in ref
- const eventListener = event => savedHandler.current(event);
+ const eventListener = (event) => savedHandler.current(event);
// Add event listener
element.addEventListener(eventName, eventListener);
diff --git a/client/src/hooks/intervalHook.ts b/client/src/hooks/intervalHook.ts
index fa340c0b..48af92b1 100644
--- a/client/src/hooks/intervalHook.ts
+++ b/client/src/hooks/intervalHook.ts
@@ -1,7 +1,7 @@
import { useEffect, useRef } from 'react';
-export function useInterval(callback, delay) {
- const savedCallback = useRef();
+export function useInterval(callback: () => void, delay: number | null, runImmediately: boolean = false): void {
+ const savedCallback = useRef<() => void>(() => {});
// Remember the latest callback.
useEffect(() => {
@@ -10,13 +10,19 @@ export function useInterval(callback, delay) {
// Set up the interval.
useEffect(() => {
- function tick() {
+ function tick(): void {
savedCallback.current();
}
- if (delay !== null) {
+
+ // Run immediately if specified
+ if (runImmediately) {
+ tick();
+ }
+
+ if (delay !== null && delay > 0) {
const id = setInterval(tick, delay);
return () => clearInterval(id);
}
return;
- }, [delay]);
+ }, [delay, runImmediately]);
}
diff --git a/client/src/hooks/useTrayData.tsx b/client/src/hooks/useTrayData.tsx
new file mode 100644
index 00000000..562130c5
--- /dev/null
+++ b/client/src/hooks/useTrayData.tsx
@@ -0,0 +1,106 @@
+import { useEffect, useState } from 'react';
+import { ITrackItem } from '../@types/ITrackItem';
+import { getTodayTimerange } from '../components/Timeline/timeline.utils';
+import { TrackItemType } from '../enum/TrackItemType';
+import { Logger } from '../logger';
+import { ElectronEventEmitter } from '../services/ElectronEventEmitter';
+import { getRunningLogItem } from '../services/settings.api';
+import { findAllDayItems, findFirstChunkLogItems } from '../services/trackItem.api';
+
+export const useTrayData = () => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [statusItems, setStatusItems] = useState([]);
+ const [logItems, setLogItems] = useState([]);
+ const [runningLogItem, setRunningLogItem] = useState();
+
+ // Listen for log item events
+ useEffect(() => {
+ const eventLogItemStarted = (logItem) => {
+ const newItem: ITrackItem = JSON.parse(logItem);
+ Logger.debug('log-trackItem-started:', newItem);
+ setRunningLogItem(newItem);
+ };
+
+ ElectronEventEmitter.on('log-item-started', eventLogItemStarted);
+
+ return () => {
+ ElectronEventEmitter.off('log-item-started', eventLogItemStarted);
+ };
+ }, []);
+
+ async function fetchStatusItems() {
+ const timerange = getTodayTimerange();
+ Logger.debug('TrayApp - Loading status items:', JSON.stringify(timerange));
+
+ try {
+ // Only fetch status items
+ const items = await findAllDayItems(timerange[0], timerange[1], TrackItemType.StatusTrackItem);
+ setStatusItems(items);
+ return items;
+ } catch (error) {
+ Logger.error('Error fetching status items:', error);
+ return [];
+ }
+ }
+
+ async function fetchLogItems() {
+ Logger.debug('TrayApp - Loading log items');
+
+ try {
+ const items = await findFirstChunkLogItems();
+ setLogItems(items);
+ // Logger.debug('TrayApp - Log items loaded:', items);
+ return items;
+ } catch (error) {
+ Logger.error('Error fetching log items:', error);
+ return [];
+ }
+ }
+
+ async function fetchRunningLogItem() {
+ Logger.debug('TrayApp - Loading running log item');
+
+ try {
+ const logItem = await getRunningLogItem();
+ setRunningLogItem(logItem);
+ Logger.debug('TrayApp - Running log item loaded:', logItem);
+ return logItem;
+ } catch (error) {
+ Logger.error('Error fetching running log item:', error);
+ return undefined;
+ }
+ }
+
+ // Fetch both status and log items
+ async function refreshData() {
+ // Prevent multiple simultaneous refreshes
+ if (isLoading) {
+ Logger.debug('Already loading data, skipping refresh');
+ return;
+ }
+
+ setIsLoading(true);
+ Logger.debug('Starting data refresh');
+
+ try {
+ await Promise.all([fetchStatusItems(), fetchLogItems(), fetchRunningLogItem()]);
+ Logger.debug('Data refresh completed');
+ } catch (error) {
+ Logger.error('Error during data refresh:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ return {
+ isLoading,
+ statusItems,
+ logItems,
+ runningLogItem,
+ setRunningLogItem,
+ fetchStatusItems,
+ fetchLogItems,
+ fetchRunningLogItem,
+ refreshData,
+ };
+};
diff --git a/client/src/hooks/windowFocusedHook.ts b/client/src/hooks/windowFocusedHook.ts
index 728e3a7b..4ae1e1d9 100644
--- a/client/src/hooks/windowFocusedHook.ts
+++ b/client/src/hooks/windowFocusedHook.ts
@@ -1,31 +1,40 @@
-import { useEffect, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
export const useWindowFocused = () => {
const [windowIsActive, setWindowIsActive] = useState(true);
- function handleActivity(forcedFlag) {
+ // Use useCallback to ensure stable reference across renders
+ const handleActivity = useCallback((forcedFlag?: boolean) => {
if (typeof forcedFlag === 'boolean') {
- return forcedFlag ? setWindowIsActive(true) : setWindowIsActive(false);
+ setWindowIsActive(forcedFlag);
+ return;
}
- return document.hidden ? setWindowIsActive(false) : setWindowIsActive(true);
- }
+ setWindowIsActive(!document.hidden);
+ }, []);
useEffect(() => {
- document.addEventListener('visibilitychange', handleActivity);
- document.addEventListener('blur', () => handleActivity(false));
- window.addEventListener('blur', () => handleActivity(false));
- window.addEventListener('focus', () => handleActivity(true));
- document.addEventListener('focus', () => handleActivity(true));
+ // Initial state
+ handleActivity();
+
+ const handleVisibilityChange = () => handleActivity();
+ const handleBlur = () => handleActivity(false);
+ const handleFocus = () => handleActivity(true);
+
+ document.addEventListener('visibilitychange', handleVisibilityChange);
+ document.addEventListener('blur', handleBlur);
+ window.addEventListener('blur', handleBlur);
+ window.addEventListener('focus', handleFocus);
+ document.addEventListener('focus', handleFocus);
return () => {
- window.removeEventListener('blur', handleActivity);
- document.removeEventListener('blur', handleActivity);
- window.removeEventListener('focus', handleActivity);
- document.removeEventListener('focus', handleActivity);
- document.removeEventListener('visibilitychange', handleActivity);
+ window.removeEventListener('blur', handleBlur);
+ document.removeEventListener('blur', handleBlur);
+ window.removeEventListener('focus', handleFocus);
+ document.removeEventListener('focus', handleFocus);
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
};
- }, []);
+ }, [handleActivity]);
return { windowIsActive };
};
diff --git a/client/src/index.tsx b/client/src/index.tsx
deleted file mode 100644
index 0339ea41..00000000
--- a/client/src/index.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import ReactDOM from 'react-dom';
-import { HashRouter as Router } from 'react-router-dom';
-import 'typeface-berkshire-swash';
-import { MainRouter } from './router';
-import { setupFrontendListener } from 'eiphop';
-import { AppDataProvider } from './routes/AppDataProvider';
-import { StoreProvider } from 'easy-peasy';
-import { mainStore } from './store/mainStore';
-
-(window as any).CSPSettings = {
- nonce: 'nonce',
-};
-const { ipcRenderer } = window as any;
-
-setupFrontendListener({ ipcRenderer } as any);
-
-if (process.env.NODE_ENV !== 'production') {
- const whyDidYouRender = require('@welldone-software/why-did-you-render');
- whyDidYouRender(React);
-}
-
-ReactDOM.render(
-
-
-
-
-
-
- ,
- document.getElementById('root') as HTMLElement,
-);
diff --git a/client/src/light.json b/client/src/light.json
deleted file mode 100644
index 63222a88..00000000
--- a/client/src/light.json
+++ /dev/null
@@ -1 +0,0 @@
-{"@line-height-base":"1.66667","@mode":"compact","@font-size-base":"12px","@font-size-lg":"@font-size-base + 2px","@default-padding-lg":"24px","@default-padding-md":"16px","@default-padding-sm":"12px","@default-padding-xs":"8px","@default-padding-xss":"4px","@padding-lg":"16px","@padding-md":"8px","@padding-sm":"8px","@padding-xs":"4px","@padding-xss":"0px","@control-padding-horizontal":"@padding-sm","@control-padding-horizontal-sm":"@default-padding-xs","@margin-lg":"16px","@margin-md":"8px","@margin-sm":"8px","@margin-xs":"4px","@margin-xss":"0px","@height-base":"28px","@height-lg":"32px","@height-sm":"22px","@btn-padding-horizontal-base":"@default-padding-sm - 1px","@btn-padding-horizontal-lg":"@btn-padding-horizontal-base","@btn-padding-horizontal-sm":"@default-padding-xs - 1px","@btn-square-only-icon-size-lg":"16px","@btn-square-only-icon-size":"14px","@btn-square-only-icon-size-sm":"12px","@breadcrumb-font-size":"@font-size-base","@breadcrumb-icon-font-size":"@font-size-base","@dropdown-line-height":"18px","@menu-item-padding":"0 12px","@menu-horizontal-line-height":"38px","@menu-inline-toplevel-item-height":"32px","@menu-item-height":"32px","@menu-item-vertical-margin":"0px","@menu-item-boundary-margin":"0px","@menu-icon-margin-right":"8px","@checkbox-size":"14px","@checkbox-group-item-margin-right":"6px","@picker-panel-cell-height":"22px","@picker-panel-cell-width":"32px","@picker-text-height":"32px","@picker-time-panel-cell-height":"24px","@picker-panel-without-time-cell-height":"48px","@form-item-margin-bottom":"16px","@form-vertical-label-padding":"0 0 4px","@rate-star-size":"16px","@radio-size":"14px","@radio-top":"-2px","@radio-wrapper-margin-right":"6px","@switch-height":"20px","@switch-sm-height":"14px","@switch-min-width":"40px","@switch-sm-min-width":"24px","@switch-inner-margin-min":"4px","@switch-inner-margin-max":"22px","@slider-handle-size":"12px","@slider-handle-margin-top":"-4px","@input-padding-vertical-base":"round(\n max(\n round((@input-height-base - @font-size-base * @line-height-base) / 2 * 10) / 10 -\n @border-width-base,\n 2px\n )\n)","@input-padding-horizontal-lg":"11px","@page-header-padding":"16px","@page-header-padding-vertical":"8px","@page-header-heading-title":"16px","@page-header-heading-sub-title":"12px","@page-header-tabs-tab-font-size":"14px","@pagination-mini-options-size-changer-top":"1px","@pagination-item-size-sm":"22px","@cascader-dropdown-line-height":"@dropdown-line-height","@select-dropdown-height":"@height-base","@select-single-item-height-lg":"32px","@select-multiple-item-height":"@input-height-base - max(@input-padding-vertical-base, 4) * 2","@select-multiple-item-height-lg":"24px","@select-multiple-item-spacing-half":"3px","@tree-title-height":"20px","@transfer-item-padding-vertical":"3px","@transfer-list-search-icon-top":"8px","@transfer-header-height":"36px","@comment-actions-margin-bottom":"0px","@comment-actions-margin-top":"@margin-xs","@comment-content-detail-p-margin-bottom":"0px","@steps-icon-size":"24px","@steps-icon-custom-size":"20px","@steps-icon-custom-font-size":"20px","@steps-icon-custom-top":"2px","@steps-icon-margin":"2px 8px 2px 0","@steps-icon-font-size":"@font-size-base","@steps-dot-top":"4px","@steps-icon-top":"0px","@steps-small-icon-size":"20px","@steps-vertical-icon-width":"12px","@steps-vertical-tail-width":"12px","@steps-vertical-tail-width-sm":"10px","@collapse-header-padding-extra":"32px","@collapse-content-padding":"@padding-md @padding-lg","@list-item-meta-description-font-size":"@font-size-sm","@list-item-padding-sm":"4px 12px","@list-item-padding-lg":"12px 16px","@drawer-header-padding":"11px @padding-lg","@drawer-footer-padding-vertical":"@padding-sm","@drawer-header-close-size":"44px","@modal-header-padding":"11px @modal-header-padding-horizontal","@modal-footer-padding-vertical":"@padding-sm","@modal-header-close-size":"44px","@modal-confirm-body-padding":"24px 24px 16px","@message-notice-content-padding":"8px 16px","@popover-min-height":"28px","@popover-padding-horizontal":"@default-padding-sm","@card-padding-base":"12px","@card-head-height":"36px","@card-head-font-size":"@card-head-font-size-sm","@card-head-padding":"8.5px","@card-padding-base-sm":"@card-padding-base","@card-head-height-sm":"30px","@card-head-padding-sm":"6px","@card-actions-li-margin":"4px 0","@card-head-tabs-margin-bottom":"-9px","@table-padding-vertical":"12px","@table-padding-horizontal":"8px","@table-padding-vertical-md":"8px","@table-padding-horizontal-md":"8px","@table-padding-vertical-sm":"4px","@table-padding-horizontal-sm":"4px","@table-selection-column-width":"32px","@statistic-content-font-size":"20px","@alert-with-description-no-icon-padding-vertical":"7px","@alert-with-description-padding-vertical":"11px","@alert-icon-top":"7px + @font-size-base * @line-height-base / 2 - @font-size-base / 2","@alert-with-description-icon-size":"20px","@skeleton-paragraph-margin-top":"20px","@skeleton-paragraph-li-margin-top":"12px","@skeleton-paragraph-li-height":"14px","@skeleton-title-height":"14px","@skeleton-title-paragraph-margin-top":"20px","@descriptions-title-margin-bottom":"8px","@descriptions-default-padding":"12px @padding-lg","@descriptions-item-padding-bottom":"@padding-xs","@avatar-size-base":"28px","@avatar-size-lg":"32px","@avatar-size-sm":"22px","@avatar-font-size-base":"16px","@avatar-font-size-lg":"20px","@avatar-font-size-sm":"12px","@badge-height":"18px","@tag-line-height":"18px","@notification-padding-vertical":"12px","@notification-padding-horizontal":"16px","@result-title-font-size":"20px","@result-icon-font-size":"64px","@result-extra-margin":"24px 0 0 0","@anchor-link-top":"4px","@anchor-link-left":"16px","@anchor-link-padding":"@anchor-link-top 0 @anchor-link-top @anchor-link-left","@tabs-card-horizontal-padding":"4px @padding-md","@progress-circle-text-font-size":"0.833333em","@image-size-base":"48px","@image-font-size-base":"24px","@primary-color":"@blue-6"}
\ No newline at end of file
diff --git a/client/src/logger.ts b/client/src/logger.ts
index b5a7e323..724ea704 100644
--- a/client/src/logger.ts
+++ b/client/src/logger.ts
@@ -1,14 +1,34 @@
-import { getIsLoggingEnabled } from './services/settings.api';
+import log from 'electron-log/renderer';
-export const Logger: any = (window as any).logger;
+// Set reasonable log levels
+log.transports.console.level = 'info';
+log.transports.ipc.level = 'info';
-const isProd = process.env.NODE_ENV === 'production';
-Logger.transports.console.level = isProd ? 'warn' : 'debug';
+// Prevent excessive logging by filtering messages
+const originalConsoleTransport = log.transports.console;
+const ignorePatterns = [
+ 'Warning: ',
+ 'React does not recognize',
+ 'Unknown prop',
+ 'Invalid DOM property',
+ 'Non-serializable values were found',
+];
-let isLoggingEnabled = getIsLoggingEnabled();
+// Create a wrapped console transport that filters messages
+log.transports.console.level = originalConsoleTransport.level;
+log.transports.console.format = originalConsoleTransport.format;
-if (isLoggingEnabled) {
- Logger.transports.file.level = 'debug';
-} else {
- Logger.transports.file.level = false;
-}
+// Override just the write function
+const originalWriteFn = log.transports.console.writeFn;
+log.transports.console.writeFn = (messageObj) => {
+ // Skip messages matching ignore patterns
+ const message = messageObj.message || messageObj;
+ const msgStr = String((message.data && message.data[0]) || '');
+ if (ignorePatterns.some((pattern) => msgStr.includes(pattern))) {
+ return;
+ }
+
+ originalWriteFn(messageObj);
+};
+
+export const Logger = log;
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 00000000..4791b020
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,31 @@
+import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
+import '@fontsource/inter';
+import { createRoot } from 'react-dom/client';
+import { HashRouter as Router } from 'react-router-dom';
+import { ErrorBoundary } from './components/ErrorBoundary/ErrorBoundary';
+import { MainRouter } from './MainRouter';
+import { theme } from './theme/theme';
+import { setAppParams } from './useGoogleAnalytics.utils';
+
+setAppParams();
+
+// Set CSP settings for the window
+// @ts-expect-error - CSPSettings is not defined on Window type
+window.CSPSettings = {
+ nonce: 'nonce',
+};
+
+const root = createRoot(document.getElementById('root') as HTMLElement);
+
+root.render(
+ <>
+
+
+
+
+
+
+
+
+ >,
+);
diff --git a/client/src/react-app-env.d.ts b/client/src/react-app-env.d.ts
deleted file mode 100644
index 30da8962..00000000
--- a/client/src/react-app-env.d.ts
+++ /dev/null
@@ -1 +0,0 @@
-// /
diff --git a/client/src/registerServiceWorker.ts b/client/src/registerServiceWorker.ts
deleted file mode 100644
index c12784cb..00000000
--- a/client/src/registerServiceWorker.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { Logger } from './logger';
-
-// tslint:disable:no-Logger
-// In production, we register a service worker to serve assets from local cache.
-
-// This lets the app load faster on subsequent visits in production, and gives
-// it offline capabilities. However, it also means that developers (and users)
-// will only see deployed updates on the 'N+1' visit to a page, since previously
-// cached resources are updated in the background.
-
-// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
-// This link also includes instructions on opting out of this behavior.
-
-const isLocalhost = Boolean(
- window.location.hostname === 'localhost' ||
- // [::1] is the IPv6 localhost address.
- window.location.hostname === '[::1]' ||
- // 127.0.0.1/8 is considered localhost for IPv4.
- window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/),
-);
-
-export default function register() {
- if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
- // The URL constructor is available in all browsers that support SW.
- const publicUrl = new URL(process.env.PUBLIC_URL!, window.location.toString());
- if (publicUrl.origin !== window.location.origin) {
- // Our service worker won't work if PUBLIC_URL is on a different origin
- // from what our page is served on. This might happen if a CDN is used to
- // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
- return;
- }
-
- window.addEventListener('load', () => {
- const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
-
- if (!isLocalhost) {
- // Is not local host. Just register service worker
- registerValidSW(swUrl);
- } else {
- // This is running on localhost. Lets check if a service worker still exists or not.
- checkValidServiceWorker(swUrl);
- }
- });
- }
-}
-
-function registerValidSW(swUrl: string) {
- navigator.serviceWorker
- .register(swUrl)
- .then(registration => {
- registration.onupdatefound = () => {
- const installingWorker = registration.installing;
- if (installingWorker) {
- installingWorker.onstatechange = () => {
- if (installingWorker.state === 'installed') {
- if (navigator.serviceWorker.controller) {
- // At this point, the old content will have been purged and
- // the fresh content will have been added to the cache.
- // It's the perfect time to display a 'New content is
- // available; please refresh.' message in your web app.
- Logger.debug('New content is available; please refresh.');
- } else {
- // At this point, everything has been precached.
- // It's the perfect time to display a
- // 'Content is cached for offline use.' message.
- Logger.debug('Content is cached for offline use.');
- }
- }
- };
- }
- };
- })
- .catch(error => {
- Logger.error('Error during service worker registration:', error);
- });
-}
-
-function checkValidServiceWorker(swUrl: string) {
- // Check if the service worker can be found. If it can't reload the page.
- fetch(swUrl)
- .then(response => {
- // Ensure service worker exists, and that we really are getting a JS file.
- if (
- response.status === 404 ||
- response.headers.get('content-type')!.indexOf('javascript') === -1
- ) {
- // No service worker found. Probably a different app. Reload the page.
- navigator.serviceWorker.ready.then(registration => {
- registration.unregister().then(() => {
- window.location.reload();
- });
- });
- } else {
- // Service worker found. Proceed as normal.
- registerValidSW(swUrl);
- }
- })
- .catch(() => {
- Logger.debug('No internet connection found. App is running in offline mode.');
- });
-}
-
-export function unregister() {
- if ('serviceWorker' in navigator) {
- navigator.serviceWorker.ready.then(registration => {
- registration.unregister();
- });
- }
-}
diff --git a/client/src/router.tsx b/client/src/router.tsx
deleted file mode 100644
index b27c86d0..00000000
--- a/client/src/router.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React, { useEffect, useCallback } from 'react';
-
-import { Route, Switch } from 'react-router-dom';
-import { RootProvider } from './RootContext';
-import { NotFound } from './routes/404';
-import { MainAppPage } from './routes/MainAppPage';
-import { TrayAppPage } from './routes/TrayAppPage';
-
-import { ConfigProvider } from 'antd';
-import moment from 'moment';
-import 'moment/min/locales';
-import { useAppDataState } from './routes/AppDataProvider';
-import { ThemeProvider } from 'styled-components';
-import { EventEmitter } from './services/EventEmitter';
-import { Logger } from './logger';
-import { getThemeFromStorage, saveThemeToStorage } from './services/settings.api';
-import { ChartThemeProvider } from './routes/ChartThemeProvider';
-import { useStoreActions, useStoreState } from './store/easyPeasy';
-import { useGoogleAnalytics } from './useGoogleAnalytics';
-
-moment.locale('en-gb');
-
-const savedTheme = getThemeFromStorage();
-
-export function MainRouter() {
- useGoogleAnalytics();
- const theme = useStoreState(state => state.theme);
- const setThemeWithVariables = useStoreActions(actions => actions.setThemeWithVariables);
- const setThemeByName = useStoreActions(actions => actions.setThemeByName);
-
- useEffect(() => {
- if (savedTheme && savedTheme.variables) {
- setThemeWithVariables(savedTheme);
- }
- }, [setThemeWithVariables]);
-
- const changeActiveTheme = useCallback(
- (event, themeName) => {
- setThemeByName(themeName);
- },
- [setThemeByName],
- );
-
- useEffect(() => {
- EventEmitter.on('activeThemeChanged', changeActiveTheme);
- return () => {
- Logger.debug('Clearing eventEmitter');
- EventEmitter.off('activeThemeChanged', changeActiveTheme);
- };
- }, [changeActiveTheme]); // eslint-disable-line react-hooks/exhaustive-deps
-
- useEffect(() => {
- saveThemeToStorage(theme);
- }, [theme]);
-
- const state: any = useAppDataState();
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/client/src/routes/404.tsx b/client/src/routes/404.tsx
index 395dd920..946d1c84 100644
--- a/client/src/routes/404.tsx
+++ b/client/src/routes/404.tsx
@@ -1,9 +1,6 @@
-import React from 'react';
-import useRouter from 'use-react-router';
import { Logger } from '../logger';
export const NotFound = () => {
- const router = useRouter();
- Logger.error('404 Not Found', router);
+ Logger.error('404 Not Found');
return Tockler Error: 404 Not Found
;
};
diff --git a/client/src/routes/AppDataProvider.tsx b/client/src/routes/AppDataProvider.tsx
deleted file mode 100644
index 6a9a971b..00000000
--- a/client/src/routes/AppDataProvider.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React, { useState, createContext, useContext } from 'react';
-
-import enGB from 'antd/es/locale/en_GB';
-
-export const AppDataContext = createContext({});
-
-export const AppDataProvider = ({ children }) => {
- const [locale, setLocale] = useState(enGB);
-
- const defaultContext = {
- locale,
- setLocale,
- };
-
- return {children} ;
-};
-
-export const useAppDataState = () => {
- const context = useContext(AppDataContext);
-
- if (context === undefined) {
- throw new Error('useAppDataState must be used within a AppDataContext');
- }
-
- return context;
-};
diff --git a/client/src/routes/ChartThemeProvider.tsx b/client/src/routes/ChartThemeProvider.tsx
index 531779e5..04b7f5d8 100644
--- a/client/src/routes/ChartThemeProvider.tsx
+++ b/client/src/routes/ChartThemeProvider.tsx
@@ -1,11 +1,13 @@
-import React, { useState, createContext, useContext, useEffect } from 'react';
+import { useState, createContext, useContext, useEffect } from 'react';
import { Logger } from '../logger';
import { getChartTheme } from '../components/Timeline/ChartTheme';
import { THEMES } from '../store/theme.util';
+import { useColorMode } from '@chakra-ui/react';
export const ChartThemeContext = createContext({ chartTheme: getChartTheme(false) });
-export const ChartThemeProvider = ({ theme, children }) => {
+export const ChartThemeProvider = ({ children }) => {
+ const { colorMode } = useColorMode();
const [chartTheme, setChartTheme] = useState(getChartTheme(false));
const defaultContext = {
@@ -14,13 +16,11 @@ export const ChartThemeProvider = ({ theme, children }) => {
};
useEffect(() => {
- Logger.info('Changing chartTheme');
+ Logger.info('Changing chartTheme', colorMode);
- setChartTheme(theme.name === THEMES.DARK ? getChartTheme(true) : getChartTheme(false));
- }, [theme]);
- return (
- {children}
- );
+ setChartTheme(colorMode === THEMES.DARK ? getChartTheme(true) : getChartTheme(false));
+ }, [colorMode]);
+ return {children} ;
};
export const useChartThemeState = () => {
diff --git a/client/src/routes/LiveViewOverlay.tsx b/client/src/routes/LiveViewOverlay.tsx
new file mode 100644
index 00000000..9d57d764
--- /dev/null
+++ b/client/src/routes/LiveViewOverlay.tsx
@@ -0,0 +1,60 @@
+import { Box, Text, useColorModeValue } from '@chakra-ui/react';
+import { useState } from 'react';
+import { useStoreActions, useStoreState } from '../store/easyPeasy';
+
+const BELOW_SEARCH_BAR_HEIGHT = 70;
+
+export function LiveViewOverlay() {
+ const liveView = useStoreState((state) => state.liveView);
+ const setLiveView = useStoreActions((actions) => actions.setLiveView);
+
+ const [isHovering, setIsHovering] = useState(false);
+
+ const overlayBoxBg = useColorModeValue('white', 'gray.800');
+
+ const handleOverlayClick = () => {
+ setLiveView(false);
+ };
+
+ return (
+ <>
+ {liveView && (
+ setIsHovering(true)}
+ onMouseLeave={() => setIsHovering(false)}
+ m={4}
+ >
+ {isHovering && (
+
+
+ Live View Active
+
+
+ The timeline is automatically updating in real-time.
+
+ It's best to disable it before making edits.
+
+ Click anywhere to disable live view.
+
+
+ )}
+
+ )}
+ >
+ );
+}
diff --git a/client/src/routes/MainAppPage.tsx b/client/src/routes/MainAppPage.tsx
index 88378292..8f2e91af 100644
--- a/client/src/routes/MainAppPage.tsx
+++ b/client/src/routes/MainAppPage.tsx
@@ -1,22 +1,74 @@
-import React from 'react';
-import { Route, Switch } from 'react-router-dom';
+import { Route, Routes } from 'react-router-dom';
+import { ErrorBoundary } from '../components/ErrorBoundary/ErrorBoundary';
+import { MainLayout } from '../components/MainLayout/MainLayout';
+import { SearchPage } from './SearchPage';
import { SettingsPage } from './SettingsPage';
import { SummaryPage } from './SummaryPage';
+import { SupportPage } from './SupportPage';
import { TimelinePage } from './TimelinePage';
import { TrayAppPage } from './TrayAppPage';
-import { SearchPage } from './SearchPage';
-import { SupportPage } from './SupportPage';
-export function MainAppPage({ history }: any) {
+export function MainAppPage() {
return (
-
-
-
-
-
-
-
-
-
+
+ }>
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
);
}
diff --git a/client/src/routes/NotificationAppPage.tsx b/client/src/routes/NotificationAppPage.tsx
new file mode 100644
index 00000000..f784d92d
--- /dev/null
+++ b/client/src/routes/NotificationAppPage.tsx
@@ -0,0 +1,46 @@
+import { Box, Text } from '@chakra-ui/react';
+import { memo, useEffect, useState } from 'react';
+import { ShortTimeInterval } from '../components/TrayList/ShortTimeInterval';
+import { Logger } from '../logger';
+import { ElectronEventEmitter } from '../services/ElectronEventEmitter';
+
+interface NotifyUserPayload {
+ durationMs: number;
+}
+
+const NotificationAppPageTemp = () => {
+ const [currentSession, setCurrentSession] = useState(0);
+
+ useEffect(() => {
+ const notifyUserReceiver = (payload) => {
+ const data = payload as NotifyUserPayload;
+ Logger.info('Notification received in client:', payload, data.durationMs);
+ setCurrentSession(data.durationMs || 0);
+ };
+
+ ElectronEventEmitter.on('notifyUser', notifyUserReceiver);
+
+ return () => {
+ ElectronEventEmitter.off('notifyUser', notifyUserReceiver);
+ };
+ }, []);
+
+ return (
+
+
+ {currentSession && }
+
+
+ );
+};
+
+export const NotificationAppPage = memo(NotificationAppPageTemp);
diff --git a/client/src/routes/PageLoader.tsx b/client/src/routes/PageLoader.tsx
deleted file mode 100644
index 4733fab8..00000000
--- a/client/src/routes/PageLoader.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-import { Flex } from 'reflexbox';
-import { Calendar, Spin } from 'antd';
-import { Spinner } from '../components/Timeline/Timeline.styles';
-
-export function PageLoader({ history }: any) {
- return (
-
-
-
-
-
- );
-}
diff --git a/client/src/routes/SearchPage.test.tsx b/client/src/routes/SearchPage.test.tsx
new file mode 100644
index 00000000..dffc8b89
--- /dev/null
+++ b/client/src/routes/SearchPage.test.tsx
@@ -0,0 +1,245 @@
+// Set up mocks before any imports
+import { vi } from 'vitest';
+
+// Now import the rest of the dependencies
+import { ChakraProvider } from '@chakra-ui/react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { StoreProvider } from 'easy-peasy';
+import { DateTime, Settings } from 'luxon';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { mainStore } from '../store/mainStore';
+import { SearchPage } from './SearchPage';
+
+const searchFromItems = vi.fn();
+const exportFromItems = vi.fn();
+
+const mockData = {
+ data: [
+ {
+ id: 1,
+ app: 'Chrome',
+ title: 'Test title',
+ beginDate: Date.now() - 3600000, // 1 hour ago
+ endDate: Date.now(),
+ taskName: 'AppTrackItem',
+ },
+ ],
+ total: 1,
+};
+
+// Create a more robust mock function for invokeIpc that logs all arguments
+const invokeIpc = vi.fn().mockImplementation((channel, ...args) => {
+ console.warn('MOCK invokeIpc called with channel:', channel, 'and args:', JSON.stringify(args));
+
+ if (channel === 'searchFromItems') {
+ return searchFromItems(...args);
+ }
+
+ if (channel === 'exportFromItems') {
+ return exportFromItems(...args);
+ }
+
+ // Default return for other channels
+ return Promise.resolve({});
+});
+
+// Create the mock electronBridge global
+const mockElectronBridge = {
+ configGet: vi.fn(),
+ configSet: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ },
+ platform: 'darwin',
+ isMas: false,
+ appVersion: vi.fn(),
+ openUrlInExternalWindow: vi.fn(),
+ invokeIpc,
+ sendIpc: vi.fn(),
+ onIpc: vi.fn(),
+ removeListenerIpc: vi.fn(),
+};
+
+const NOW = getTimestamp('2023-01-10T12:00:00');
+
+function getTimestamp(dateString: string) {
+ return new Date(dateString).getTime();
+}
+
+const renderSearchPage = () => {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+const getLastCall = () => {
+ const searchCalls = vi.mocked(searchFromItems).mock.calls;
+
+ const lastCall = searchCalls[searchCalls.length - 1];
+ return lastCall ? lastCall[0] : null;
+};
+
+describe('SearchPage Component', () => {
+ beforeEach(() => {
+ // Clear all mocks between tests
+ vi.clearAllMocks();
+ Settings.now = () => NOW;
+ vi.stubGlobal('electronBridge', mockElectronBridge);
+
+ searchFromItems.mockResolvedValue(mockData);
+ });
+
+ it('renders the search form correctly', async () => {
+ renderSearchPage();
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(1));
+
+ expect(screen.getByPlaceholderText('Search from all items')).toBeInTheDocument();
+ expect(screen.getByText('Search')).toBeInTheDocument();
+ expect(screen.getByText('Export')).toBeInTheDocument();
+ });
+
+ it('searches items when form is submitted', async () => {
+ renderSearchPage();
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(1));
+
+ const searchInput = screen.getByPlaceholderText('Search from all items');
+ fireEvent.change(searchInput, { target: { value: 'test search' } });
+
+ const searchButton = screen.getByText('Search');
+ fireEvent.click(searchButton);
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(2));
+
+ const lastCall = getLastCall();
+
+ expect(lastCall).toStrictEqual({
+ from: DateTime.fromISO('2022-12-31T00:00:00.000+02:00').valueOf(),
+ to: DateTime.fromISO('2023-01-10T23:59:59.999+02:00').valueOf(),
+ taskName: 'AppTrackItem',
+ searchStr: 'test search',
+ sumTotal: true,
+ paging: {
+ limit: 10,
+ offset: 0,
+ sortByKey: 'endDate',
+ sortByOrder: 'desc',
+ },
+ });
+ });
+
+ it('exports items to CSV and JSON when export options are clicked', async () => {
+ renderSearchPage();
+
+ // Click the export button to open the menu
+ const exportButton = screen.getByText('Export');
+ fireEvent.click(exportButton);
+
+ // Click CSV export option
+ const csvExportOption = screen.getByText('Export to CSV');
+ fireEvent.click(csvExportOption);
+
+ // Verify that exportFromItems was called with CSV format
+ await waitFor(() => expect(exportFromItems).toHaveBeenCalledTimes(1));
+ expect(exportFromItems.mock.calls[0][0].format).toBe('csv');
+
+ // Reset mock
+ exportFromItems.mockClear();
+
+ // Click the export button again to open the menu
+ fireEvent.click(exportButton);
+
+ // Click JSON export option
+ const jsonExportOption = screen.getByText('Export to JSON');
+ fireEvent.click(jsonExportOption);
+
+ // Verify that exportFromItems was called with JSON format
+ await waitFor(() => expect(exportFromItems).toHaveBeenCalledTimes(1));
+ expect(exportFromItems.mock.calls[0][0].format).toBe('json');
+ });
+
+ it('updates the task type when type selector changes', async () => {
+ renderSearchPage();
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(1));
+
+ // Find the type select dropdown and change its value
+ const typeSelect = await screen.findByRole('combobox', { name: 'Type Select' });
+ fireEvent.change(typeSelect, { target: { value: 'LogTrackItem' } });
+
+ // Submit the form to trigger a new search
+ const searchButton = screen.getByText('Search');
+ fireEvent.click(searchButton);
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(2));
+
+ const lastCall = getLastCall();
+
+ expect(lastCall).toStrictEqual({
+ from: DateTime.fromISO('2022-12-31T00:00:00.000+02:00').valueOf(),
+ to: DateTime.fromISO('2023-01-10T23:59:59.999+02:00').valueOf(),
+ taskName: 'LogTrackItem',
+ searchStr: '',
+ sumTotal: true,
+ paging: { limit: 10, offset: 0, sortByKey: 'endDate', sortByOrder: 'desc' },
+ });
+ });
+
+ it('updates timerange when date range selection changes', async () => {
+ renderSearchPage();
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(1));
+
+ // Click the Month button to change the timerange
+ const monthButton = screen.getByText('Month');
+ fireEvent.click(monthButton);
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(2));
+
+ const lastCall = getLastCall();
+
+ expect(lastCall).toStrictEqual({
+ from: DateTime.fromISO('2022-12-10T00:00:00.000+02:00').valueOf(),
+ to: DateTime.fromISO('2023-01-10T23:59:59.999+02:00').valueOf(),
+ taskName: 'AppTrackItem',
+ searchStr: '',
+ sumTotal: true,
+ paging: { limit: 10, offset: 0, sortByKey: 'endDate', sortByOrder: 'desc' },
+ });
+ });
+
+ it('sorts items by BeginDate', async () => {
+ renderSearchPage();
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(1));
+
+ // Click the header for the Begin column to sort
+ const beginHeader = screen.getByText('Begin');
+ fireEvent.click(beginHeader);
+
+ // Submit the form to trigger a new search
+ const searchButton = screen.getByText('Search');
+ fireEvent.click(searchButton);
+
+ await waitFor(() => expect(searchFromItems).toHaveBeenCalledTimes(2));
+
+ const lastCall = getLastCall();
+
+ expect(lastCall).toStrictEqual({
+ from: DateTime.fromISO('2022-12-31T00:00:00.000+02:00').valueOf(),
+ to: DateTime.fromISO('2023-01-10T23:59:59.999+02:00').valueOf(),
+ taskName: 'AppTrackItem',
+ searchStr: '',
+ sumTotal: true,
+ paging: { limit: 10, offset: 0, sortByKey: 'beginDate', sortByOrder: 'desc' },
+ });
+ });
+});
diff --git a/client/src/routes/SearchPage.tsx b/client/src/routes/SearchPage.tsx
index 344cef09..28f3a392 100644
--- a/client/src/routes/SearchPage.tsx
+++ b/client/src/routes/SearchPage.tsx
@@ -1,50 +1,104 @@
-import React, { useState, useEffect } from 'react';
-import { MainLayout } from '../components/MainLayout/MainLayout';
-import { Flex, Box } from 'reflexbox';
-import { Input, Spin, Button } from 'antd';
-import { useFormState } from 'react-use-form-state';
-import { searchFromItems, exportFromItems } from '../services/trackItem.api';
-import moment from 'moment';
-import { TrackItemType } from '../enum/TrackItemType';
-import { SearchResults } from '../components/SearchResults/SearchResults';
+import { DateTime } from 'luxon';
+import { useCallback, useEffect, useRef, useState } from 'react';
import { SearchOptions } from '../components/SearchResults/SearchOptions';
-import { Spinner } from '../components/Timeline/Timeline.styles';
+import { SearchResults } from '../components/SearchResults/SearchResults';
+import { TrackItemType } from '../enum/TrackItemType';
+import { exportFromItems, searchFromItems, SearchResultI } from '../services/trackItem.api';
+
+import { ChevronDownIcon } from '@chakra-ui/icons';
+import { Box, Button, Flex, HStack, Input, Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/react';
+import { useDebouncedCallback } from 'use-debounce';
+import { CardBox } from '../components/CardBox';
+import { Loader } from '../components/Timeline/Loader';
+import { TypeSelect } from '../components/TypeSelect';
+
+interface SearchPagingState {
+ pageSize: number;
+ pageIndex: number;
+ sortByKey?: string;
+ sortByOrder?: 'asc' | 'desc';
+}
+
+export function SearchPage() {
+ const resetButtonsRef = useRef(null) as React.RefObject;
+ const fetchIdRef = useRef(0);
-export function SearchPage({ location }: any) {
- const [formState, { text }] = useFormState({ search: '' });
+ const [searchText, setSearchText] = useState('');
+ const [taskName, setTaskName] = useState(TrackItemType.AppTrackItem);
+ const [isLoading, setIsLoading] = useState(false);
+ const [searchPaging, setSearchPaging] = useState({
+ pageSize: 20,
+ pageIndex: 0,
+ sortByKey: 'endDate',
+ sortByOrder: 'desc',
+ });
- const [isLoading, setIsLoading] = useState(false);
- const [searchPaging, setSearchPaging] = useState({ pageSize: 20, page: 1 });
+ const [searchResult, setSearchResult] = useState({ data: [], total: 0 });
- const [searchResult, setSearchResult] = useState([]);
const [timerange, setTimerange] = useState([
- moment()
- .startOf('day')
- .subtract(10, 'days'),
- moment().endOf('day'),
+ DateTime.now().startOf('day').minus({ days: 10 }),
+ DateTime.now().endOf('day'),
]);
- const taskName = TrackItemType.AppTrackItem;
-
- const loadItems = async (searchStr, firstPage = false) => {
+ const loadItems = async (searchStr: string) => {
+ const fetchId = ++fetchIdRef.current;
setIsLoading(true);
const [from, to] = timerange;
- const items = await searchFromItems({
+
+ const pagingParams = {
+ limit: searchPaging.pageSize,
+ offset: searchPaging.pageIndex * searchPaging.pageSize,
+ sortByKey: searchPaging.sortByKey || 'endDate',
+ sortByOrder: searchPaging.sortByOrder || 'desc',
+ };
+
+ const results = await searchFromItems({
from,
to,
taskName,
searchStr,
- paging: searchPaging,
+ paging: pagingParams,
+ sumTotal: true,
});
- setSearchResult(items);
- console.info('searching with pa', searchPaging, items);
- setIsLoading(false);
+ console.info('Sum data', results);
- return;
+ if (fetchId === fetchIdRef.current) {
+ setSearchResult(results);
+ console.info('searching with paging', searchPaging, timerange, results);
+ }
+
+ setIsLoading(false);
};
- const exportItems = async searchStr => {
+ const debouncedLoadItems = useDebouncedCallback(loadItems, 300);
+
+ const fetchData = useCallback(
+ ({
+ pageSize,
+ pageIndex,
+ sortBy,
+ }: {
+ pageSize: number;
+ pageIndex: number;
+ sortBy: { id: string; desc: boolean }[];
+ }) => {
+ const pageProps: SearchPagingState = { pageSize, pageIndex };
+ if (sortBy && sortBy.length > 0) {
+ const [sort] = sortBy;
+ pageProps.sortByKey = sort.id;
+ pageProps.sortByOrder = sort.desc ? 'desc' : 'asc';
+ } else {
+ pageProps.sortByKey = 'endDate';
+ pageProps.sortByOrder = 'desc';
+ }
+
+ setSearchPaging(pageProps);
+ },
+ [],
+ );
+
+ const exportItems = async (searchStr, format = 'csv') => {
setIsLoading(true);
const [from, to] = timerange;
await exportFromItems({
@@ -52,68 +106,67 @@ export function SearchPage({ location }: any) {
to,
taskName,
searchStr,
+ format,
});
-
setIsLoading(false);
- return;
};
- useEffect(() => {
- loadItems(formState.values.search);
- // eslint-disable-next-line react-hooks/exhaustive-deps
+ const refreshData = useCallback(() => {
+ // Reset to first page and reload items
+ setSearchPaging({ ...searchPaging, pageIndex: 0 });
+ // loadItems will be called via the useEffect that depends on searchPaging
}, [searchPaging]);
- const onSubmit = event => {
- setSearchPaging({ ...searchPaging, page: 1 });
+ useEffect(() => {
+ console.info('searchPaging, searchText or timerange has changed');
+ debouncedLoadItems(searchText);
+ }, [debouncedLoadItems, searchPaging, searchText, timerange]);
+ const onSubmit = (event) => {
event.preventDefault();
+ setSearchPaging((prev) => ({ ...prev, pageIndex: 0 }));
};
return (
-
-
+
+
+ Search
+
+
+
+ }>
+ Export
+
+
+ exportItems(searchText, 'csv')}>Export to CSV
+ exportItems(searchText, 'json')}>Export to JSON
+
+
+
+
+
+
+
+
);
}
diff --git a/client/src/routes/SettingsPage.tsx b/client/src/routes/SettingsPage.tsx
index 67a1d73c..f1e4e00b 100644
--- a/client/src/routes/SettingsPage.tsx
+++ b/client/src/routes/SettingsPage.tsx
@@ -1,12 +1,5 @@
-import React from 'react';
-
-import { MainLayout } from '../components/MainLayout/MainLayout';
import { SettingsForm } from '../components/Settings/SettingsForm';
-export function SettingsPage({ location }: any) {
- return (
-
-
-
- );
+export function SettingsPage() {
+ return ;
}
diff --git a/client/src/routes/SummaryPage.tsx b/client/src/routes/SummaryPage.tsx
index f3621ae9..9a8ba89f 100644
--- a/client/src/routes/SummaryPage.tsx
+++ b/client/src/routes/SummaryPage.tsx
@@ -1,19 +1,20 @@
-import React from 'react';
-import { MainLayout } from '../components/MainLayout/MainLayout';
+import { VStack } from '@chakra-ui/react';
+import { CardBox } from '../components/CardBox';
+import { LineChart } from '../components/LineCharts/LineChart';
import { SummaryCalendar } from '../components/SummaryCalendar/SummaryCalendar';
import { SummaryProvider } from '../SummaryContext';
-import { LineChart } from '../components/LineCharts/LineChart';
-import { LineChartBox } from '../components/LineCharts/LineCharts.styles';
-export function SummaryPage({ location }: any) {
+export function SummaryPage() {
return (
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
);
}
diff --git a/client/src/routes/SupportPage.tsx b/client/src/routes/SupportPage.tsx
index f3c3477f..345ccdab 100644
--- a/client/src/routes/SupportPage.tsx
+++ b/client/src/routes/SupportPage.tsx
@@ -1,29 +1,35 @@
-import React, { useState } from 'react';
-import { MainLayout } from '../components/MainLayout/MainLayout';
-import { Box, Flex } from 'reflexbox';
-import emailjs from 'emailjs-com';
-import { Button, Input, Typography } from 'antd';
-const { Text, Title, Paragraph } = Typography;
+import { Box, Button, Flex, HStack, Icon, Input, Text, Textarea, VStack } from '@chakra-ui/react';
+import { useState } from 'react';
+import { FaDonate, FaGithub } from 'react-icons/fa';
+import { CardBox } from '../components/CardBox';
+import { sendEmail } from '../services/email.service';
-const TEMPLATE_ID = process.env.REACT_APP_TEMPLATE_ID || '';
-const SERVICE_ID = process.env.REACT_APP_SERVICE_ID || '';
-const USER_ID = process.env.REACT_APP_USER_ID || '';
+const Paragraph = (props) => (
+
+
+
+);
-export function SupportPage({ location }: any) {
+const errToString = (err) => (err.text ? err.text : err.toString());
+
+export function SupportPage() {
const [content, setContent] = useState('');
const [contentError, setContentError] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const [isSending, setIsSending] = useState(false);
const [email, setEmail] = useState('');
+ const [sendError, setSendError] = useState('');
- const changeContent = e => {
+ const changeContent = (e) => {
const { value } = e.target;
setContent(value);
+ setSendError('');
};
- const changeEmail = e => {
+ const changeEmail = (e) => {
const { value } = e.target;
setEmail(value);
+ setSendError('');
};
const sendForm = async () => {
@@ -31,112 +37,87 @@ export function SupportPage({ location }: any) {
setContentError(true);
return;
}
- const templateParams = { reply_to: email, message: content };
+
try {
setIsSending(true);
- await emailjs.send(SERVICE_ID, TEMPLATE_ID, templateParams, USER_ID);
+ setSendError('');
+ await sendEmail({ reply_to: email, message: content });
setEmailSent(true);
setContent('');
setContentError(false);
} catch (err) {
- alert('Email send error! ' + (err.text ? err.text : err.toString()));
+ console.error('Email send error!', err);
+ setSendError('Email send error! ' + errToString(err));
}
setIsSending(false);
};
return (
-
-
- Contact Me
-
- Feel free to contact if you have any problems or feature requests. Or if you
- have any feedback to give - good or bad.
-
-
-
+
+
+
+ Feel free to contact if you have any problems or feature requests. Or if you have any feedback
+ to give - good or bad.
+
+
+
-
- {contentError && (
-
- Content is empty!
-
- )}
-
-
-
-
-
+
+ {contentError && Content is empty! }
+
+
+
+
Send
-
+
{emailSent && (
-
- Email is sent!
+
+ Email is sent!
+
+ )}
+ {sendError && (
+
+ {sendError}
)}
-
-
- Support Tockler
-
- This app is made in my own free time and often at expense of family, friends,
- and sleep. I would like to keep this app free, open-source, and improving over
- time. But for that, your support is needed.
-
-
- It is understandable if you can't give anything. You can always give some
- constructive feedback.
-
-
- So if you find this app useful then feel free to donate. Anything helps to keep
- this app up to date and always improving. I don't plan to get rich with that,
- need to justify working on this.
-
-
-
-
+
+
+
+
+ Start supporting this project by starring it on GitHub, contributing code, or making a donation.
+
+
+
+ }
+ colorScheme="gray"
+ onClick={() =>
+ window.electronBridge?.openUrlInExternalWindow('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/MayGo/tockler')
+ }
>
-
-
-
-
-
+
+ }
+ colorScheme="blue"
+ onClick={() =>
+ window.electronBridge?.openUrlInExternalWindow('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/sponsors/MayGo')
+ }
>
-
-
-
-
-
-
-
-
-
-
-
+ Donate
+
+
+
+
+
);
}
diff --git a/client/src/routes/TimelinePage.test.tsx b/client/src/routes/TimelinePage.test.tsx
new file mode 100644
index 00000000..e20e0c43
--- /dev/null
+++ b/client/src/routes/TimelinePage.test.tsx
@@ -0,0 +1,97 @@
+// Set up mocks before any imports
+import { vi } from 'vitest';
+
+// Now import the rest of the dependencies
+import { ChakraProvider } from '@chakra-ui/react';
+import { render, screen, waitFor } from '@testing-library/react';
+import { StoreProvider } from 'easy-peasy';
+import { Settings } from 'luxon';
+import { MemoryRouter } from 'react-router-dom';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { getTimestamp } from '../__tests__/time.testUtils';
+import { mainStore } from '../store/mainStore';
+import { TimelinePage } from './TimelinePage';
+
+const findAllDayItems = vi.fn();
+
+const mockData = [
+ {
+ id: 1,
+ app: 'Chrome',
+ title: 'Test title',
+ beginDate: Date.now() - 3600000, // 1 hour ago
+ endDate: Date.now(),
+ taskName: 'AppTrackItem',
+ },
+];
+
+// Create a more robust mock function for invokeIpc that logs all arguments
+const invokeIpc = vi.fn().mockImplementation((channel, ...args) => {
+ console.warn('MOCK invokeIpc called with channel:', channel, 'and args:', JSON.stringify(args));
+
+ if (channel === 'findAllDayItems') {
+ return findAllDayItems(...args);
+ }
+
+ // Default return for other channels
+ return Promise.resolve({});
+});
+
+// Create the mock electronBridge global
+const mockElectronBridge = {
+ configGet: vi.fn(),
+ configSet: vi.fn(),
+ logger: {
+ info: vi.fn(),
+ error: vi.fn(),
+ warn: vi.fn(),
+ debug: vi.fn(),
+ },
+ platform: 'darwin',
+ isMas: false,
+ appVersion: vi.fn(),
+ openUrlInExternalWindow: vi.fn(),
+ invokeIpc,
+ sendIpc: vi.fn(),
+ onIpc: vi.fn(),
+ removeListenerIpc: vi.fn(),
+};
+
+const NOW = getTimestamp('2023-01-10T12:00:00');
+
+const renderTimelinePage = () => {
+ return render(
+
+
+
+
+
+
+ ,
+ );
+};
+
+describe('TimelinePage Component', () => {
+ beforeEach(() => {
+ // Clear all mocks between tests
+ // Clear all mocks between tests
+ vi.clearAllMocks();
+ Settings.now = () => NOW;
+ vi.stubGlobal('electronBridge', mockElectronBridge);
+
+ findAllDayItems.mockResolvedValue(mockData);
+ });
+
+ it('renders the timeline components correctly', async () => {
+ renderTimelinePage();
+
+ // Check for key components
+ expect(screen.getByText('App Usage')).toBeInTheDocument();
+ });
+
+ it('calls fetchTimerange on component mount', async () => {
+ renderTimelinePage();
+
+ await waitFor(() => expect(findAllDayItems).toHaveBeenCalled());
+ });
+});
diff --git a/client/src/routes/TimelinePage.tsx b/client/src/routes/TimelinePage.tsx
index 44c13b7f..b29b39b0 100644
--- a/client/src/routes/TimelinePage.tsx
+++ b/client/src/routes/TimelinePage.tsx
@@ -1,32 +1,70 @@
-import React, { useEffect } from 'react';
-import { MainLayout } from '../components/MainLayout/MainLayout';
-import { PieCharts } from '../components/PieCharts/PieCharts';
+import { Box, Flex, Stack, Text, useColorModeValue, VStack } from '@chakra-ui/react';
+import { useEffect } from 'react';
+import { CardBox } from '../components/CardBox';
+import { AppUsageChart } from '../components/PieCharts/AppUsageChart';
+import { MetricTiles } from '../components/PieCharts/MetricTiles';
+import { TaskList } from '../components/TaskList';
+import { NewLogButton } from '../components/Timeline/NewLogButton';
import { Search } from '../components/Timeline/Search';
import { Timeline } from '../components/Timeline/Timeline';
-import { TrackItemTable } from '../components/TrackItemTable/TrackItemTable';
+import { VisibleRange } from '../components/Timeline/VisibleRange';
+import { TrackItemTabs } from '../components/TrackItemTable/TrackItemTabs';
import { useInterval } from '../hooks/intervalHook';
import { useStoreActions } from '../store/easyPeasy';
-const BG_SYNC_DELAY_MS = 3000;
+const BG_SYNC_DELAY_MS = 5000;
-export function TimelinePage({ location }: any) {
- const fetchTimerange = useStoreActions(actions => actions.fetchTimerange);
- const bgSyncInterval = useStoreActions(actions => actions.bgSyncInterval);
+const ItemLabel = (props) => ;
- useInterval(() => {
- bgSyncInterval();
- }, [BG_SYNC_DELAY_MS]);
+export function TimelinePage() {
+ const fetchTimerange = useStoreActions((actions) => actions.fetchTimerange);
+ const bgSyncInterval = useStoreActions((actions) => actions.bgSyncInterval);
+
+ useInterval(
+ () => {
+ bgSyncInterval();
+ },
+ BG_SYNC_DELAY_MS,
+ true,
+ );
useEffect(() => {
fetchTimerange();
}, [fetchTimerange]);
return (
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Task
+ Status
+ App
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/client/src/routes/TrayAppPage.tsx b/client/src/routes/TrayAppPage.tsx
index aa10572a..4e8f6307 100644
--- a/client/src/routes/TrayAppPage.tsx
+++ b/client/src/routes/TrayAppPage.tsx
@@ -1,123 +1,73 @@
-import { Box } from 'reflexbox';
-import React, { useEffect, useState, useCallback, memo } from 'react';
-import randomcolor from 'randomcolor';
-import { TimelineItemEdit } from '../components/Timeline/TimelineItemEdit';
-import { TrayLayout } from '../components/TrayLayout/TrayLayout';
-import { TrayList } from '../components/TrayList/TrayList';
-import { EventEmitter } from '../services/EventEmitter';
-import { getRunningLogItem } from '../services/settings.api';
-import { startNewLogItem, findFirstLogItems, stopRunningLogItem } from '../services/trackItem.api';
-import { Logger } from '../logger';
+import { Box, Divider } from '@chakra-ui/react';
+import { memo, useEffect } from 'react';
+import { ITrackItem } from '../@types/ITrackItem';
+import { LoadingLine } from '../components/LoadingLine';
+import { OnlineChart } from '../components/TrayLayout/OnlineChart';
+import { AggregatedTrackItem, TrayList } from '../components/TrayList/TrayList';
+import { useTrayData } from '../hooks/useTrayData';
import { useWindowFocused } from '../hooks/windowFocusedHook';
-import { throttle } from 'lodash';
-import deepEqual from 'fast-deep-equal/es6';
-import { analytics } from '../analytics';
-
-const EMPTY_SELECTED_ITEM = {};
+import { Logger } from '../logger';
+import { startNewLogItem, stopRunningLogItem } from '../services/trackItem.api';
+import { sendOpenTrayEvent } from '../useGoogleAnalytics.utils';
+import { TrayItemEdit } from './tray/TrayItemEdit';
const EMPTY_ARRAY = [];
const TrayAppPageTemp = () => {
- const [loading, setLoading] = useState(true);
-
- const [selectedItem, setSelectedItem] = useState(EMPTY_SELECTED_ITEM);
- const [runningLogItem, setRunningLogItem] = useState();
- const [lastLogItems, setLastLogItems] = useState(EMPTY_ARRAY);
-
+ const { isLoading, statusItems, logItems, refreshData, runningLogItem, setRunningLogItem } = useTrayData();
const { windowIsActive } = useWindowFocused();
- const loadLastLogItems = async () => {
- setLoading(true);
- try {
- const items = await findFirstLogItems();
- const areEqual = deepEqual(items, lastLogItems);
-
- if (!areEqual) {
- setLastLogItems(items);
- }
- } catch (e) {
- Logger.error('Error loading first last items', e);
- }
- setLoading(false);
- };
-
- const loadLastLogItemsThrottled = throttle(loadLastLogItems, 4000, { trailing: false });
-
+ // Refresh data when window becomes active
useEffect(() => {
if (windowIsActive) {
- Logger.debug('Window active:', windowIsActive);
- setSelectedItem(s => ({ ...s, color: randomcolor() }));
- loadLastLogItemsThrottled();
- analytics.track('trayOpened', { version: process.env.REACT_APP_VERSION });
+ Logger.debug('Window active, refreshing data');
+ refreshData();
+ sendOpenTrayEvent();
}
+ // Intentionally omitting refreshData from dependencies as it would cause a refresh loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [windowIsActive]);
- useEffect(() => {
- const eventLogItemStarted = (_, logItem) => {
- Logger.debug('log-trackItem-started:', JSON.parse(logItem));
- setRunningLogItem(JSON.parse(logItem));
- loadLastLogItemsThrottled();
- };
-
- EventEmitter.on('log-item-started', eventLogItemStarted);
-
- return () => {
- EventEmitter.off('log-item-started', eventLogItemStarted);
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- useEffect(() => {
- loadLastLogItemsThrottled();
- getRunningLogItem().then(logItem => {
- setRunningLogItem(logItem);
- });
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const startNewLogItemEvent = useCallback((trackItem: any, colorScope: any) => {
+ function startNewLogItemEvent(trackItem: ITrackItem) {
startNewLogItem(trackItem);
- loadLastLogItemsThrottled();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- const stopRunningLogItemEvent = useCallback(
- (trackItem: any, colorScope: any) => {
- if (runningLogItem) {
- stopRunningLogItem(runningLogItem.id);
- loadLastLogItemsThrottled();
- setRunningLogItem(null);
- } else {
- Logger.error('No running log trackItem to stop');
- }
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [runningLogItem, setRunningLogItem],
- );
+ refreshData();
+ }
+
+ function stopRunningLogItemEvent() {
+ if (runningLogItem) {
+ setRunningLogItem(undefined);
+ stopRunningLogItem(runningLogItem.id);
+ } else {
+ Logger.error('No running log trackItem to stop');
+ }
+ }
return (
-
- {!runningLogItem && (
-
-
-
- )}
+ <>
+
+ {isLoading && }
+
+
+
+ {
+ startNewLogItem(trackItem);
+ refreshData();
+ }}
+ />
+
+
+
+
+
-
+ >
);
};
-TrayAppPageTemp.whyDidYouRender = true;
-
export const TrayAppPage = memo(TrayAppPageTemp);
diff --git a/client/src/routes/TrayPage.tsx b/client/src/routes/TrayPage.tsx
deleted file mode 100644
index eb696bf4..00000000
--- a/client/src/routes/TrayPage.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import React from 'react';
-
-import { MainLayout } from '../components/MainLayout/MainLayout';
-import { TrayAppPage } from './TrayAppPage';
-/*
- * This Page is for testing purposes only
- */
-export function TrayPage({ location }: any) {
- return (
-
-
-
- );
-}
diff --git a/client/src/routes/tray/TrayItemEdit.tsx b/client/src/routes/tray/TrayItemEdit.tsx
new file mode 100644
index 00000000..6f42ec71
--- /dev/null
+++ b/client/src/routes/tray/TrayItemEdit.tsx
@@ -0,0 +1,66 @@
+import { useState, useEffect } from 'react';
+import randomcolor from 'randomcolor';
+import { Logger } from '../../logger';
+import { Box } from '@chakra-ui/react';
+import { Input } from '@chakra-ui/react';
+import { IconButton } from '@chakra-ui/react';
+import { FaPlay } from 'react-icons/fa';
+import { HStack } from '@chakra-ui/react';
+import { ColorPicker } from '../../components/Timeline/ColorPicker';
+import { useWindowFocused } from '../../hooks/windowFocusedHook';
+
+const COLOR_SCOPE_ONLY_THIS = 'ONLY_THIS';
+const EMPTY_SELECTED_ITEM = { app: '', title: '', color: randomcolor() };
+
+export const TrayItemEdit = ({ saveTimelineItem }) => {
+ const { windowIsActive } = useWindowFocused();
+ const [trackItem, setTrackItem] = useState(EMPTY_SELECTED_ITEM);
+
+ useEffect(() => {
+ if (windowIsActive) {
+ setTrackItem((oldItem) => ({ ...oldItem, color: randomcolor() }));
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [windowIsActive]);
+
+ const changeColorHandler = (color) => {
+ Logger.debug('Changed color:', color);
+
+ setTrackItem((oldItem) => ({ ...oldItem, color }));
+ };
+
+ const changeAppName = (e) => {
+ const { value } = e.target;
+ setTrackItem((oldItem) => ({ ...oldItem, app: value }));
+ };
+
+ const changeAppTitle = (e) => {
+ const { value } = e.target;
+
+ setTrackItem((oldItem) => ({ ...oldItem, title: value }));
+ };
+
+ const onSubmit = (event) => {
+ event.preventDefault();
+
+ saveTimelineItem(trackItem, COLOR_SCOPE_ONLY_THIS);
+ setTrackItem((oldItem) => ({ ...oldItem, app: '', title: '', color: randomcolor() }));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ } />
+
+
+ );
+};
diff --git a/client/src/services/ElectronEventEmitter.ts b/client/src/services/ElectronEventEmitter.ts
new file mode 100644
index 00000000..37422ef8
--- /dev/null
+++ b/client/src/services/ElectronEventEmitter.ts
@@ -0,0 +1,48 @@
+import { Logger } from '../logger';
+
+// Define more specific types for the event data
+type EventData = unknown;
+type EventListener = (event: EventData, ...args: unknown[]) => void;
+
+interface ElectronBridge {
+ invokeIpc: (channel: string, ...args: unknown[]) => Promise;
+ sendIpc: (channel: string, ...args: unknown[]) => void;
+ onIpc: (channel: string, listener: EventListener) => void;
+ removeListenerIpc: (channel: string, listener: EventListener) => void;
+}
+
+// Get the electronBridge at call time, not at module load time
+const getElectronBridge = (): ElectronBridge => {
+ return (window as unknown as { electronBridge: ElectronBridge }).electronBridge;
+};
+
+function send(name: string, ...args: unknown[]): void {
+ Logger.debug(`Send event: ${name}`);
+ getElectronBridge().sendIpc(name, ...args);
+}
+
+function on(name: string, listener: EventListener): void {
+ getElectronBridge().onIpc(name, listener);
+}
+
+function off(name: string, listener: EventListener): void {
+ getElectronBridge().removeListenerIpc(name, listener);
+}
+
+async function emit(name: string, ...args: unknown[]): Promise {
+ try {
+ Logger.debug(`Emit event: ${name}`);
+ const result = await getElectronBridge().invokeIpc(name, ...args);
+ return result;
+ } catch (error) {
+ console.error('[EventEmitter] emit failed for', name, 'with error:', error);
+ throw error;
+ }
+}
+
+export const ElectronEventEmitter = {
+ send,
+ on,
+ off,
+ emit,
+};
diff --git a/client/src/services/EventEmitter.ts b/client/src/services/EventEmitter.ts
deleted file mode 100644
index f8a8a070..00000000
--- a/client/src/services/EventEmitter.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Logger } from '../logger';
-
-const { ipcRenderer } = window as any;
-
-function send(name, ...args) {
- Logger.debug(`Send event: ${name}`);
- ipcRenderer.send(name, ...args);
-}
-function on(name, listener) {
- ipcRenderer.on(name, listener);
-}
-
-function off(name, listener) {
- ipcRenderer.removeListener(name, listener);
-}
-
-function once(name, listener) {
- ipcRenderer.once(name, listener);
-}
-
-export const EventEmitter = {
- send,
- on,
- off,
- once,
-};
diff --git a/client/src/services/appSettings.api.ts b/client/src/services/appSettings.api.ts
index f6836e7e..b99e885e 100644
--- a/client/src/services/appSettings.api.ts
+++ b/client/src/services/appSettings.api.ts
@@ -1,5 +1,5 @@
-import { emit } from 'eiphop';
+import { ElectronEventEmitter } from './ElectronEventEmitter';
export async function changeColorForApp(appName: string, color: string) {
- return emit('changeColorForApp', { appName, color });
+ return ElectronEventEmitter.emit('changeColorForApp', { appName, color });
}
diff --git a/client/src/services/email.service.ts b/client/src/services/email.service.ts
new file mode 100644
index 00000000..e4cfe5f9
--- /dev/null
+++ b/client/src/services/email.service.ts
@@ -0,0 +1,37 @@
+const TEMPLATE_ID = import.meta.env.VITE_TEMPLATE_ID || '';
+const SERVICE_ID = import.meta.env.VITE_SERVICE_ID || '';
+const USER_ID = import.meta.env.VITE_USER_ID || '';
+
+const EMAILJS_API = 'https://api.emailjs.com/api/v1.0/email/send';
+
+export const sendEmail = (templateParams) => {
+ const data = {
+ service_id: SERVICE_ID,
+ template_id: TEMPLATE_ID,
+ user_id: USER_ID,
+ template_params: { ...templateParams },
+ };
+
+ const requestOptions = {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(data),
+ };
+
+ return fetch(EMAILJS_API, requestOptions).then((response) => {
+ if (!response.ok) {
+ // For HTTP error responses (including 400), convert to JSON if possible
+ // then reject with the error information
+ return response.text().then((text) => {
+ try {
+ const errorData = JSON.parse(text);
+ throw errorData;
+ } catch {
+ // If can't parse as JSON, throw the text
+ throw new Error(text || `HTTP error ${response.status}`);
+ }
+ });
+ }
+ return response;
+ });
+};
diff --git a/client/src/services/response-error.ts b/client/src/services/response-error.ts
old mode 100755
new mode 100644
diff --git a/client/src/services/settings.api.ts b/client/src/services/settings.api.ts
index 9046f073..3b2c050a 100644
--- a/client/src/services/settings.api.ts
+++ b/client/src/services/settings.api.ts
@@ -1,76 +1,165 @@
-import { EventEmitter } from './EventEmitter';
+import { ITrackItem } from '../@types/ITrackItem';
import { Logger } from '../logger';
-import { emit } from 'eiphop';
+import { ElectronEventEmitter } from './ElectronEventEmitter';
-const { Store } = window as any;
+// Use the Window interface extension from global declarations
+const { electronBridge } = window;
+const { configGet, configSet } = electronBridge;
-const config = new Store();
+const THEME_CONFIG_KEY = 'selectedTheme';
+const IS_NATIVE_THEME_ENABLED = 'isNativeThemeEnabled';
+const IS_LOGGING_ENABLED = 'isLoggingEnabled';
+const OPEN_AT_LOGIN = 'openAtLogin';
+const IS_AUTO_UPDATE_ENABLED = 'isAutoUpdateEnabled';
+const NATIVE_THEME_CONFIG_CHANGED = 'nativeThemeChanged';
+const USE_PURPLE_TRAY_ICON = 'usePurpleTrayIcon';
+const USE_PURPLE_TRAY_ICON_CHANGED = 'usePurpleTrayIconChanged';
+const MAC_AUTO_HIDE_MENU_BAR_ENABLED = 'macAutoHideMenuBarEnabled';
-export function getOpenAtLogin() {
- return config.get('openAtLogin') as boolean;
+export function getNativeThemeChange() {
+ return configGet(IS_NATIVE_THEME_ENABLED) as boolean;
}
-export function getIsAutoUpdateEnabled() {
- return config.get('isAutoUpdateEnabled') as boolean;
+export function saveNativeThemeChange(enabled) {
+ configSet(IS_NATIVE_THEME_ENABLED, enabled);
+ ElectronEventEmitter.send(NATIVE_THEME_CONFIG_CHANGED);
}
-export function getIsLoggingEnabled() {
- return config.get('isLoggingEnabled') as boolean;
+
+// -----
+
+export function getOpenAtLogin() {
+ return configGet(OPEN_AT_LOGIN) as boolean;
}
export function saveOpenAtLogin(openAtLogin) {
if (openAtLogin !== getOpenAtLogin()) {
- EventEmitter.send('openAtLoginChanged');
- config.set('openAtLogin', openAtLogin);
+ configSet(OPEN_AT_LOGIN, openAtLogin);
+ ElectronEventEmitter.send('openAtLoginChanged');
}
}
-export function getThemeFromStorage() {
- const activeTheme = config.get('activeTheme');
- Logger.info('Got theme from storage:', activeTheme);
- return activeTheme;
-}
+// -----
-export function saveThemeToStorage(theme) {
- Logger.info('Save theme to storage:', theme);
- config.set('activeTheme', theme);
+export function getIsAutoUpdateEnabled() {
+ return configGet(IS_AUTO_UPDATE_ENABLED) as boolean;
}
export function saveIsAutoUpdateEnabled(isAutoUpdateEnabled) {
if (isAutoUpdateEnabled !== getIsAutoUpdateEnabled()) {
Logger.debug('Setting isAutoUpdateEnabled', isAutoUpdateEnabled);
- config.set('isAutoUpdateEnabled', isAutoUpdateEnabled);
+ configSet(IS_AUTO_UPDATE_ENABLED, isAutoUpdateEnabled);
}
}
+
+// -----
+
+export function getIsLoggingEnabled() {
+ return configGet(IS_LOGGING_ENABLED) as boolean;
+}
+
export function saveIsLoggingEnabled(isLoggingEnabled) {
if (isLoggingEnabled !== getIsLoggingEnabled()) {
Logger.debug('Setting isLoggingEnabled', isLoggingEnabled);
- config.set('isLoggingEnabled', isLoggingEnabled);
+ configSet(IS_LOGGING_ENABLED, isLoggingEnabled);
}
}
+// -----
+
+export function getUsePurpleTrayIcon() {
+ return configGet(USE_PURPLE_TRAY_ICON) as boolean;
+}
+
+export function getMacAutoHideMenuBarEnabled() {
+ return configGet(MAC_AUTO_HIDE_MENU_BAR_ENABLED) as boolean;
+}
+
+export function saveMacAutoHideMenuBarEnabled(enabled) {
+ configSet(MAC_AUTO_HIDE_MENU_BAR_ENABLED, enabled);
+}
+export function saveUsePurpleTrayIcon(enabled) {
+ configSet(USE_PURPLE_TRAY_ICON, enabled);
+ ElectronEventEmitter.send(USE_PURPLE_TRAY_ICON_CHANGED);
+}
+
+export function getThemeFromStorage() {
+ const colorMode = configGet(THEME_CONFIG_KEY);
+ Logger.info('Got theme from storage:', colorMode);
+ return colorMode;
+}
+
+export function saveThemeToStorage(colorMode) {
+ Logger.info('Save theme to config:', colorMode);
+ return ElectronEventEmitter.emit('saveThemeAndNotify', colorMode);
+}
+
export async function updateByName(name, jsonData) {
Logger.debug('updateByName', JSON.stringify(jsonData));
+ return ElectronEventEmitter.emit('updateByName', { name, jsonData: JSON.stringify(jsonData) });
+}
- return emit('updateByName', { name, jsonData: JSON.stringify(jsonData) });
+export async function notifyUser(durationMs: number) {
+ Logger.debug('notifyUser', durationMs);
+ return ElectronEventEmitter.emit('notifyUser', { durationMs });
}
export function getRunningLogItem() {
- return emit('getRunningLogItemAsJson');
+ return ElectronEventEmitter.emit('getRunningLogItemAsJson') as Promise;
+}
+
+export function getMachineId() {
+ return ElectronEventEmitter.emit('getMachineId');
}
-export function fetchWorkSettings() {
- return emit('fetchWorkSettings');
+export async function fetchWorkSettings() {
+ const jsonStr = await ElectronEventEmitter.emit('fetchWorkSettingsJsonString');
+ try {
+ return JSON.parse(jsonStr as string);
+ } catch (e) {
+ Logger.error('Error in fetchWorkSettings', jsonStr, e);
+ return null;
+ }
+}
+
+export async function fetchDataSettings() {
+ const jsonStr = await ElectronEventEmitter.emit('fetchDataSettingsJsonString');
+ try {
+ return JSON.parse(jsonStr as string);
+ } catch (e) {
+ Logger.error('Error in fetchDataSettings', jsonStr, e);
+ return null;
+ }
+}
+
+export function saveWorkSettings(data) {
+ updateByName('WORK_SETTINGS', data);
+}
+
+export function saveDataSettings(data) {
+ updateByName('DATA_SETTINGS', data);
+ return ElectronEventEmitter.emit('updateByNameDataSettings', {
+ name: 'DATA_SETTINGS',
+ jsonData: JSON.stringify(data),
+ });
}
export function saveAnalyserSettings(data) {
updateByName('ANALYSER_SETTINGS', data);
}
+export async function getTaskAnalyserEnabled() {
+ return ElectronEventEmitter.emit('getTaskAnalyserEnabled') as Promise;
+}
+
+export async function setTaskAnalyserEnabled(enabled: boolean) {
+ return ElectronEventEmitter.emit('setTaskAnalyserEnabled', { enabled });
+}
+
export async function fetchAnalyserSettings() {
- const jsonStr = await emit('fetchAnalyserSettingsJsonString');
- Logger.debug('fetchAnalyserSettings', jsonStr);
+ const jsonStr = await ElectronEventEmitter.emit('fetchAnalyserSettingsJsonString');
+
try {
- return JSON.parse(jsonStr);
+ return JSON.parse(jsonStr as string);
} catch (e) {
Logger.error('fetchAnalyserSettings', jsonStr, e);
return [];
diff --git a/client/src/services/trackItem.api.ts b/client/src/services/trackItem.api.ts
index 09e11af0..38e30357 100644
--- a/client/src/services/trackItem.api.ts
+++ b/client/src/services/trackItem.api.ts
@@ -1,121 +1,137 @@
-import moment from 'moment';
-import { ITrackItem } from '../@types/ITrackItem';
+import { DateTime } from 'luxon';
+import { ITrackItem, NewTrackItem } from '../@types/ITrackItem';
import { TrackItemType } from '../enum/TrackItemType';
import { Logger } from '../logger';
-import { EventEmitter } from './EventEmitter';
-import { emit } from 'eiphop';
-
-async function findAllDayItems(
- from: moment.Moment,
- to: moment.Moment,
- taskName: string,
-): Promise {
+import { ElectronEventEmitter } from './ElectronEventEmitter';
+
+export async function findAllDayItems(from: DateTime, to: DateTime, taskName: string): Promise {
//Logger.debug('findAllDayItems', JSON.stringify({ from, to, taskName }));
- const data = await emit('findAllDayItems', {
+ const data = await ElectronEventEmitter.emit('findAllDayItems', {
from: from.valueOf(),
to: to.valueOf(),
taskName,
});
// Logger.debug('findAllDayItems result ', data);
- return data;
+ return data as ITrackItem[];
}
-export async function findAllDayItemsForEveryTrack(from: moment.Moment, to: moment.Moment) {
- // TODO, query all at async
- const appItems: ITrackItem[] = await findAllDayItems(from, to, TrackItemType.AppTrackItem);
-
- const statusItems: ITrackItem[] = await findAllDayItems(
- from,
- to,
- TrackItemType.StatusTrackItem,
- );
- const logItems: ITrackItem[] = await findAllDayItems(from, to, TrackItemType.LogTrackItem);
+export async function findAllDayItemsForEveryTrack(from: DateTime, to: DateTime) {
+ const [appItems, statusItems, logItems] = await Promise.all([
+ findAllDayItems(from, to, TrackItemType.AppTrackItem),
+ findAllDayItems(from, to, TrackItemType.StatusTrackItem),
+ findAllDayItems(from, to, TrackItemType.LogTrackItem),
+ ]);
return { appItems, statusItems, logItems };
}
-export function findFirstLogItems(): Promise {
- return emit('findFirstLogItems');
+export function findFirstChunkLogItems(): Promise {
+ return ElectronEventEmitter.emit('findFirstChunkLogItems');
+}
+
+export function findFirstTrackItem(): Promise {
+ return ElectronEventEmitter.emit('findFirstTrackItem');
}
-export function getOnlineStartTime(): Promise {
- return emit('getOnlineStartTime');
+export interface SearchResultI {
+ data: Array;
+ total: number;
+ totalDuration?: number;
}
-export function searchFromItems({ from, to, taskName, searchStr, paging }): Promise {
- return emit('searchFromItems', {
+export function searchFromItems({ from, to, taskName, searchStr, paging, sumTotal = false }): Promise {
+ Logger.debug('Searching items:', { from, to, taskName, searchStr, paging });
+
+ return ElectronEventEmitter.emit('searchFromItems', {
from: from.valueOf(),
to: to.valueOf(),
taskName,
searchStr,
paging,
+ sumTotal,
});
}
-export function exportFromItems({ from, to, taskName, searchStr }): Promise {
- return emit('exportFromItems', {
+export function exportFromItems({ from, to, taskName, searchStr, format = 'csv' }): Promise {
+ return ElectronEventEmitter.emit('exportFromItems', {
from: from.valueOf(),
to: to.valueOf(),
taskName,
searchStr,
+ format,
});
}
-function createTrackItem(trackItem: ITrackItem): Promise {
- return emit('createTrackItem', { trackItem: trackItem });
+function createTrackItem(trackItem: ITrackItem): Promise {
+ return ElectronEventEmitter.emit('createTrackItem', { trackItem: trackItem });
}
-function updateTrackItem(trackItem: ITrackItem): Promise {
- return emit('updateTrackItem', { trackItem });
+function updateTrackItem(trackItem: ITrackItem, trackItemId: number): Promise {
+ return ElectronEventEmitter.emit('updateTrackItem', { trackItem, trackItemId });
}
-export async function saveTrackItem(trackItem): Promise {
+function getRawTrackItem2(savedItem: ITrackItem): ITrackItem {
+ const item = {
+ id: savedItem.id,
+ app: savedItem.app,
+ title: savedItem.title,
+ taskName: savedItem.taskName,
+ color: savedItem.color,
+ beginDate: savedItem.beginDate,
+ url: savedItem.url,
+ endDate: savedItem.endDate,
+ };
+
+ return item;
+}
+
+export async function saveTrackItem(inputItem: ITrackItem): Promise {
+ const trackItem = getRawTrackItem2(inputItem);
Logger.debug('Saving trackitem.', trackItem);
+
if (!trackItem.taskName) {
trackItem.taskName = 'LogTrackItem';
}
+
if (trackItem.id) {
- if (trackItem.originalColor === trackItem.color) {
- // this.updateTrackItem(trackItem);
- } else {
- // this.showChangeColorDialog();
- }
- const item = await updateTrackItem(trackItem);
+ const item = await updateTrackItem(trackItem, trackItem.id);
Logger.debug('Updated trackitem to DB:', item);
return item;
}
+
if (!trackItem.app) {
trackItem.app = 'Default';
}
+
const item = createTrackItem(trackItem);
// Logger.debug('Created trackitem to DB:', item);
return item;
}
-export function deleteByIds(trackItemIds: number[]) {
- return emit('deleteByIds', { trackItemIds });
+export function deleteByIds(trackItemIds: number[]): Promise {
+ return ElectronEventEmitter.emit('deleteByIds', { trackItemIds });
}
-export function startNewLogItem(oldItem: any) {
- Logger.debug('startNewLogItem');
+export function startNewLogItem(oldItem: ITrackItem): Promise {
+ Logger.debug('startNewLogItem', oldItem);
- const newItem: any = {};
- newItem.app = oldItem.app || 'WORK';
- newItem.taskName = 'LogTrackItem';
- newItem.color = oldItem.color;
- newItem.title = oldItem.title;
- newItem.beginDate = moment().valueOf();
- newItem.endDate = moment()
- .add(60, 'seconds')
- .valueOf();
+ const newItem: NewTrackItem = {
+ app: oldItem.app || 'WORK',
+ taskName: 'LogTrackItem',
+ color: oldItem.color,
+ title: oldItem.title,
+ beginDate: DateTime.now().toMillis(),
+ endDate: DateTime.now().plus({ seconds: 60 }).toMillis(),
+ };
- EventEmitter.send('start-new-log-item', newItem);
+ ElectronEventEmitter.send('start-new-log-item', newItem);
+ return Promise.resolve();
}
export function stopRunningLogItem(runningLogItemId: number) {
Logger.debug('stopRunningLogItem', runningLogItemId);
- EventEmitter.send('end-running-log-item');
+ ElectronEventEmitter.send('end-running-log-item');
}
export function updateTrackItemColor(appName: string, color: string) {
- return emit('updateTrackItemColor', { appName, color });
+ return ElectronEventEmitter.emit('updateTrackItemColor', { appName, color });
}
diff --git a/client/src/setupTests.ts b/client/src/setupTests.ts
new file mode 100644
index 00000000..c1f82900
--- /dev/null
+++ b/client/src/setupTests.ts
@@ -0,0 +1,79 @@
+// add Vitest functions here globally
+import '@testing-library/jest-dom/vitest';
+import { cleanup } from '@testing-library/react';
+import { afterEach, vi } from 'vitest';
+
+vi.mock('./logger');
+
+// Mock window.matchMedia - required for Chakra UI components in test environment
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(), // Deprecated
+ removeListener: vi.fn(), // Deprecated
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+});
+
+// Mock scrollTo for menu elements
+// This fixes the "menuRef.current?.scrollTo is not a function" error with React 19 + Chakra UI
+HTMLElement.prototype.scrollTo = HTMLElement.prototype.scrollTo || vi.fn();
+
+// Mock ResizeObserver which is not available in jsdom
+class ResizeObserverMock {
+ observe = vi.fn();
+ unobserve = vi.fn();
+ disconnect = vi.fn();
+}
+
+// Add ResizeObserver to the global object
+global.ResizeObserver = ResizeObserverMock;
+
+// Mock Canvas context
+// Instead of modifying the prototype directly, use a vi.spyOn approach to avoid TypeScript errors
+vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockImplementation(function (contextType) {
+ if (contextType === '2d') {
+ return {
+ font: '',
+ measureText: (text: string) => ({ width: text.length * 10 }), // Simple approximation
+ fillRect: vi.fn(),
+ clearRect: vi.fn(),
+ getImageData: vi.fn(() => ({
+ data: new Array(4),
+ })),
+ putImageData: vi.fn(),
+ createImageData: vi.fn(() => []),
+ setTransform: vi.fn(),
+ drawImage: vi.fn(),
+ save: vi.fn(),
+ fillText: vi.fn(),
+ restore: vi.fn(),
+ beginPath: vi.fn(),
+ moveTo: vi.fn(),
+ lineTo: vi.fn(),
+ closePath: vi.fn(),
+ stroke: vi.fn(),
+ translate: vi.fn(),
+ scale: vi.fn(),
+ rotate: vi.fn(),
+ arc: vi.fn(),
+ fill: vi.fn(),
+ transform: vi.fn(),
+ rect: vi.fn(),
+ clip: vi.fn(),
+ } as unknown as CanvasRenderingContext2D;
+ }
+
+ // Default to null for other context types
+ return null;
+});
+
+// Run cleanup after each test case (e.g., clearing jsdom)
+afterEach(() => {
+ cleanup();
+});
diff --git a/client/src/store/easyPeasy.tsx b/client/src/store/easyPeasy.tsx
index 362d48d1..c63bb0fd 100644
--- a/client/src/store/easyPeasy.tsx
+++ b/client/src/store/easyPeasy.tsx
@@ -1,8 +1,9 @@
import { createTypedHooks } from 'easy-peasy';
import { StoreModel } from './mainStore';
-const typedHooks = createTypedHooks();
+// Create typed hooks for the main store
+const mainTypedHooks = createTypedHooks();
-export const useStoreActions = typedHooks.useStoreActions;
-export const useStoreDispatch = typedHooks.useStoreDispatch;
-export const useStoreState = typedHooks.useStoreState;
+export const useStoreActions = mainTypedHooks.useStoreActions;
+export const useStoreDispatch = mainTypedHooks.useStoreDispatch;
+export const useStoreState = mainTypedHooks.useStoreState;
diff --git a/client/src/store/mainStore.tsx b/client/src/store/mainStore.tsx
index 724f0911..bf17246e 100644
--- a/client/src/store/mainStore.tsx
+++ b/client/src/store/mainStore.tsx
@@ -1,40 +1,31 @@
-import { createStore, Action, action, Thunk, thunk, thunkOn, ThunkOn } from 'easy-peasy';
-import moment, { Moment } from 'moment';
-import { ITrackItem } from '../@types/ITrackItem';
-import {
- getTodayTimerange,
- getCenteredTimerange,
- setDayFromTimerange,
-} from '../components/Timeline/timeline.utils';
+import { Action, action, createStore, Thunk, thunk, thunkOn, ThunkOn } from 'easy-peasy';
+import { DateTime } from 'luxon';
+import { ITimelineState } from '../@types/ITimelineState';
+import { SelectedTrackItem } from '../@types/ITrackItem';
+import { getCenteredTimerange, getTodayTimerange, setDayFromTimerange } from '../components/Timeline/timeline.utils';
+import { TrackItemType } from '../enum/TrackItemType';
import { Logger } from '../logger';
import { findAllDayItemsForEveryTrack } from '../services/trackItem.api';
import { addToTimelineItems } from '../timeline.util';
-import { PRIMARY_COLOR_VAR, setThemeVars, THEMES } from './theme.util';
-
-const emptyTimeItems = {
- appItems: [],
- logItems: [],
- statusItems: [],
-};
+import { loadVisibleRange, saveVisibleRange } from '../utils';
const defaultTimerange = getTodayTimerange();
-const defaultVisibleTimerange = getCenteredTimerange(
- defaultTimerange,
- [moment().subtract(1, 'hour'), moment().add(1, 'hour')],
- moment(),
-);
+// Try to load saved visible range from localStorage, fall back to default if not found
+const savedVisibleTimerange = loadVisibleRange();
+
+const defaultVisibleTimerange =
+ savedVisibleTimerange ||
+ getCenteredTimerange(
+ defaultTimerange,
+ [DateTime.now().minus({ hours: 1 }), DateTime.now().plus({ hours: 1 })],
+ DateTime.now(),
+ );
export const TIMERANGE_MODE_TODAY = 'TODAY';
export interface StoreModel {
- theme: any;
- setTheme: Action;
- setThemeWithVariables: Thunk;
-
- setThemeByName: Thunk;
-
- selectedTimelineItem: null | ITrackItem;
- setSelectedTimelineItem: Action;
+ selectedTimelineItem: null | SelectedTrackItem;
+ setSelectedTimelineItem: Action;
liveView: boolean;
setLiveView: Action;
@@ -42,51 +33,32 @@ export interface StoreModel {
isLoading: boolean;
setIsLoading: Action;
- timerange: Moment[];
- setTimerange: Action;
+ timerange: DateTime[];
+ setTimerange: Action;
- visibleTimerange: Moment[];
- setVisibleTimerange: Action;
+ visibleTimerange: DateTime[];
+ setVisibleTimerange: Action;
timerangeMode: string | null;
setTimerangeMode: Action;
- lastRequestTime: Moment;
- setLastRequestTime: Action;
+ lastRequestTime: DateTime;
+ setLastRequestTime: Action;
- timeItems: any;
- setTimeItems: Action;
+ timeItems: ITimelineState;
+ setTimeItems: Action;
onSetTimerange: ThunkOn;
fetchTimerange: Thunk;
- loadTimerange: Thunk;
+ loadTimerange: Thunk;
- bgSync: Thunk