diff --git a/web/package-lock.json b/web/package-lock.json index eabe8ea..649b16b 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "next": "16.1.6", "react": "19.2.3", "react-dom": "19.2.3", + "recharts": "^3.7.0", "serwist": "^9.5.6", "uuid": "^13.0.0", "zod": "^4.3.6", @@ -1956,6 +1957,42 @@ "node": ">=14" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.59.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", @@ -2448,7 +2485,12 @@ "version": "1.1.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2845,6 +2887,69 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -2912,6 +3017,12 @@ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/uuid": { "version": "10.0.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/@types/uuid/-/uuid-10.0.0.tgz", @@ -4109,6 +4220,15 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/color-convert/-/color-convert-2.0.1.tgz", @@ -4218,6 +4338,127 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -4328,6 +4569,12 @@ "dev": true, "license": "MIT" }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/deep-is/-/deep-is-0.1.4.tgz", @@ -4655,6 +4902,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/esbuild/-/esbuild-0.27.3.tgz", @@ -5161,6 +5418,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/expect-type/-/expect-type-1.3.0.tgz", @@ -5750,6 +6013,17 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/import-fresh/-/import-fresh-3.3.1.tgz", @@ -5802,6 +6076,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -6942,6 +7225,7 @@ "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/next/-/next-16.1.6.tgz", "integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==", "license": "MIT", + "peer": true, "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", @@ -7516,8 +7800,62 @@ "version": "16.13.1", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } }, "node_modules/redent": { "version": "3.0.0", @@ -7533,6 +7871,22 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT", + "peer": true + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7587,6 +7941,12 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/resolve/-/resolve-1.22.11.tgz", @@ -8432,6 +8792,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/tinybench/-/tinybench-2.9.0.tgz", @@ -8853,6 +9219,16 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peer": true, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/uuid": { "version": "13.0.0", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/uuid/-/uuid-13.0.0.tgz", @@ -8866,6 +9242,28 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/vite/-/vite-7.3.1.tgz", diff --git a/web/src/app/history/page.tsx b/web/src/app/history/page.tsx new file mode 100644 index 0000000..3f534b7 --- /dev/null +++ b/web/src/app/history/page.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { useState, useEffect, useMemo } from 'react'; +import Link from 'next/link'; +import { useTimerStore } from '@/lib/store'; +import { computeStreak } from '@/lib/stats'; +import { StatsView } from '@/components/StatsView'; +import { StreakCard } from '@/components/StreakCard'; +import { TimerCard } from '@/components/TimerCard'; +import { downloadExport, readFileAsText, parseImportData, importTimers } from '@/lib/export'; +import { importCalendar } from '@/lib/calendar-import'; +import { ArrowLeft, Download, Upload, Calendar, Search, Filter } from 'lucide-react'; +import { getCategoryById, getAllCategories } from '@/lib/categories'; + +export default function HistoryPage() { + const timers = useTimerStore((s) => s.timers); + const now = useTimerStore((s) => s.now); + const [mounted, setMounted] = useState(false); + const [search, setSearch] = useState(''); + const [filterCategory, setFilterCategory] = useState(''); + const [filterUrgency, setFilterUrgency] = useState(''); + const [importStatus, setImportStatus] = useState(null); + const [tab, setTab] = useState<'stats' | 'history' | 'import'>('stats'); + + useEffect(() => { setMounted(true); }, []); + + const streak = useMemo(() => computeStreak(timers, now), [timers, now]); + + const completedTimers = useMemo(() => { + return timers + .filter((t) => ['dismissed', 'completed'].includes(t.state)) + .filter((t) => { + if (search) { + const q = search.toLowerCase(); + if (!t.label.toLowerCase().includes(q) && !t.description?.toLowerCase().includes(q)) { + return false; + } + } + if (filterCategory && t.category !== filterCategory) return false; + if (filterUrgency && t.urgency !== filterUrgency) return false; + return true; + }) + .sort((a, b) => (b.completedAt ?? b.dismissedAt ?? b.createdAt) - (a.completedAt ?? a.dismissedAt ?? a.createdAt)); + }, [timers, search, filterCategory, filterUrgency]); + + const categories = useMemo(() => getAllCategories(), []); + + if (!mounted) return null; + + const handleExport = () => downloadExport(timers); + + const handleJsonImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await readFileAsText(file); + const data = parseImportData(text); + if (!data) { + setImportStatus('Invalid file format. Expected a ChronoMind export JSON.'); + return; + } + const result = importTimers(data, timers); + // Add the imported timers to the store + const existingIds = new Set(timers.map((t) => t.id)); + const newTimers = data.timers.filter((t) => !existingIds.has(t.id)); + if (newTimers.length > 0) { + useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] })); + } + setImportStatus(`Imported ${result.imported} timers. ${result.skipped} skipped.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`); + } catch { + setImportStatus('Failed to read file.'); + } + e.target.value = ''; + }; + + const handleIcsImport = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + const text = await readFileAsText(file); + const result = importCalendar(text, timers); + if (result.events.length === 0) { + setImportStatus(`No events found.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`); + return; + } + const newTimers = result.events.map((e) => e.timer); + useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] })); + const conflictCount = result.events.filter((e) => e.conflicts.length > 0).length; + setImportStatus(`Imported ${result.events.length} calendar events.${conflictCount > 0 ? ` ${conflictCount} have conflicts with existing timers.` : ''}`); + } catch { + setImportStatus('Failed to parse .ics file.'); + } + e.target.value = ''; + }; + + return ( +
+
+ + Back to Dashboard + + +

+ History & Stats +

+ + {/* Tabs */} +
+ {[ + { key: 'stats' as const, label: 'Statistics' }, + { key: 'history' as const, label: 'History' }, + { key: 'import' as const, label: 'Import / Export' }, + ].map((t) => ( + + ))} +
+ + {/* Stats tab */} + {tab === 'stats' && ( +
+ + +
+ )} + + {/* History tab */} + {tab === 'history' && ( +
+ {/* Search + filters */} +
+
+ + setSearch(e.target.value)} + placeholder="Search timers..." + className="w-full pl-9 pr-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--cm-surface-card)', + borderColor: 'var(--cm-border)', + color: 'var(--cm-text-primary)', + }} + /> +
+
+ + +
+
+ + {/* Timer list */} + {completedTimers.length > 0 ? ( +
+

+ {completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history +

+ {completedTimers.map((timer) => ( + + ))} +
+ ) : ( +
+ +

+ {search || filterCategory || filterUrgency + ? 'No timers match your filters.' + : 'No completed timers yet.'} +

+
+ )} +
+ )} + + {/* Import/Export tab */} + {tab === 'import' && ( +
+ {/* Warning */} +
+ Your timers are stored locally in your browser. Export regularly to back up your data. + Cloud sync is coming in a future version. +
+ + {/* Export */} +
+

+ Export Timers +

+

+ Download all {timers.length} timers as a JSON file. +

+ +
+ + {/* Import JSON */} +
+

+ Import Timers (JSON) +

+

+ Restore timers from a previously exported ChronoMind JSON file. +

+ +
+ + {/* Import .ics */} +
+

+ Import Calendar (.ics) +

+

+ Import events from a .ics file (Google Calendar, Outlook, Apple Calendar exports). + Events become alarms with auto-generated pre-warning cascades. +

+ +
+ + {/* Import status */} + {importStatus && ( +
+ {importStatus} +
+ )} +
+ )} +
+
+ ); +} diff --git a/web/src/components/CreateTimerModal.tsx b/web/src/components/CreateTimerModal.tsx index 80160e2..5f9c632 100644 --- a/web/src/components/CreateTimerModal.tsx +++ b/web/src/components/CreateTimerModal.tsx @@ -7,6 +7,7 @@ import type { UrgencyLevel } from '@/lib/urgency'; import { CASCADE_PRESET_LABELS } from '@/lib/cascade'; import type { CascadePreset } from '@/lib/cascade'; import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react'; +import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories'; import { parseNaturalLanguage } from '@/lib/nl-parser'; import type { ParseResult } from '@/lib/nl-parser'; @@ -26,6 +27,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { const [label, setLabel] = useState(''); const [urgency, setUrgency] = useState('standard'); const [cascadePreset, setCascadePreset] = useState('standard'); + const [category, setCategory] = useState(''); // Alarm fields const [alarmTime, setAlarmTime] = useState(''); @@ -82,8 +84,21 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { onClose(); }; + // When category changes, update urgency + cascade defaults + const handleCategoryChange = (catId: string) => { + setCategory(catId); + if (catId) { + const cat = getCategoryById(catId); + if (cat) { + setUrgency(cat.defaultUrgency); + setCascadePreset(cat.defaultCascade); + } + } + }; + const handleCreate = () => { const cascade = { preset: cascadePreset, intervals: [] as number[] }; + const catOrUndef = category || undefined; if (tab === 'alarm') { if (!alarmTime) return; @@ -98,6 +113,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { targetTime: target.getTime(), urgency, cascade, + category: catOrUndef, }); } else if (tab === 'countdown') { const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000; @@ -107,6 +123,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { durationMs, urgency, cascade, + category: catOrUndef, }); } else if (tab === 'pomodoro') { addPomodoro({ @@ -127,6 +144,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { setHours(0); setMinutes(25); setSeconds(0); + setCategory(''); onClose(); }; @@ -355,6 +373,42 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) { )} + {/* Category */} + {tab !== 'pomodoro' && ( +
+ +
+ + {BUILT_IN_CATEGORIES.map((cat) => ( + + ))} +
+
+ )} + {/* Urgency (non-pomodoro) */} {tab !== 'pomodoro' && (
diff --git a/web/src/components/StatsView.tsx b/web/src/components/StatsView.tsx new file mode 100644 index 0000000..2ef4c1f --- /dev/null +++ b/web/src/components/StatsView.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { useTimerStore } from '@/lib/store'; +import { + computeStats, + computeDailyStats, + computeCategoryBreakdown, + type TimeRange, +} from '@/lib/stats'; +import { getCategoryById } from '@/lib/categories'; +import { + BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, PieChart, Pie, Cell, +} from 'recharts'; +import { TrendingUp, Target, Clock, Zap } from 'lucide-react'; + +const RANGE_LABELS: Record = { + daily: 'Today', + weekly: '7 Days', + monthly: '30 Days', + all: 'All Time', +}; + +export function StatsView() { + const timers = useTimerStore((s) => s.timers); + const now = useTimerStore((s) => s.now); + const [range, setRange] = useState('weekly'); + + const stats = useMemo(() => computeStats(timers, range, now), [timers, range, now]); + const dailyData = useMemo( + () => computeDailyStats(timers, range === 'daily' ? 1 : range === 'monthly' ? 30 : 7, now), + [timers, range, now] + ); + const categoryBreakdown = useMemo( + () => computeCategoryBreakdown(timers, range, now), + [timers, range, now] + ); + + const chartData = dailyData.map((d) => ({ + date: d.date.slice(5), // MM-DD + Created: d.created, + Completed: d.completed, + Dismissed: d.dismissed, + })); + + const focusData = dailyData.map((d) => ({ + date: d.date.slice(5), + Minutes: d.focusMinutes, + })); + + const categoryPieData = categoryBreakdown.map((c) => { + const cat = getCategoryById(c.categoryId); + return { + name: cat?.label ?? c.categoryId, + value: c.count, + color: cat?.color ?? '#5A8CFF', + }; + }); + + return ( +
+ {/* Range selector */} +
+ {(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => ( + + ))} +
+ + {/* Summary cards */} +
+ } label="Created" value={stats.created} color="var(--cm-accent)" /> + } label="Completed" value={stats.completed} color="var(--cm-gentle)" /> + } + label="On-Time Rate" + value={`${Math.round(stats.onTimeRate * 100)}%`} + color="var(--cm-standard)" + /> + } + label="Focus Time" + value={stats.focusMinutes > 60 ? `${(stats.focusMinutes / 60).toFixed(1)}h` : `${stats.focusMinutes}m`} + color="var(--cm-accent-secondary)" + /> +
+ + {/* Timer activity chart */} + {chartData.length > 1 && ( +
+

+ Timer Activity +

+ + + + + + + + + + + +
+ )} + + {/* Focus time trend */} + {focusData.some((d) => d.Minutes > 0) && ( +
+

+ Focus Time (minutes) +

+ + + + + + + + + +
+ )} + + {/* Category breakdown */} + {categoryPieData.length > 0 && ( +
+

+ By Category +

+
+ + + + {categoryPieData.map((entry, idx) => ( + + ))} + + + +
+ {categoryPieData.map((entry) => ( +
+
+
+ {entry.name} +
+ + {entry.value} + +
+ ))} +
+
+
+ )} + + {/* Extra stats */} +
+

+ Details +

+
+ + + + +
+
+
+ ); +} + +function StatCard({ + icon, + label, + value, + color, +}: { + icon: React.ReactNode; + label: string; + value: string | number; + color: string; +}) { + return ( +
+
+ {icon} + + {label} + +
+
+ {value} +
+
+ ); +} + +function DetailRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/web/src/components/StreakCard.tsx b/web/src/components/StreakCard.tsx new file mode 100644 index 0000000..639a7e3 --- /dev/null +++ b/web/src/components/StreakCard.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { Flame, Trophy, Shield } from 'lucide-react'; +import type { StreakInfo } from '@/lib/stats'; + +interface StreakCardProps { + streak: StreakInfo; +} + +export function StreakCard({ streak }: StreakCardProps) { + const { currentStreak, longestStreak, streakFreezeUsed, streakFreezeAvailable } = streak; + + return ( +
+
+

+ 0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }} /> + Streak +

+ {streakFreezeUsed && ( + + Freeze used + + )} +
+ +
+ {/* Current streak */} +
+
0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }} + > + {currentStreak} +
+
+ {currentStreak === 1 ? 'day' : 'days'} current +
+
+ + {/* Longest streak */} +
+
+ + + {longestStreak} + +
+
+ best +
+
+ + {/* Freeze status */} + {streakFreezeAvailable && ( +
+
+ + + 1 freeze + +
+
+ available +
+
+ )} +
+ + {/* Streak milestones */} + {currentStreak >= 7 && ( +
= 30 + ? 'rgba(255, 159, 67, 0.15)' + : 'rgba(46, 213, 115, 0.15)', + color: currentStreak >= 30 ? 'var(--cm-important)' : 'var(--cm-gentle)', + }} + > + {currentStreak >= 100 + ? 'πŸ† 100+ day streak! Legendary!' + : currentStreak >= 30 + ? 'πŸ”₯ 30+ day streak! On fire!' + : '✨ 7+ day streak! Keep it up!'} +
+ )} +
+ ); +} diff --git a/web/src/lib/calendar-import.test.ts b/web/src/lib/calendar-import.test.ts new file mode 100644 index 0000000..2608143 --- /dev/null +++ b/web/src/lib/calendar-import.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect } from 'vitest'; +import { + parseIcs, + mapPriorityToUrgency, + eventToTimer, + detectConflicts, + importCalendar, +} from './calendar-import'; +import type { Timer } from './timer-engine'; + +// ── Fixtures ────────────────────────────────────────────────── + +const SIMPLE_ICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//Test//EN +BEGIN:VEVENT +UID:test-1@example.com +SUMMARY:Team Standup +DTSTART:20260315T090000 +DTEND:20260315T093000 +DESCRIPTION:Daily standup meeting +LOCATION:Room 42 +PRIORITY:3 +END:VEVENT +END:VCALENDAR`; + +const MULTI_EVENT_ICS = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:evt-1 +SUMMARY:Morning Meeting +DTSTART:20260315T090000 +DTEND:20260315T100000 +END:VEVENT +BEGIN:VEVENT +UID:evt-2 +SUMMARY:Lunch +DTSTART:20260315T120000 +DTEND:20260315T130000 +END:VEVENT +BEGIN:VEVENT +UID:evt-3 +SUMMARY:Code Review +DTSTART:20260315T140000 +DTEND:20260315T150000 +PRIORITY:2 +END:VEVENT +END:VCALENDAR`; + +const UTC_ICS = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:utc-1 +SUMMARY:UTC Event +DTSTART:20260315T140000Z +DTEND:20260315T150000Z +END:VEVENT +END:VCALENDAR`; + +const DATE_ONLY_ICS = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:date-1 +SUMMARY:All Day Event +DTSTART:20260315 +END:VEVENT +END:VCALENDAR`; + +const ESCAPED_ICS = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:esc-1 +SUMMARY:Meeting\\, Important +DESCRIPTION:Details:\\nLine 2\\nLine 3 +DTSTART:20260315T090000 +END:VEVENT +END:VCALENDAR`; + +const FOLDED_ICS = `BEGIN:VCALENDAR\r +BEGIN:VEVENT\r +UID:fold-1\r +SUMMARY:A very long summary that gets\r + folded across multiple lines\r +DTSTART:20260315T090000\r +END:VEVENT\r +END:VCALENDAR`; + +const INVALID_ICS = `BEGIN:VCALENDAR +BEGIN:VEVENT +UID:bad-1 +END:VEVENT +END:VCALENDAR`; + +// ── Tests ───────────────────────────────────────────────────── + +describe('parseIcs', () => { + it('parses a simple .ics file', () => { + const { events, errors } = parseIcs(SIMPLE_ICS); + expect(errors).toHaveLength(0); + expect(events).toHaveLength(1); + + const evt = events[0]; + expect(evt.uid).toBe('test-1@example.com'); + expect(evt.summary).toBe('Team Standup'); + expect(evt.description).toBe('Daily standup meeting'); + expect(evt.location).toBe('Room 42'); + expect(evt.priority).toBe(3); + expect(evt.dtstart.getFullYear()).toBe(2026); + expect(evt.dtstart.getMonth()).toBe(2); // March = 2 (0-indexed) + expect(evt.dtstart.getDate()).toBe(15); + expect(evt.dtstart.getHours()).toBe(9); + }); + + it('parses multiple events', () => { + const { events, errors } = parseIcs(MULTI_EVENT_ICS); + expect(errors).toHaveLength(0); + expect(events).toHaveLength(3); + expect(events[0].summary).toBe('Morning Meeting'); + expect(events[1].summary).toBe('Lunch'); + expect(events[2].summary).toBe('Code Review'); + }); + + it('parses UTC datetime', () => { + const { events } = parseIcs(UTC_ICS); + expect(events).toHaveLength(1); + // UTC time: should be converted properly + expect(events[0].dtstart.getUTCHours()).toBe(14); + }); + + it('parses date-only events', () => { + const { events } = parseIcs(DATE_ONLY_ICS); + expect(events).toHaveLength(1); + expect(events[0].dtstart.getFullYear()).toBe(2026); + expect(events[0].dtstart.getMonth()).toBe(2); + expect(events[0].dtstart.getDate()).toBe(15); + }); + + it('unescapes special characters', () => { + const { events } = parseIcs(ESCAPED_ICS); + expect(events[0].summary).toBe('Meeting, Important'); + expect(events[0].description).toBe('Details:\nLine 2\nLine 3'); + }); + + it('unfolds continuation lines', () => { + const { events } = parseIcs(FOLDED_ICS); + expect(events).toHaveLength(1); + expect(events[0].summary).toBe('A very long summary that gets folded across multiple lines'); + }); + + it('reports errors for events missing required fields', () => { + const { events, errors } = parseIcs(INVALID_ICS); + expect(events).toHaveLength(0); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('missing required fields'); + }); + + it('handles empty content', () => { + const { events, errors } = parseIcs(''); + expect(events).toHaveLength(0); + expect(errors).toHaveLength(0); + }); +}); + +describe('mapPriorityToUrgency', () => { + it('maps priority 1 to critical', () => { + expect(mapPriorityToUrgency(1)).toBe('critical'); + }); + + it('maps priority 2 to critical', () => { + expect(mapPriorityToUrgency(2)).toBe('critical'); + }); + + it('maps priority 3 to important', () => { + expect(mapPriorityToUrgency(3)).toBe('important'); + }); + + it('maps priority 5 to standard', () => { + expect(mapPriorityToUrgency(5)).toBe('standard'); + }); + + it('maps priority 7 to gentle', () => { + expect(mapPriorityToUrgency(7)).toBe('gentle'); + }); + + it('maps priority 9 to passive', () => { + expect(mapPriorityToUrgency(9)).toBe('passive'); + }); + + it('defaults to standard for undefined priority', () => { + expect(mapPriorityToUrgency(undefined)).toBe('standard'); + }); + + it('defaults to standard for out-of-range priority', () => { + expect(mapPriorityToUrgency(0)).toBe('standard'); + expect(mapPriorityToUrgency(10)).toBe('standard'); + }); +}); + +describe('eventToTimer', () => { + it('converts an event to a timer', () => { + const event = { + uid: 'test-1', + summary: 'Meeting', + dtstart: new Date(Date.now() + 3_600_000), + description: 'Important meeting', + priority: 3, + }; + + const timer = eventToTimer(event); + expect(timer.type).toBe('alarm'); + expect(timer.label).toBe('Meeting'); + expect(timer.urgency).toBe('important'); + expect(timer.state).toBe('active'); + expect(timer.cascade.preset).toBe('standard'); + }); + + it('marks past events as dismissed', () => { + const event = { + uid: 'past-1', + summary: 'Past Event', + dtstart: new Date(Date.now() - 3_600_000), + }; + + const timer = eventToTimer(event); + expect(timer.state).toBe('dismissed'); + }); + + it('includes location in description', () => { + const event = { + uid: 'loc-1', + summary: 'Meeting', + dtstart: new Date(Date.now() + 3_600_000), + location: 'Room 42', + description: 'Notes here', + }; + + const timer = eventToTimer(event); + expect(timer.description).toContain('Room 42'); + expect(timer.description).toContain('Notes here'); + }); +}); + +describe('detectConflicts', () => { + const now = Date.now(); + + function makeExistingTimer(targetOffset: number): Timer { + return { + id: `existing-${Math.random()}`, + type: 'alarm', + label: 'Existing', + urgency: 'standard', + state: 'active', + targetTime: now + targetOffset, + duration: null, + createdAt: now, + startedAt: now, + pausedAt: null, + firedAt: null, + dismissedAt: null, + completedAt: null, + elapsedBeforePause: 0, + cascade: { preset: 'none', intervals: [] }, + warnings: [], + snoozeCount: 0, + snoozedUntil: null, + }; + } + + it('detects overlapping timers', () => { + const event = { + uid: 'conflict-1', + summary: 'New Event', + dtstart: new Date(now + 3_600_000), + dtend: new Date(now + 7_200_000), + }; + + const existing = [ + makeExistingTimer(3_600_000 + 5 * 60_000), // 5 min after event start + ]; + + const conflicts = detectConflicts(event, existing); + expect(conflicts).toHaveLength(1); + }); + + it('does not flag non-overlapping timers', () => { + const event = { + uid: 'no-conflict', + summary: 'New Event', + dtstart: new Date(now + 3_600_000), + dtend: new Date(now + 7_200_000), + }; + + const existing = [ + makeExistingTimer(24 * 3_600_000), // 24 hours later + ]; + + const conflicts = detectConflicts(event, existing); + expect(conflicts).toHaveLength(0); + }); + + it('ignores dismissed timers', () => { + const event = { + uid: 'dismissed-test', + summary: 'New Event', + dtstart: new Date(now + 3_600_000), + dtend: new Date(now + 7_200_000), + }; + + const dismissed = makeExistingTimer(3_600_000 + 60_000); + dismissed.state = 'dismissed'; + + const conflicts = detectConflicts(event, [dismissed]); + expect(conflicts).toHaveLength(0); + }); +}); + +describe('importCalendar', () => { + it('imports a full .ics file', () => { + const result = importCalendar(SIMPLE_ICS, [], Date.now()); + expect(result.totalParsed).toBe(1); + expect(result.events).toHaveLength(1); + expect(result.events[0].timer.label).toBe('Team Standup'); + }); + + it('imports multiple events', () => { + const result = importCalendar(MULTI_EVENT_ICS, []); + expect(result.totalParsed).toBe(3); + expect(result.events).toHaveLength(3); + }); + + it('reports parse errors', () => { + const result = importCalendar(INVALID_ICS, []); + expect(result.errors.length).toBeGreaterThan(0); + }); + + it('detects conflicts with existing timers', () => { + const now = Date.now(); + // Create an existing timer at March 15, 2026 9:15 AM + const target = new Date(2026, 2, 15, 9, 15, 0).getTime(); + const existing: Timer[] = [{ + id: 'conflict-timer', + type: 'alarm', + label: 'Conflicting', + urgency: 'standard', + state: 'active', + targetTime: target, + duration: null, + createdAt: now, + startedAt: now, + pausedAt: null, + firedAt: null, + dismissedAt: null, + completedAt: null, + elapsedBeforePause: 0, + cascade: { preset: 'none', intervals: [] }, + warnings: [], + snoozeCount: 0, + snoozedUntil: null, + }]; + + const result = importCalendar(SIMPLE_ICS, existing, now); + // The "Team Standup" event is at 9:00 AM March 15 β€” the existing timer at 9:15 is within window + const standup = result.events[0]; + expect(standup.conflicts.length).toBeGreaterThan(0); + }); +}); diff --git a/web/src/lib/calendar-import.ts b/web/src/lib/calendar-import.ts new file mode 100644 index 0000000..96f6f82 --- /dev/null +++ b/web/src/lib/calendar-import.ts @@ -0,0 +1,267 @@ +// ── Calendar .ics Import ────────────────────────────────────── +// Parse iCalendar (.ics) files and import events as timers + +import type { Timer } from './timer-engine'; +import type { UrgencyLevel } from './urgency'; +import type { CascadeConfig } from './cascade'; +import { v4 as uuidv4 } from 'uuid'; +import { calculateCascadeWarnings, getCascadeIntervals } from './cascade'; + +// ── Types ───────────────────────────────────────────────────── + +export interface IcsEvent { + uid: string; + summary: string; + description?: string; + dtstart: Date; + dtend?: Date; + location?: string; + priority?: number; // 1-9, lower = higher priority +} + +export interface ImportedEvent { + event: IcsEvent; + timer: Timer; + conflicts: string[]; // IDs of existing timers that overlap +} + +export interface CalendarImportResult { + events: ImportedEvent[]; + errors: string[]; + totalParsed: number; +} + +// ── .ics Parser ─────────────────────────────────────────────── + +/** + * Parse an .ics file content into events. + */ +export function parseIcs(content: string): { events: IcsEvent[]; errors: string[] } { + const events: IcsEvent[] = []; + const errors: string[] = []; + + // Unfold lines (RFC 5545: continuation lines start with space/tab) + const unfolded = content.replace(/\r\n[ \t]/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = unfolded.split('\n'); + + let inEvent = false; + let currentEvent: Partial = {}; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed === 'BEGIN:VEVENT') { + inEvent = true; + currentEvent = {}; + continue; + } + + if (trimmed === 'END:VEVENT') { + inEvent = false; + if (currentEvent.summary && currentEvent.dtstart) { + events.push({ + uid: currentEvent.uid ?? uuidv4(), + summary: currentEvent.summary, + description: currentEvent.description, + dtstart: currentEvent.dtstart, + dtend: currentEvent.dtend, + location: currentEvent.location, + priority: currentEvent.priority, + }); + } else { + errors.push(`Event missing required fields (SUMMARY or DTSTART)`); + } + continue; + } + + if (!inEvent) continue; + + // Parse property + const colonIdx = trimmed.indexOf(':'); + if (colonIdx === -1) continue; + + const propPart = trimmed.slice(0, colonIdx); + const value = trimmed.slice(colonIdx + 1); + + // Strip parameters (e.g., DTSTART;TZID=America/New_York:20260315T090000) + const propName = propPart.split(';')[0].toUpperCase(); + + switch (propName) { + case 'UID': + currentEvent.uid = value; + break; + case 'SUMMARY': + currentEvent.summary = unescapeIcsText(value); + break; + case 'DESCRIPTION': + currentEvent.description = unescapeIcsText(value); + break; + case 'LOCATION': + currentEvent.location = unescapeIcsText(value); + break; + case 'DTSTART': + currentEvent.dtstart = parseIcsDateTime(value); + break; + case 'DTEND': + currentEvent.dtend = parseIcsDateTime(value); + break; + case 'PRIORITY': + currentEvent.priority = parseInt(value, 10) || undefined; + break; + } + } + + return { events, errors }; +} + +/** + * Parse iCalendar date-time string. + * Formats: 20260315T090000, 20260315T090000Z, 20260315 + */ +function parseIcsDateTime(value: string): Date { + const cleaned = value.trim(); + + // Date only: YYYYMMDD + if (cleaned.length === 8) { + const y = parseInt(cleaned.slice(0, 4)); + const m = parseInt(cleaned.slice(4, 6)) - 1; + const d = parseInt(cleaned.slice(6, 8)); + return new Date(y, m, d); + } + + // DateTime: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ + const isUtc = cleaned.endsWith('Z'); + const dt = isUtc ? cleaned.slice(0, -1) : cleaned; + const parts = dt.split('T'); + + const y = parseInt(parts[0].slice(0, 4)); + const m = parseInt(parts[0].slice(4, 6)) - 1; + const d = parseInt(parts[0].slice(6, 8)); + const h = parts[1] ? parseInt(parts[1].slice(0, 2)) : 0; + const min = parts[1] ? parseInt(parts[1].slice(2, 4)) : 0; + const s = parts[1] ? parseInt(parts[1].slice(4, 6)) : 0; + + if (isUtc) { + return new Date(Date.UTC(y, m, d, h, min, s)); + } + return new Date(y, m, d, h, min, s); +} + +/** + * Unescape iCalendar text values. + */ +function unescapeIcsText(text: string): string { + return text + .replace(/\\n/g, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +// ── Event β†’ Timer Conversion ────────────────────────────────── + +/** + * Map iCalendar priority (1-9) to ChronoMind urgency level. + * 1-2: critical, 3-4: important, 5: standard, 6-7: gentle, 8-9: passive + */ +export function mapPriorityToUrgency(priority?: number): UrgencyLevel { + if (!priority || priority < 1 || priority > 9) return 'standard'; + if (priority <= 2) return 'critical'; + if (priority <= 4) return 'important'; + if (priority <= 5) return 'standard'; + if (priority <= 7) return 'gentle'; + return 'passive'; +} + +/** + * Convert an IcsEvent to a Timer with auto-generated cascade. + */ +export function eventToTimer(event: IcsEvent, now: number = Date.now()): Timer { + const urgency = mapPriorityToUrgency(event.priority); + const targetTime = event.dtstart.getTime(); + + // Auto-cascade based on urgency + const cascadePresetMap: Record = { + critical: { preset: 'aggressive', intervals: [] }, + important: { preset: 'standard', intervals: [] }, + standard: { preset: 'light', intervals: [] }, + gentle: { preset: 'minimal', intervals: [] }, + passive: { preset: 'none', intervals: [] }, + }; + + const cascade = cascadePresetMap[urgency]; + const intervals = getCascadeIntervals(cascade); + + return { + id: uuidv4(), + type: 'alarm', + label: event.summary, + description: [event.description, event.location].filter(Boolean).join(' β€” '), + urgency, + state: targetTime > now ? 'active' : 'dismissed', + targetTime, + duration: event.dtend ? event.dtend.getTime() - targetTime : null, + createdAt: now, + startedAt: targetTime > now ? now : null, + pausedAt: null, + firedAt: targetTime <= now ? targetTime : null, + dismissedAt: targetTime <= now ? now : null, + completedAt: null, + elapsedBeforePause: 0, + cascade, + warnings: calculateCascadeWarnings(targetTime, intervals, now), + snoozeCount: 0, + snoozedUntil: null, + }; +} + +// ── Conflict Detection ──────────────────────────────────────── + +/** + * Detect conflicts between an event and existing timers. + * Conflict = existing timer fires within 15 minutes of event start. + */ +export function detectConflicts( + event: IcsEvent, + existingTimers: Timer[], + conflictWindowMs: number = 15 * 60 * 1000 +): string[] { + const eventStart = event.dtstart.getTime(); + const eventEnd = event.dtend?.getTime() ?? eventStart + 60 * 60 * 1000; + + return existingTimers + .filter((t) => { + if (['dismissed', 'completed'].includes(t.state)) return false; + const timerTime = t.targetTime; + // Timer fires within event window or event starts within timer's range + return ( + (timerTime >= eventStart - conflictWindowMs && timerTime <= eventEnd + conflictWindowMs) + ); + }) + .map((t) => t.id); +} + +// ── Full Import Pipeline ────────────────────────────────────── + +/** + * Parse .ics content and convert to importable timers with conflict detection. + */ +export function importCalendar( + icsContent: string, + existingTimers: Timer[], + now: number = Date.now() +): CalendarImportResult { + const { events: icsEvents, errors: parseErrors } = parseIcs(icsContent); + + const importedEvents: ImportedEvent[] = icsEvents.map((event) => { + const timer = eventToTimer(event, now); + const conflicts = detectConflicts(event, existingTimers); + return { event, timer, conflicts }; + }); + + return { + events: importedEvents, + errors: parseErrors, + totalParsed: icsEvents.length, + }; +} diff --git a/web/src/lib/categories.test.ts b/web/src/lib/categories.test.ts new file mode 100644 index 0000000..f38998e --- /dev/null +++ b/web/src/lib/categories.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + BUILT_IN_CATEGORIES, + getAllCategories, + getCustomCategories, + saveCustomCategory, + removeCustomCategory, + getCategoryById, + getCategoryColor, + getCustomTags, + addCustomTag, + removeCustomTag, + matchesCategory, + matchesTags, +} from './categories'; + +// Mock localStorage +const storage: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => storage[key] ?? null), + setItem: vi.fn((key: string, value: string) => { storage[key] = value; }), + removeItem: vi.fn((key: string) => { delete storage[key]; }), + clear: vi.fn(() => { Object.keys(storage).forEach((k) => delete storage[k]); }), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); + +describe('Categories', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + describe('BUILT_IN_CATEGORIES', () => { + it('has 6 built-in categories', () => { + expect(BUILT_IN_CATEGORIES).toHaveLength(6); + }); + + it('all built-in categories have isBuiltIn=true', () => { + for (const cat of BUILT_IN_CATEGORIES) { + expect(cat.isBuiltIn).toBe(true); + } + }); + + it('includes Work, Personal, Health, Cooking, Exercise, Study', () => { + const ids = BUILT_IN_CATEGORIES.map((c) => c.id); + expect(ids).toEqual(['work', 'personal', 'health', 'cooking', 'exercise', 'study']); + }); + }); + + describe('getAllCategories', () => { + it('returns built-in categories when no custom ones exist', () => { + const cats = getAllCategories(); + expect(cats).toHaveLength(6); + }); + + it('includes custom categories alongside built-in', () => { + saveCustomCategory({ + id: 'custom1', + label: 'Custom', + color: '#FF0000', + icon: 'Star', + defaultUrgency: 'gentle', + defaultCascade: 'minimal', + }); + const cats = getAllCategories(); + expect(cats).toHaveLength(7); + expect(cats[6].id).toBe('custom1'); + }); + }); + + describe('saveCustomCategory', () => { + it('saves a new custom category', () => { + const result = saveCustomCategory({ + id: 'gaming', + label: 'Gaming', + color: '#9333EA', + icon: 'Gamepad2', + defaultUrgency: 'gentle', + defaultCascade: 'none', + }); + expect(result.isBuiltIn).toBe(false); + expect(result.label).toBe('Gaming'); + + const customs = getCustomCategories(); + expect(customs).toHaveLength(1); + expect(customs[0].id).toBe('gaming'); + }); + + it('updates existing custom category by ID', () => { + saveCustomCategory({ + id: 'gaming', + label: 'Gaming', + color: '#9333EA', + icon: 'Gamepad2', + defaultUrgency: 'gentle', + defaultCascade: 'none', + }); + + saveCustomCategory({ + id: 'gaming', + label: 'Gaming (Updated)', + color: '#7C3AED', + icon: 'Gamepad2', + defaultUrgency: 'standard', + defaultCascade: 'light', + }); + + const customs = getCustomCategories(); + expect(customs).toHaveLength(1); + expect(customs[0].label).toBe('Gaming (Updated)'); + expect(customs[0].color).toBe('#7C3AED'); + }); + }); + + describe('removeCustomCategory', () => { + it('removes existing custom category', () => { + saveCustomCategory({ + id: 'gaming', + label: 'Gaming', + color: '#9333EA', + icon: 'Gamepad2', + defaultUrgency: 'gentle', + defaultCascade: 'none', + }); + + const result = removeCustomCategory('gaming'); + expect(result).toBe(true); + expect(getCustomCategories()).toHaveLength(0); + }); + + it('returns false when removing non-existent category', () => { + const result = removeCustomCategory('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('getCategoryById', () => { + it('finds built-in category', () => { + const cat = getCategoryById('work'); + expect(cat).toBeDefined(); + expect(cat!.label).toBe('Work'); + }); + + it('finds custom category', () => { + saveCustomCategory({ + id: 'gaming', + label: 'Gaming', + color: '#9333EA', + icon: 'Gamepad2', + defaultUrgency: 'gentle', + defaultCascade: 'none', + }); + const cat = getCategoryById('gaming'); + expect(cat).toBeDefined(); + expect(cat!.label).toBe('Gaming'); + }); + + it('returns undefined for unknown ID', () => { + expect(getCategoryById('unknown')).toBeUndefined(); + }); + }); + + describe('getCategoryColor', () => { + it('returns color for valid category', () => { + expect(getCategoryColor('work')).toBe('#5A8CFF'); + }); + + it('returns undefined for undefined category', () => { + expect(getCategoryColor(undefined)).toBeUndefined(); + }); + + it('returns undefined for unknown category', () => { + expect(getCategoryColor('nope')).toBeUndefined(); + }); + }); +}); + +describe('Custom Tags', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('starts with empty tags', () => { + expect(getCustomTags()).toEqual([]); + }); + + it('adds a tag (normalized to lowercase)', () => { + const tags = addCustomTag('Urgent'); + expect(tags).toEqual(['urgent']); + }); + + it('deduplicates tags', () => { + addCustomTag('focus'); + const tags = addCustomTag('Focus'); + expect(tags).toEqual(['focus']); + }); + + it('ignores empty tags', () => { + addCustomTag('valid'); + const tags = addCustomTag(' '); + expect(tags).toEqual(['valid']); + }); + + it('removes a tag', () => { + addCustomTag('a'); + addCustomTag('b'); + const tags = removeCustomTag('a'); + expect(tags).toEqual(['b']); + }); +}); + +describe('Filter helpers', () => { + describe('matchesCategory', () => { + it('returns true when no filter', () => { + expect(matchesCategory('work', null)).toBe(true); + }); + + it('returns true when timer category matches filter', () => { + expect(matchesCategory('work', 'work')).toBe(true); + }); + + it('returns false when timer category does not match filter', () => { + expect(matchesCategory('personal', 'work')).toBe(false); + }); + + it('returns false when timer has no category but filter is set', () => { + expect(matchesCategory(undefined, 'work')).toBe(false); + }); + }); + + describe('matchesTags', () => { + it('returns true when no filter tags', () => { + expect(matchesTags(['a', 'b'], [])).toBe(true); + }); + + it('returns true when timer has a matching tag', () => { + expect(matchesTags(['focus', 'deep'], ['focus'])).toBe(true); + }); + + it('returns false when timer has no matching tags', () => { + expect(matchesTags(['a'], ['b'])).toBe(false); + }); + + it('returns false when timer has no tags', () => { + expect(matchesTags(undefined, ['b'])).toBe(false); + }); + + it('returns false when timer tags are empty', () => { + expect(matchesTags([], ['b'])).toBe(false); + }); + }); +}); diff --git a/web/src/lib/categories.ts b/web/src/lib/categories.ts new file mode 100644 index 0000000..94000bf --- /dev/null +++ b/web/src/lib/categories.ts @@ -0,0 +1,202 @@ +// ── Categories / Tags System ────────────────────────────────── +// Built-in categories with default urgency + cascade presets, plus custom tags + +import type { UrgencyLevel } from './urgency'; +import type { CascadePreset } from './cascade'; + +export interface Category { + id: string; + label: string; + color: string; + icon: string; // Lucide icon name + defaultUrgency: UrgencyLevel; + defaultCascade: CascadePreset; + isBuiltIn: boolean; +} + +// ── Built-in Categories ─────────────────────────────────────── + +export const BUILT_IN_CATEGORIES: Category[] = [ + { + id: 'work', + label: 'Work', + color: '#5A8CFF', + icon: 'Briefcase', + defaultUrgency: 'important', + defaultCascade: 'standard', + isBuiltIn: true, + }, + { + id: 'personal', + label: 'Personal', + color: '#A78BFA', + icon: 'User', + defaultUrgency: 'standard', + defaultCascade: 'light', + isBuiltIn: true, + }, + { + id: 'health', + label: 'Health', + color: '#34D399', + icon: 'Heart', + defaultUrgency: 'important', + defaultCascade: 'standard', + isBuiltIn: true, + }, + { + id: 'cooking', + label: 'Cooking', + color: '#F59E0B', + icon: 'ChefHat', + defaultUrgency: 'standard', + defaultCascade: 'light', + isBuiltIn: true, + }, + { + id: 'exercise', + label: 'Exercise', + color: '#2EE6D6', + icon: 'Dumbbell', + defaultUrgency: 'standard', + defaultCascade: 'minimal', + isBuiltIn: true, + }, + { + id: 'study', + label: 'Study', + color: '#FF9F43', + icon: 'BookOpen', + defaultUrgency: 'standard', + defaultCascade: 'light', + isBuiltIn: true, + }, +]; + +// ── Category Helpers ────────────────────────────────────────── + +const STORAGE_KEY = 'chronomind-custom-categories'; +const TAGS_STORAGE_KEY = 'chronomind-custom-tags'; + +/** + * Get all categories (built-in + custom) + */ +export function getAllCategories(): Category[] { + return [...BUILT_IN_CATEGORIES, ...getCustomCategories()]; +} + +/** + * Get custom user-defined categories from localStorage + */ +export function getCustomCategories(): Category[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +/** + * Save a custom category + */ +export function saveCustomCategory(category: Omit): Category { + const full: Category = { ...category, isBuiltIn: false }; + const existing = getCustomCategories(); + const idx = existing.findIndex((c) => c.id === full.id); + if (idx >= 0) { + existing[idx] = full; + } else { + existing.push(full); + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(existing)); + return full; +} + +/** + * Remove a custom category (cannot remove built-in) + */ +export function removeCustomCategory(id: string): boolean { + const existing = getCustomCategories(); + const filtered = existing.filter((c) => c.id !== id); + if (filtered.length === existing.length) return false; + localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)); + return true; +} + +/** + * Find a category by ID (built-in or custom) + */ +export function getCategoryById(id: string): Category | undefined { + return getAllCategories().find((c) => c.id === id); +} + +/** + * Get the category color for display (returns undefined if no category) + */ +export function getCategoryColor(categoryId: string | undefined): string | undefined { + if (!categoryId) return undefined; + return getCategoryById(categoryId)?.color; +} + +// ── Custom Tags ─────────────────────────────────────────────── + +/** + * Get all custom tags from localStorage + */ +export function getCustomTags(): string[] { + if (typeof window === 'undefined') return []; + try { + const raw = localStorage.getItem(TAGS_STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +/** + * Add a custom tag (deduped, lowercased) + */ +export function addCustomTag(tag: string): string[] { + const normalized = tag.trim().toLowerCase(); + if (!normalized) return getCustomTags(); + const existing = getCustomTags(); + if (existing.includes(normalized)) return existing; + const updated = [...existing, normalized]; + localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(updated)); + return updated; +} + +/** + * Remove a custom tag + */ +export function removeCustomTag(tag: string): string[] { + const existing = getCustomTags(); + const updated = existing.filter((t) => t !== tag.trim().toLowerCase()); + localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(updated)); + return updated; +} + +/** + * Filter timers by category ID. Returns true if timer matches. + */ +export function matchesCategory( + timerCategory: string | undefined, + filterCategoryId: string | null +): boolean { + if (!filterCategoryId) return true; // no filter = show all + return timerCategory === filterCategoryId; +} + +/** + * Filter timers by tags. Returns true if timer has at least one matching tag. + */ +export function matchesTags( + timerTags: string[] | undefined, + filterTags: string[] +): boolean { + if (filterTags.length === 0) return true; // no filter = show all + if (!timerTags || timerTags.length === 0) return false; + return filterTags.some((ft) => timerTags.includes(ft)); +} diff --git a/web/src/lib/export.ts b/web/src/lib/export.ts new file mode 100644 index 0000000..3f51d08 --- /dev/null +++ b/web/src/lib/export.ts @@ -0,0 +1,127 @@ +// ── Timer Export / Import ───────────────────────────────────── +// Export all timers as JSON, import from JSON file + +import type { Timer } from './timer-engine'; + +// ── Types ───────────────────────────────────────────────────── + +export interface ExportData { + version: 1; + exportedAt: number; + app: 'chronomind'; + timers: Timer[]; +} + +export interface ImportResult { + success: boolean; + imported: number; + skipped: number; + errors: string[]; +} + +// ── Export ───────────────────────────────────────────────────── + +/** + * Export timers as a downloadable JSON blob. + */ +export function exportTimers(timers: Timer[]): ExportData { + return { + version: 1, + exportedAt: Date.now(), + app: 'chronomind', + timers: timers.map((t) => ({ ...t })), // shallow clone + }; +} + +/** + * Trigger a browser download of the export data. + */ +export function downloadExport(timers: Timer[]): void { + const data = exportTimers(timers); + const json = JSON.stringify(data, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `chronomind-export-${new Date().toISOString().slice(0, 10)}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} + +// ── Import ──────────────────────────────────────────────────── + +/** + * Validate and parse an import file. + */ +export function parseImportData(jsonStr: string): ExportData | null { + try { + const data = JSON.parse(jsonStr); + if (!data || data.app !== 'chronomind' || !Array.isArray(data.timers)) { + return null; + } + return data as ExportData; + } catch { + return null; + } +} + +/** + * Import timers from parsed export data, deduplicating by ID. + */ +export function importTimers( + exportData: ExportData, + existingTimers: Timer[] +): ImportResult { + const errors: string[] = []; + let imported = 0; + let skipped = 0; + + const existingIds = new Set(existingTimers.map((t) => t.id)); + const newTimers: Timer[] = []; + + for (const timer of exportData.timers) { + // Basic validation + if (!timer.id || !timer.type || !timer.label) { + errors.push(`Invalid timer: missing required fields (id, type, or label)`); + skipped++; + continue; + } + + if (existingIds.has(timer.id)) { + skipped++; + continue; + } + + // Validate required numeric fields + if (typeof timer.createdAt !== 'number' || typeof timer.targetTime !== 'number') { + errors.push(`Timer "${timer.label}": invalid timestamps`); + skipped++; + continue; + } + + newTimers.push(timer); + imported++; + } + + return { + success: errors.length === 0, + imported, + skipped, + errors, + }; +} + +/** + * Read a File object and return the parsed content as string. + */ +export function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(new Error('Failed to read file')); + reader.readAsText(file); + }); +} diff --git a/web/src/lib/recurrence.test.ts b/web/src/lib/recurrence.test.ts new file mode 100644 index 0000000..7b0308d --- /dev/null +++ b/web/src/lib/recurrence.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from 'vitest'; +import { + getNextOccurrence, + getNextNOccurrences, + getOccurrenceAfterSkip, + createDailyRule, + createWeekdayRule, + createWeekendRule, + createWeeklyRule, + createBiweeklyRule, + createMonthlyRule, + createCustomRule, + formatTimeOfDay, + describeRecurrence, + RECURRENCE_LABELS, +} from './recurrence'; + +// Helper: create a date at a specific time +function makeDate(year: number, month: number, day: number, hour = 0, minute = 0): number { + return new Date(year, month - 1, day, hour, minute, 0, 0).getTime(); +} + +function getDateParts(ts: number) { + const d = new Date(ts); + return { + year: d.getFullYear(), + month: d.getMonth() + 1, + day: d.getDate(), + hour: d.getHours(), + minute: d.getMinutes(), + dayOfWeek: d.getDay(), + }; +} + +describe('Recurrence Engine', () => { + describe('Daily', () => { + const rule = createDailyRule(9 * 60); // 9:00 AM + + it('returns next day at 9 AM if after 9 AM today', () => { + const after = makeDate(2026, 3, 15, 10, 0); // 10:00 AM + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.day).toBe(16); + expect(parts.hour).toBe(9); + expect(parts.minute).toBe(0); + }); + + it('returns same day at 9 AM if before 9 AM today', () => { + const after = makeDate(2026, 3, 15, 7, 0); // 7:00 AM + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.day).toBe(15); + expect(parts.hour).toBe(9); + }); + + it('crosses month boundary', () => { + const after = makeDate(2026, 3, 31, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.month).toBe(4); + expect(parts.day).toBe(1); + }); + + it('crosses year boundary', () => { + const after = makeDate(2026, 12, 31, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.year).toBe(2027); + expect(parts.month).toBe(1); + expect(parts.day).toBe(1); + }); + }); + + describe('Weekday', () => { + const rule = createWeekdayRule(8 * 60 + 30); // 8:30 AM + + it('skips weekends', () => { + // Friday March 13, 2026 at 9:00 AM β†’ should be Monday March 16 + const friday = makeDate(2026, 3, 13, 9, 0); + const next = getNextOccurrence(rule, friday)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(1); // Monday + expect(parts.day).toBe(16); + }); + + it('returns same day if before time on a weekday', () => { + // Wednesday at 7:00 AM β†’ same day at 8:30 + const wed = makeDate(2026, 3, 11, 7, 0); + const next = getNextOccurrence(rule, wed)!; + const parts = getDateParts(next); + expect(parts.day).toBe(11); + expect(parts.hour).toBe(8); + expect(parts.minute).toBe(30); + }); + + it('skips Saturday', () => { + const sat = makeDate(2026, 3, 14, 7, 0); // Saturday + const next = getNextOccurrence(rule, sat)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(1); // Monday + }); + + it('skips Sunday', () => { + const sun = makeDate(2026, 3, 15, 7, 0); // Sunday + const next = getNextOccurrence(rule, sun)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(1); // Monday + }); + }); + + describe('Weekend', () => { + const rule = createWeekendRule(10 * 60); // 10:00 AM + + it('skips weekdays', () => { + const mon = makeDate(2026, 3, 9, 11, 0); // Monday + const next = getNextOccurrence(rule, mon)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek === 0 || parts.dayOfWeek === 6).toBe(true); + }); + + it('returns Saturday if on Friday evening', () => { + const fri = makeDate(2026, 3, 13, 18, 0); + const next = getNextOccurrence(rule, fri)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(6); // Saturday + expect(parts.day).toBe(14); + }); + }); + + describe('Weekly', () => { + it('returns next week same day', () => { + const rule = createWeeklyRule(14 * 60); // 2:00 PM + // Monday at 3pm β†’ next Monday + const mon = makeDate(2026, 3, 9, 15, 0); + const next = getNextOccurrence(rule, mon)!; + const parts = getDateParts(next); + expect(parts.day).toBe(16); + expect(parts.dayOfWeek).toBe(1); // Monday + }); + }); + + describe('Biweekly', () => { + it('returns occurrence in 2 weeks', () => { + const rule = createBiweeklyRule(9 * 60); + const start = makeDate(2026, 3, 9, 10, 0); // Monday + const next = getNextOccurrence(rule, start)!; + const parts = getDateParts(next); + // Should be the same day of week (Monday) within 14 days + expect(parts.dayOfWeek).toBe(1); + }); + }); + + describe('Monthly', () => { + const rule = createMonthlyRule(9 * 60); // 9:00 AM + + it('returns same day next month', () => { + const after = makeDate(2026, 3, 15, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.month).toBe(4); + expect(parts.day).toBe(15); + }); + + it('handles month with fewer days (31st β†’ 30th)', () => { + // Jan 31 β†’ Feb should be 28 (non-leap) or clamp + const after = makeDate(2026, 1, 31, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.month).toBe(2); + expect(parts.day).toBe(28); // Feb 2026 has 28 days + }); + + it('handles leap year (Feb 29)', () => { + // 2028 is a leap year + const after = makeDate(2028, 1, 29, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.month).toBe(2); + expect(parts.day).toBe(29); // Feb 29 exists in 2028 + }); + + it('handles month boundary Dec β†’ Jan', () => { + const after = makeDate(2026, 12, 15, 10, 0); + const next = getNextOccurrence(rule, after)!; + const parts = getDateParts(next); + expect(parts.year).toBe(2027); + expect(parts.month).toBe(1); + expect(parts.day).toBe(15); + }); + }); + + describe('Custom days', () => { + it('returns next matching day (Mon, Wed, Fri)', () => { + const rule = createCustomRule([1, 3, 5], 9 * 60); // Mon, Wed, Fri + const tue = makeDate(2026, 3, 10, 10, 0); // Tuesday + const next = getNextOccurrence(rule, tue)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(3); // Wednesday + expect(parts.day).toBe(11); + }); + + it('handles single day (Sunday only)', () => { + const rule = createCustomRule([0], 12 * 60); // Sunday noon + const mon = makeDate(2026, 3, 9, 10, 0); + const next = getNextOccurrence(rule, mon)!; + const parts = getDateParts(next); + expect(parts.dayOfWeek).toBe(0); // Sunday + }); + + it('returns null with empty daysOfWeek', () => { + const rule = createCustomRule([], 9 * 60); + const result = getNextOccurrence(rule, Date.now()); + expect(result).toBeNull(); + }); + }); + + describe('End date', () => { + it('returns null when next occurrence would be after end date', () => { + const rule = createDailyRule(9 * 60, makeDate(2026, 3, 10, 23, 59)); + const after = makeDate(2026, 3, 10, 10, 0); + const next = getNextOccurrence(rule, after); + expect(next).toBeNull(); + }); + + it('returns occurrence if before end date', () => { + const rule = createDailyRule(9 * 60, makeDate(2026, 3, 20, 23, 59)); + const after = makeDate(2026, 3, 10, 10, 0); + const next = getNextOccurrence(rule, after); + expect(next).not.toBeNull(); + }); + }); + + describe('getNextNOccurrences', () => { + it('returns N daily occurrences', () => { + const rule = createDailyRule(9 * 60); + const after = makeDate(2026, 3, 15, 10, 0); + const occs = getNextNOccurrences(rule, after, 5); + expect(occs).toHaveLength(5); + + // Should be consecutive days + for (let i = 1; i < occs.length; i++) { + const diff = occs[i] - occs[i - 1]; + expect(diff).toBe(24 * 60 * 60 * 1000); // exactly 1 day apart + } + }); + + it('returns fewer than N if end date limits', () => { + const rule = createDailyRule(9 * 60, makeDate(2026, 3, 18, 23, 59)); + const after = makeDate(2026, 3, 15, 10, 0); + const occs = getNextNOccurrences(rule, after, 10); + expect(occs.length).toBeLessThanOrEqual(3); + }); + + it('returns weekday-only occurrences', () => { + const rule = createWeekdayRule(9 * 60); + const sun = makeDate(2026, 3, 8, 10, 0); // Sunday + const occs = getNextNOccurrences(rule, sun, 5); + expect(occs).toHaveLength(5); + occs.forEach((ts) => { + const dow = new Date(ts).getDay(); + expect(dow).toBeGreaterThanOrEqual(1); + expect(dow).toBeLessThanOrEqual(5); + }); + }); + }); + + describe('getOccurrenceAfterSkip', () => { + it('skips the next occurrence and returns the one after', () => { + const rule = createDailyRule(9 * 60); + const after = makeDate(2026, 3, 15, 10, 0); + const skipped = getOccurrenceAfterSkip(rule, after)!; + const parts = getDateParts(skipped); + expect(parts.day).toBe(17); // skipped 16, got 17 + }); + + it('returns null if no occurrence after skip', () => { + const rule = createDailyRule(9 * 60, makeDate(2026, 3, 16, 23, 59)); + const after = makeDate(2026, 3, 15, 10, 0); + const skipped = getOccurrenceAfterSkip(rule, after); + expect(skipped).toBeNull(); + }); + }); + + describe('DST edge cases', () => { + it('handles spring forward (March DST transition)', () => { + // In 2026, US DST starts March 8. Timer at 2:30 AM should still work. + const rule = createDailyRule(2 * 60 + 30); // 2:30 AM + const before = makeDate(2026, 3, 7, 3, 0); + const next = getNextOccurrence(rule, before)!; + expect(next).not.toBeNull(); + const parts = getDateParts(next); + // Should return March 8 (or 9) at the target time + expect(parts.month).toBe(3); + }); + + it('handles fall back (November DST transition)', () => { + // In 2026, US DST ends November 1 + const rule = createDailyRule(1 * 60 + 30); // 1:30 AM + const before = makeDate(2026, 10, 31, 2, 0); + const next = getNextOccurrence(rule, before)!; + expect(next).not.toBeNull(); + const parts = getDateParts(next); + expect(parts.month).toBe(11); + }); + }); +}); + +describe('Display helpers', () => { + describe('formatTimeOfDay', () => { + it('formats midnight', () => { + expect(formatTimeOfDay(0)).toBe('12:00 AM'); + }); + + it('formats noon', () => { + expect(formatTimeOfDay(12 * 60)).toBe('12:00 PM'); + }); + + it('formats 9:00 AM', () => { + expect(formatTimeOfDay(9 * 60)).toBe('9:00 AM'); + }); + + it('formats 3:30 PM', () => { + expect(formatTimeOfDay(15 * 60 + 30)).toBe('3:30 PM'); + }); + + it('formats 11:59 PM', () => { + expect(formatTimeOfDay(23 * 60 + 59)).toBe('11:59 PM'); + }); + }); + + describe('describeRecurrence', () => { + it('describes daily', () => { + expect(describeRecurrence(createDailyRule(9 * 60))).toBe('Every day at 9:00 AM'); + }); + + it('describes weekday', () => { + expect(describeRecurrence(createWeekdayRule(8 * 60 + 30))).toBe('Weekdays at 8:30 AM'); + }); + + it('describes custom with days', () => { + const desc = describeRecurrence(createCustomRule([1, 3, 5], 9 * 60)); + expect(desc).toBe('Mon, Wed, Fri at 9:00 AM'); + }); + }); + + describe('RECURRENCE_LABELS', () => { + it('has labels for all frequencies', () => { + expect(Object.keys(RECURRENCE_LABELS)).toHaveLength(7); + expect(RECURRENCE_LABELS.daily).toBe('Every day'); + }); + }); +}); diff --git a/web/src/lib/recurrence.ts b/web/src/lib/recurrence.ts new file mode 100644 index 0000000..d87f239 --- /dev/null +++ b/web/src/lib/recurrence.ts @@ -0,0 +1,284 @@ +// ── Recurring Timer Engine ──────────────────────────────────── +// Recurrence rules, next-occurrence calculation, skip/pause logic + +export type RecurrenceFrequency = + | 'daily' + | 'weekday' + | 'weekend' + | 'weekly' + | 'biweekly' + | 'monthly' + | 'custom'; + +export interface RecurrenceRule { + frequency: RecurrenceFrequency; + daysOfWeek?: number[]; // 0=Sun, 1=Mon, ..., 6=Sat (for 'custom' frequency) + interval?: number; // Every N periods (default 1) + endDate?: number; // epoch ms β€” stop recurring after this + timeOfDay: number; // minutes since midnight (e.g., 540 = 9:00 AM) +} + +export interface RecurringTimer { + id: string; + recurrence: RecurrenceRule; + paused: boolean; + skipNext: boolean; + lastOccurrence: number | null; // epoch ms of last generated occurrence +} + +export const RECURRENCE_LABELS: Record = { + daily: 'Every day', + weekday: 'Weekdays (Mon–Fri)', + weekend: 'Weekends (Sat–Sun)', + weekly: 'Every week', + biweekly: 'Every 2 weeks', + monthly: 'Every month', + custom: 'Custom days', +}; + +const DAY_MS = 24 * 60 * 60 * 1000; + +// ── Next Occurrence Calculation ─────────────────────────────── + +/** + * Calculate the next occurrence of a recurring timer after `afterDate`. + * Returns epoch ms of next occurrence, or null if no more occurrences. + */ +export function getNextOccurrence( + rule: RecurrenceRule, + afterDate: number, + maxLookaheadDays: number = 366 +): number | null { + const { frequency, timeOfDay, endDate, interval = 1 } = rule; + + // Start from the day after afterDate + const after = new Date(afterDate); + const startDay = new Date(after.getFullYear(), after.getMonth(), after.getDate()); + + // Set time of day + const setTimeOnDate = (d: Date): Date => { + const result = new Date(d.getFullYear(), d.getMonth(), d.getDate()); + result.setMinutes(timeOfDay); + return result; + }; + + // Check if a candidate on the same day as afterDate still works + const sameDayCandidate = setTimeOnDate(startDay); + const candidates: Date[] = []; + + if (sameDayCandidate.getTime() > afterDate) { + candidates.push(sameDayCandidate); + } + + // Generate candidates going forward + const limit = new Date(startDay.getTime() + maxLookaheadDays * DAY_MS); + + for (let d = new Date(startDay.getTime() + DAY_MS); d <= limit; d = new Date(d.getTime() + DAY_MS)) { + candidates.push(setTimeOnDate(d)); + if (candidates.length > maxLookaheadDays) break; + } + + for (const candidate of candidates) { + const ts = candidate.getTime(); + + // Check end date + if (endDate && ts > endDate) return null; + + // Check if this day matches the frequency + if (matchesFrequency(candidate, rule, afterDate, interval)) { + return ts; + } + } + + return null; +} + +/** + * Check if a given date matches the recurrence frequency rule. + */ +function matchesFrequency( + date: Date, + rule: RecurrenceRule, + _afterDate: number, + interval: number +): boolean { + const dayOfWeek = date.getDay(); // 0=Sun + + switch (rule.frequency) { + case 'daily': + return true; + + case 'weekday': + return dayOfWeek >= 1 && dayOfWeek <= 5; + + case 'weekend': + return dayOfWeek === 0 || dayOfWeek === 6; + + case 'weekly': { + // Same day of week as the reference day, with interval + const refDay = new Date(_afterDate).getDay(); + if (dayOfWeek !== refDay) return false; + if (interval <= 1) return true; + const refDate = new Date(_afterDate); + const refStart = new Date(refDate.getFullYear(), refDate.getMonth(), refDate.getDate()); + const diffDays = Math.round((date.getTime() - refStart.getTime()) / DAY_MS); + return diffDays % (interval * 7) === 0 || diffDays % (interval * 7) === 7; + } + + case 'biweekly': { + const refDay2 = new Date(_afterDate).getDay(); + if (dayOfWeek !== refDay2) return false; + const refDate2 = new Date(_afterDate); + const refStart2 = new Date(refDate2.getFullYear(), refDate2.getMonth(), refDate2.getDate()); + const diffDays2 = Math.round((date.getTime() - refStart2.getTime()) / DAY_MS); + return diffDays2 >= 0 && diffDays2 % 14 < 7; + } + + case 'monthly': { + // Same day of month β€” handle months with fewer days + const refDayOfMonth = new Date(_afterDate).getDate(); + const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate(); + const targetDay = Math.min(refDayOfMonth, daysInMonth); + return date.getDate() === targetDay; + } + + case 'custom': + return rule.daysOfWeek ? rule.daysOfWeek.includes(dayOfWeek) : false; + + default: + return false; + } +} + +// ── Bulk Helpers ────────────────────────────────────────────── + +/** + * Get the next N occurrences of a recurring timer. + */ +export function getNextNOccurrences( + rule: RecurrenceRule, + afterDate: number, + count: number +): number[] { + const occurrences: number[] = []; + let cursor = afterDate; + + for (let i = 0; i < count; i++) { + const next = getNextOccurrence(rule, cursor); + if (!next) break; + occurrences.push(next); + cursor = next; + } + + return occurrences; +} + +/** + * Apply "skip next" β€” get the occurrence after the next one. + */ +export function getOccurrenceAfterSkip( + rule: RecurrenceRule, + afterDate: number +): number | null { + const next = getNextOccurrence(rule, afterDate); + if (!next) return null; + return getNextOccurrence(rule, next); +} + +// ── Rule Builders ───────────────────────────────────────────── + +/** + * Create a daily recurrence rule at a specific time. + */ +export function createDailyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'daily', timeOfDay: timeOfDayMinutes, endDate }; +} + +/** + * Create a weekday recurrence rule. + */ +export function createWeekdayRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'weekday', timeOfDay: timeOfDayMinutes, endDate }; +} + +/** + * Create a weekend recurrence rule. + */ +export function createWeekendRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'weekend', timeOfDay: timeOfDayMinutes, endDate }; +} + +/** + * Create a weekly recurrence rule. + */ +export function createWeeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'weekly', timeOfDay: timeOfDayMinutes, interval: 1, endDate }; +} + +/** + * Create a biweekly recurrence rule. + */ +export function createBiweeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'biweekly', timeOfDay: timeOfDayMinutes, endDate }; +} + +/** + * Create a monthly recurrence rule. + */ +export function createMonthlyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule { + return { frequency: 'monthly', timeOfDay: timeOfDayMinutes, endDate }; +} + +/** + * Create a custom recurrence rule for specific days of the week. + */ +export function createCustomRule( + daysOfWeek: number[], + timeOfDayMinutes: number, + endDate?: number +): RecurrenceRule { + return { frequency: 'custom', daysOfWeek, timeOfDay: timeOfDayMinutes, endDate }; +} + +// ── Display Helpers ─────────────────────────────────────────── + +/** + * Format time-of-day minutes as "HH:MM AM/PM" + */ +export function formatTimeOfDay(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + const period = h >= 12 ? 'PM' : 'AM'; + const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h; + return `${h12}:${String(m).padStart(2, '0')} ${period}`; +} + +/** + * Get a human-readable description of a recurrence rule. + */ +export function describeRecurrence(rule: RecurrenceRule): string { + const time = formatTimeOfDay(rule.timeOfDay); + const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + switch (rule.frequency) { + case 'daily': + return `Every day at ${time}`; + case 'weekday': + return `Weekdays at ${time}`; + case 'weekend': + return `Weekends at ${time}`; + case 'weekly': + return `Every week at ${time}`; + case 'biweekly': + return `Every 2 weeks at ${time}`; + case 'monthly': + return `Monthly at ${time}`; + case 'custom': { + if (!rule.daysOfWeek || rule.daysOfWeek.length === 0) return `Custom at ${time}`; + const days = rule.daysOfWeek.sort().map((d) => dayNames[d]).join(', '); + return `${days} at ${time}`; + } + default: + return `At ${time}`; + } +} diff --git a/web/src/lib/stats.test.ts b/web/src/lib/stats.test.ts new file mode 100644 index 0000000..1851aeb --- /dev/null +++ b/web/src/lib/stats.test.ts @@ -0,0 +1,343 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Timer } from './timer-engine'; +import { + computeStats, + computeDailyStats, + computeStreak, + computeWeeklySummary, + computeCategoryBreakdown, +} from './stats'; + +// Mock localStorage for streak persistence +const storage: Record = {}; +const localStorageMock = { + getItem: vi.fn((key: string) => storage[key] ?? null), + setItem: vi.fn((key: string, value: string) => { storage[key] = value; }), + removeItem: vi.fn((key: string) => { delete storage[key]; }), + clear: vi.fn(() => { Object.keys(storage).forEach((k) => delete storage[k]); }), +}; +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true }); + +// ── Helpers ─────────────────────────────────────────────────── + +function makeTimer(overrides: Partial = {}): Timer { + const now = Date.now(); + return { + id: `timer-${Math.random().toString(36).slice(2)}`, + type: 'countdown', + label: 'Test', + urgency: 'standard', + state: 'active', + targetTime: now + 60_000, + duration: 60_000, + createdAt: now, + startedAt: now, + pausedAt: null, + firedAt: null, + dismissedAt: null, + completedAt: null, + elapsedBeforePause: 0, + cascade: { preset: 'none', intervals: [] }, + warnings: [], + snoozeCount: 0, + snoozedUntil: null, + ...overrides, + }; +} + +function daysAgo(n: number, now: number = Date.now()): number { + return now - n * 24 * 60 * 60 * 1000; +} + +describe('computeStats', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('counts created timers', () => { + const timers = [makeTimer(), makeTimer(), makeTimer()]; + const stats = computeStats(timers); + expect(stats.created).toBe(3); + }); + + it('counts completed timers', () => { + const timers = [ + makeTimer({ state: 'completed', completedAt: Date.now() }), + makeTimer({ state: 'completed', completedAt: Date.now() }), + makeTimer({ state: 'active' }), + ]; + const stats = computeStats(timers); + expect(stats.completed).toBe(2); + }); + + it('counts dismissed timers', () => { + const timers = [ + makeTimer({ state: 'dismissed', dismissedAt: Date.now() }), + makeTimer({ state: 'active' }), + ]; + const stats = computeStats(timers); + expect(stats.dismissed).toBe(1); + }); + + it('counts snoozed timers', () => { + const timers = [ + makeTimer({ snoozeCount: 2 }), + makeTimer({ snoozeCount: 0 }), + makeTimer({ snoozeCount: 1 }), + ]; + const stats = computeStats(timers); + expect(stats.snoozed).toBe(2); + }); + + it('computes on-time rate correctly', () => { + const now = Date.now(); + const timers = [ + // Acted on within 2 min + makeTimer({ + state: 'completed', + firedAt: now - 60_000, + completedAt: now - 60_000 + 30_000, // 30s after firing + }), + // Acted on after 2 min + makeTimer({ + state: 'dismissed', + firedAt: now - 300_000, + dismissedAt: now - 300_000 + 180_000, // 3 min after firing + }), + ]; + + const stats = computeStats(timers); + expect(stats.onTimeRate).toBe(0.5); // 1 out of 2 + }); + + it('on-time rate is 0 when no timers have fired', () => { + const timers = [makeTimer()]; + const stats = computeStats(timers); + expect(stats.onTimeRate).toBe(0); + }); + + it('computes focus minutes from completed Pomodoros', () => { + const timers = [ + makeTimer({ + type: 'pomodoro', + state: 'completed', + pomodoroConfig: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: 4 }, + pomodoroState: { currentRound: 4, isBreak: false, isLongBreak: false, completedRounds: 4 }, + }), + makeTimer({ + type: 'pomodoro', + state: 'completed', + pomodoroConfig: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: 4 }, + pomodoroState: { currentRound: 2, isBreak: false, isLongBreak: false, completedRounds: 2 }, + }), + ]; + + const stats = computeStats(timers); + expect(stats.focusMinutes).toBe(4 * 25 + 2 * 25); // 150 minutes + }); + + it('computes average snooze count', () => { + const timers = [ + makeTimer({ snoozeCount: 3 }), + makeTimer({ snoozeCount: 1 }), + makeTimer({ snoozeCount: 0 }), + ]; + const stats = computeStats(timers); + expect(stats.averageSnoozeCount).toBe(2); // (3+1)/2 + }); + + it('filters by daily range', () => { + const now = Date.now(); + const timers = [ + makeTimer({ createdAt: now }), + makeTimer({ createdAt: daysAgo(2, now) }), + ]; + const stats = computeStats(timers, 'daily', now); + expect(stats.created).toBe(1); + }); + + it('filters by weekly range', () => { + const now = Date.now(); + const timers = [ + makeTimer({ createdAt: now }), + makeTimer({ createdAt: daysAgo(5, now) }), + makeTimer({ createdAt: daysAgo(10, now) }), + ]; + const stats = computeStats(timers, 'weekly', now); + expect(stats.created).toBe(2); + }); + + it('filters by monthly range', () => { + const now = Date.now(); + const timers = [ + makeTimer({ createdAt: now }), + makeTimer({ createdAt: daysAgo(20, now) }), + makeTimer({ createdAt: daysAgo(40, now) }), + ]; + const stats = computeStats(timers, 'monthly', now); + expect(stats.created).toBe(2); + }); +}); + +describe('computeDailyStats', () => { + it('returns correct number of days', () => { + const daily = computeDailyStats([], 7); + expect(daily).toHaveLength(7); + }); + + it('each day has a valid date string', () => { + const daily = computeDailyStats([], 3); + for (const d of daily) { + expect(d.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + it('counts timers in correct day buckets', () => { + const now = Date.now(); + const today = new Date(now); + today.setHours(12, 0, 0, 0); + + const timers = [ + makeTimer({ createdAt: today.getTime(), state: 'completed', completedAt: today.getTime() }), + makeTimer({ createdAt: today.getTime() }), + ]; + + const daily = computeDailyStats(timers, 1, now); + expect(daily).toHaveLength(1); + expect(daily[0].created).toBe(2); + expect(daily[0].completed).toBe(1); + }); +}); + +describe('computeStreak', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('returns 0 streak with no completed timers', () => { + const streak = computeStreak([]); + expect(streak.currentStreak).toBe(0); + expect(streak.streakStartDate).toBeNull(); + }); + + it('counts consecutive days with completed timers', () => { + const now = Date.now(); + const timers = [ + // Yesterday + makeTimer({ + state: 'completed', + completedAt: daysAgo(1, now), + createdAt: daysAgo(1, now), + }), + // Day before yesterday + makeTimer({ + state: 'completed', + completedAt: daysAgo(2, now), + createdAt: daysAgo(2, now), + }), + ]; + + const streak = computeStreak(timers, now); + expect(streak.currentStreak).toBeGreaterThanOrEqual(2); + }); + + it('streak breaks on missed day', () => { + const now = Date.now(); + const timers = [ + // Yesterday + makeTimer({ + state: 'completed', + completedAt: daysAgo(1, now), + createdAt: daysAgo(1, now), + }), + // 3 days ago (gap on day before yesterday) + makeTimer({ + state: 'completed', + completedAt: daysAgo(3, now), + createdAt: daysAgo(3, now), + }), + ]; + + const streak = computeStreak(timers, now); + // Streak should be 1 (only yesterday) unless freeze kicks in + expect(streak.currentStreak).toBeLessThanOrEqual(2); + }); + + it('longestStreak is at least currentStreak', () => { + const now = Date.now(); + const timers = [ + makeTimer({ state: 'completed', completedAt: daysAgo(1, now), createdAt: daysAgo(1, now) }), + ]; + const streak = computeStreak(timers, now); + expect(streak.longestStreak).toBeGreaterThanOrEqual(streak.currentStreak); + }); +}); + +describe('computeWeeklySummary', () => { + beforeEach(() => { + localStorageMock.clear(); + vi.clearAllMocks(); + }); + + it('returns a weekly summary object', () => { + const summary = computeWeeklySummary([]); + expect(summary.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(summary.weekEnd).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(summary.stats).toBeDefined(); + expect(summary.dailyBreakdown).toHaveLength(7); + expect(summary.streak).toBe(0); + expect(summary.topCategory).toBeNull(); + }); + + it('identifies top category', () => { + const now = Date.now(); + const weekStart = new Date(now); + weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1); // Monday + weekStart.setHours(12, 0, 0, 0); + + const timers = [ + makeTimer({ category: 'work', createdAt: weekStart.getTime() }), + makeTimer({ category: 'work', createdAt: weekStart.getTime() }), + makeTimer({ category: 'health', createdAt: weekStart.getTime() }), + ]; + + const summary = computeWeeklySummary(timers, now); + expect(summary.topCategory).toBe('work'); + }); +}); + +describe('computeCategoryBreakdown', () => { + it('returns empty array when no categories', () => { + const result = computeCategoryBreakdown([makeTimer()]); + expect(result).toEqual([]); + }); + + it('groups timers by category', () => { + const timers = [ + makeTimer({ category: 'work' }), + makeTimer({ category: 'work', state: 'completed' }), + makeTimer({ category: 'health' }), + ]; + + const breakdown = computeCategoryBreakdown(timers); + expect(breakdown).toHaveLength(2); + + const work = breakdown.find((b) => b.categoryId === 'work'); + expect(work!.count).toBe(2); + expect(work!.completed).toBe(1); + }); + + it('sorts by count descending', () => { + const timers = [ + makeTimer({ category: 'health' }), + makeTimer({ category: 'work' }), + makeTimer({ category: 'work' }), + makeTimer({ category: 'work' }), + ]; + + const breakdown = computeCategoryBreakdown(timers); + expect(breakdown[0].categoryId).toBe('work'); + }); +}); diff --git a/web/src/lib/stats.ts b/web/src/lib/stats.ts new file mode 100644 index 0000000..d2f898f --- /dev/null +++ b/web/src/lib/stats.ts @@ -0,0 +1,350 @@ +// ── Statistics + Streaks Engine ──────────────────────────────── +// Timer analytics: created/completed/snoozed/dismissed, on-time rate, focus time, streaks + +import type { Timer } from './timer-engine'; + +// ── Types ───────────────────────────────────────────────────── + +export interface TimerStats { + created: number; + completed: number; + dismissed: number; + snoozed: number; + active: number; + onTimeRate: number; // 0-1 (% acted on within 2 min of firing) + focusMinutes: number; // total Pomodoro focus minutes + averageSnoozeCount: number; +} + +export interface DailyStats { + date: string; // YYYY-MM-DD + created: number; + completed: number; + dismissed: number; + snoozed: number; + focusMinutes: number; + onTimeCount: number; // timers acted on within 2 min + firedCount: number; // total timers that fired (for on-time rate) +} + +export interface StreakInfo { + currentStreak: number; // consecutive days with β‰₯1 completed timer + longestStreak: number; + streakStartDate: string | null; // YYYY-MM-DD + lastActiveDate: string | null; // YYYY-MM-DD + streakFreezeUsed: boolean; + streakFreezeAvailable: boolean; // 1 free per week +} + +export interface WeeklySummary { + weekStart: string; // YYYY-MM-DD (Monday) + weekEnd: string; // YYYY-MM-DD (Sunday) + stats: TimerStats; + dailyBreakdown: DailyStats[]; + streak: number; + topCategory: string | null; +} + +export type TimeRange = 'daily' | 'weekly' | 'monthly' | 'all'; + +// ── Constants ───────────────────────────────────────────────── + +const ON_TIME_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes +const STREAK_STORAGE_KEY = 'chronomind-streak'; + +// ── Helpers ─────────────────────────────────────────────────── + +function toDateStr(ts: number): string { + const d = new Date(ts); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function getWeekStart(ts: number): string { + const d = new Date(ts); + const day = d.getDay(); // 0=Sun + const diff = day === 0 ? 6 : day - 1; // shift so Monday=0 + const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate() - diff); + return toDateStr(monday.getTime()); +} + +function isWithinRange(ts: number, range: TimeRange, now: number): boolean { + if (range === 'all') return true; + const d = new Date(now); + const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); + + switch (range) { + case 'daily': + return ts >= startOfDay; + case 'weekly': + return ts >= startOfDay - 6 * 24 * 60 * 60 * 1000; + case 'monthly': + return ts >= startOfDay - 29 * 24 * 60 * 60 * 1000; + default: + return true; + } +} + +// ── Core Stats Computation ──────────────────────────────────── + +/** + * Compute aggregate timer statistics. + */ +export function computeStats(timers: Timer[], range: TimeRange = 'all', now: number = Date.now()): TimerStats { + const filtered = timers.filter((t) => isWithinRange(t.createdAt, range, now)); + + const created = filtered.length; + const completed = filtered.filter((t) => t.state === 'completed').length; + const dismissed = filtered.filter((t) => t.state === 'dismissed').length; + const snoozed = filtered.filter((t) => t.snoozeCount > 0).length; + const active = filtered.filter((t) => + ['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state) + ).length; + + // On-time rate: % of fired timers acted on within 2 minutes + const firedTimers = filtered.filter((t) => t.firedAt); + let onTimeCount = 0; + for (const t of firedTimers) { + const actedAt = t.dismissedAt ?? t.completedAt; + if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) { + onTimeCount++; + } + } + const onTimeRate = firedTimers.length > 0 ? onTimeCount / firedTimers.length : 0; + + // Focus time: Pomodoro completed work minutes + const focusMinutes = filtered + .filter((t) => t.type === 'pomodoro' && t.state === 'completed' && t.pomodoroConfig) + .reduce((sum, t) => { + const config = t.pomodoroConfig!; + const completedRounds = t.pomodoroState?.completedRounds ?? config.rounds; + return sum + completedRounds * config.workMinutes; + }, 0); + + // Average snooze count + const timersWithSnooze = filtered.filter((t) => t.snoozeCount > 0); + const averageSnoozeCount = timersWithSnooze.length > 0 + ? timersWithSnooze.reduce((s, t) => s + t.snoozeCount, 0) / timersWithSnooze.length + : 0; + + return { created, completed, dismissed, snoozed, active, onTimeRate, focusMinutes, averageSnoozeCount }; +} + +/** + * Compute daily breakdown over the last N days. + */ +export function computeDailyStats(timers: Timer[], days: number = 7, now: number = Date.now()): DailyStats[] { + const result: DailyStats[] = []; + + for (let i = days - 1; i >= 0; i--) { + const dayStart = new Date(now); + dayStart.setDate(dayStart.getDate() - i); + dayStart.setHours(0, 0, 0, 0); + const dayEnd = new Date(dayStart); + dayEnd.setDate(dayEnd.getDate() + 1); + + const dateStr = toDateStr(dayStart.getTime()); + const dayTimers = timers.filter( + (t) => t.createdAt >= dayStart.getTime() && t.createdAt < dayEnd.getTime() + ); + + const firedTimers = dayTimers.filter((t) => t.firedAt); + let onTimeCount = 0; + for (const t of firedTimers) { + const actedAt = t.dismissedAt ?? t.completedAt; + if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) { + onTimeCount++; + } + } + + const focusMinutes = dayTimers + .filter((t) => t.type === 'pomodoro' && t.state === 'completed' && t.pomodoroConfig) + .reduce((sum, t) => { + const config = t.pomodoroConfig!; + const completedRounds = t.pomodoroState?.completedRounds ?? config.rounds; + return sum + completedRounds * config.workMinutes; + }, 0); + + result.push({ + date: dateStr, + created: dayTimers.length, + completed: dayTimers.filter((t) => t.state === 'completed').length, + dismissed: dayTimers.filter((t) => t.state === 'dismissed').length, + snoozed: dayTimers.filter((t) => t.snoozeCount > 0).length, + focusMinutes, + onTimeCount, + firedCount: firedTimers.length, + }); + } + + return result; +} + +// ── Streak Tracking ─────────────────────────────────────────── + +interface PersistedStreak { + longestStreak: number; + lastFreezeWeek: string | null; // ISO week string when freeze was last used +} + +function loadPersistedStreak(): PersistedStreak { + if (typeof window === 'undefined') return { longestStreak: 0, lastFreezeWeek: null }; + try { + const raw = localStorage.getItem(STREAK_STORAGE_KEY); + return raw ? JSON.parse(raw) : { longestStreak: 0, lastFreezeWeek: null }; + } catch { + return { longestStreak: 0, lastFreezeWeek: null }; + } +} + +function savePersistedStreak(data: PersistedStreak): void { + if (typeof window === 'undefined') return; + localStorage.setItem(STREAK_STORAGE_KEY, JSON.stringify(data)); +} + +/** + * Compute streak info from timer history. + */ +export function computeStreak(timers: Timer[], now: number = Date.now()): StreakInfo { + // Get all dates that had β‰₯1 completed timer + const completedDates = new Set(); + for (const t of timers) { + if (t.state === 'completed' && t.completedAt) { + completedDates.add(toDateStr(t.completedAt)); + } + } + + const today = toDateStr(now); + const persisted = loadPersistedStreak(); + + // Walk backwards from today counting consecutive days + let currentStreak = 0; + let streakStartDate: string | null = null; + let lastActiveDate: string | null = null; + let streakFreezeUsed = false; + let missedOneDay = false; + const currentWeek = getWeekStart(now); + const freezeAvailable = persisted.lastFreezeWeek !== currentWeek; + + const d = new Date(now); + d.setHours(0, 0, 0, 0); + + for (let i = 0; i < 365; i++) { + const dateStr = toDateStr(d.getTime()); + + if (completedDates.has(dateStr)) { + currentStreak++; + streakStartDate = dateStr; + if (!lastActiveDate) lastActiveDate = dateStr; + } else if (i === 0) { + // Today doesn't count yet (day not over) β€” check if yesterday had one + // Don't break streak if today hasn't ended + } else if (!missedOneDay && freezeAvailable && i <= 1) { + // Allow one streak freeze per week + missedOneDay = true; + streakFreezeUsed = true; + } else { + break; + } + + d.setDate(d.getDate() - 1); + } + + // Update longest streak + const longestStreak = Math.max(persisted.longestStreak, currentStreak); + if (longestStreak > persisted.longestStreak) { + savePersistedStreak({ ...persisted, longestStreak }); + } + + // If freeze was used, persist it + if (streakFreezeUsed) { + savePersistedStreak({ ...persisted, longestStreak, lastFreezeWeek: currentWeek }); + } + + return { + currentStreak, + longestStreak, + streakStartDate, + lastActiveDate, + streakFreezeUsed, + streakFreezeAvailable: freezeAvailable && !streakFreezeUsed, + }; +} + +// ── Weekly Summary ──────────────────────────────────────────── + +/** + * Generate a weekly summary for sharing. + */ +export function computeWeeklySummary(timers: Timer[], now: number = Date.now()): WeeklySummary { + const weekStartStr = getWeekStart(now); + const weekStart = new Date(weekStartStr); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 6); + + const weekTimers = timers.filter( + (t) => t.createdAt >= weekStart.getTime() && t.createdAt < weekEnd.getTime() + 24 * 60 * 60 * 1000 + ); + + const stats = computeStats(weekTimers, 'all', now); + const dailyBreakdown = computeDailyStats(timers, 7, now); + const { currentStreak } = computeStreak(timers, now); + + // Top category + const categoryCounts: Record = {}; + for (const t of weekTimers) { + if (t.category) { + categoryCounts[t.category] = (categoryCounts[t.category] ?? 0) + 1; + } + } + const topCategory = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)[0]?.[0] ?? null; + + return { + weekStart: weekStartStr, + weekEnd: toDateStr(weekEnd.getTime()), + stats, + dailyBreakdown, + streak: currentStreak, + topCategory, + }; +} + +// ── Category Breakdown ──────────────────────────────────────── + +export interface CategoryStats { + categoryId: string; + count: number; + completed: number; + onTimeRate: number; +} + +/** + * Break down stats by category. + */ +export function computeCategoryBreakdown(timers: Timer[], range: TimeRange = 'all', now: number = Date.now()): CategoryStats[] { + const filtered = timers.filter((t) => isWithinRange(t.createdAt, range, now) && t.category); + + const groups: Record = {}; + for (const t of filtered) { + const cat = t.category!; + if (!groups[cat]) groups[cat] = []; + groups[cat].push(t); + } + + return Object.entries(groups).map(([categoryId, catTimers]) => { + const firedTimers = catTimers.filter((t) => t.firedAt); + let onTimeCount = 0; + for (const t of firedTimers) { + const actedAt = t.dismissedAt ?? t.completedAt; + if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) { + onTimeCount++; + } + } + + return { + categoryId, + count: catTimers.length, + completed: catTimers.filter((t) => t.state === 'completed').length, + onTimeRate: firedTimers.length > 0 ? onTimeCount / firedTimers.length : 0, + }; + }).sort((a, b) => b.count - a.count); +}