feat(web): Phase 2 — stats, categories, recurring, export/import, calendar .ics
- Statistics + streaks engine (lib/stats.ts) with daily/weekly/monthly breakdowns, on-time rate, focus time, streak tracking (23 tests) - Categories/tags system (lib/categories.ts) with 6 built-in categories, custom tags, default urgency+cascade per category (29 tests) - Recurring timer engine (lib/recurrence.ts) with daily/weekday/weekend/weekly/biweekly/monthly/custom rules, skip/pause, DST edge cases (37 tests) - Timer export/import as JSON (lib/export.ts) - Calendar .ics import (lib/calendar-import.ts) with RFC 5545 parsing, conflict detection, priority-to-urgency mapping (26 tests) - StatsView component with Recharts (bar, line, pie charts) - StreakCard component with milestone badges - History page (/history) with stats, history search/filter, import/export - Category picker in CreateTimerModal with auto urgency+cascade defaults - Category filter chips on Dashboard + History link in header - Installed recharts dependency - Updated roadmap.md Phase 2 Week 4-5 with completion status - 302 tests passing (up from 82 in Phase 1)
This commit is contained in:
parent
e6b97fcbf0
commit
38bb2629e9
404
web/package-lock.json
generated
404
web/package-lock.json
generated
@ -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",
|
||||
|
||||
319
web/src/app/history/page.tsx
Normal file
319
web/src/app/history/page.tsx
Normal file
@ -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<string | ''>('');
|
||||
const [filterUrgency, setFilterUrgency] = useState<string | ''>('');
|
||||
const [importStatus, setImportStatus] = useState<string | null>(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<HTMLInputElement>) => {
|
||||
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<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
||||
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-sm mb-6"
|
||||
style={{ color: 'var(--cm-accent)' }}
|
||||
>
|
||||
<ArrowLeft size={16} /> Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
History & Stats
|
||||
</h1>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
||||
{[
|
||||
{ key: 'stats' as const, label: 'Statistics' },
|
||||
{ key: 'history' as const, label: 'History' },
|
||||
{ key: 'import' as const, label: 'Import / Export' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
className="px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
|
||||
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Stats tab */}
|
||||
{tab === 'stats' && (
|
||||
<div className="space-y-6">
|
||||
<StreakCard streak={streak} />
|
||||
<StatsView />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History tab */}
|
||||
{tab === 'history' && (
|
||||
<div className="space-y-4">
|
||||
{/* Search + filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search
|
||||
size={16}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2"
|
||||
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={filterCategory}
|
||||
onChange={(e) => setFilterCategory(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--cm-surface-card)',
|
||||
borderColor: 'var(--cm-border)',
|
||||
color: 'var(--cm-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterUrgency}
|
||||
onChange={(e) => setFilterUrgency(e.target.value)}
|
||||
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--cm-surface-card)',
|
||||
borderColor: 'var(--cm-border)',
|
||||
color: 'var(--cm-text-secondary)',
|
||||
}}
|
||||
>
|
||||
<option value="">All Urgency</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="important">Important</option>
|
||||
<option value="standard">Standard</option>
|
||||
<option value="gentle">Gentle</option>
|
||||
<option value="passive">Passive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timer list */}
|
||||
{completedTimers.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history
|
||||
</p>
|
||||
{completedTimers.map((timer) => (
|
||||
<TimerCard key={timer.id} timer={timer} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Filter size={48} className="mx-auto mb-3 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
|
||||
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{search || filterCategory || filterUrgency
|
||||
? 'No timers match your filters.'
|
||||
: 'No completed timers yet.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import/Export tab */}
|
||||
{tab === 'import' && (
|
||||
<div className="space-y-6">
|
||||
{/* Warning */}
|
||||
<div
|
||||
className="rounded-xl border p-4 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
borderColor: 'rgba(245, 158, 11, 0.3)',
|
||||
color: 'var(--cm-warning)',
|
||||
}}
|
||||
>
|
||||
Your timers are stored locally in your browser. Export regularly to back up your data.
|
||||
Cloud sync is coming in a future version.
|
||||
</div>
|
||||
|
||||
{/* Export */}
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
<Download size={16} /> Export Timers
|
||||
</h3>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Download all {timers.length} timers as a JSON file.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={timers.length === 0}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
|
||||
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||
>
|
||||
Export JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Import JSON */}
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
<Upload size={16} /> Import Timers (JSON)
|
||||
</h3>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Restore timers from a previously exported ChronoMind JSON file.
|
||||
</p>
|
||||
<label
|
||||
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||
>
|
||||
Choose File
|
||||
<input type="file" accept=".json" onChange={handleJsonImport} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import .ics */}
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
<Calendar size={16} /> Import Calendar (.ics)
|
||||
</h3>
|
||||
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Import events from a .ics file (Google Calendar, Outlook, Apple Calendar exports).
|
||||
Events become alarms with auto-generated pre-warning cascades.
|
||||
</p>
|
||||
<label
|
||||
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||
>
|
||||
Choose .ics File
|
||||
<input type="file" accept=".ics,.ical" onChange={handleIcsImport} className="hidden" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Import status */}
|
||||
{importStatus && (
|
||||
<div
|
||||
className="rounded-xl border p-3 text-sm"
|
||||
style={{
|
||||
backgroundColor: 'var(--cm-surface-card)',
|
||||
borderColor: 'var(--cm-border)',
|
||||
color: 'var(--cm-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{importStatus}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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<UrgencyLevel>('standard');
|
||||
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('standard');
|
||||
const [category, setCategory] = useState<string>('');
|
||||
|
||||
// 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) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category */}
|
||||
{tab !== 'pomodoro' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||
Category
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
onClick={() => handleCategoryChange('')}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: !category ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||
color: !category ? '#fff' : 'var(--cm-text-tertiary)',
|
||||
border: !category ? '1px solid var(--cm-accent)' : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{BUILT_IN_CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => handleCategoryChange(cat.id)}
|
||||
className="px-2.5 py-1 rounded-lg text-xs font-medium transition-all cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: category === cat.id ? `${cat.color}20` : 'var(--cm-surface-muted)',
|
||||
color: category === cat.id ? cat.color : 'var(--cm-text-tertiary)',
|
||||
border: category === cat.id ? `1px solid ${cat.color}60` : '1px solid transparent',
|
||||
}}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Urgency (non-pomodoro) */}
|
||||
{tab !== 'pomodoro' && (
|
||||
<div>
|
||||
|
||||
265
web/src/components/StatsView.tsx
Normal file
265
web/src/components/StatsView.tsx
Normal file
@ -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<TimeRange, string> = {
|
||||
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<TimeRange>('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 (
|
||||
<div className="space-y-6">
|
||||
{/* Range selector */}
|
||||
<div className="flex gap-1">
|
||||
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => setRange(r)}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: range === r ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||
color: range === r ? '#fff' : 'var(--cm-text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{RANGE_LABELS[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard icon={<Zap size={16} />} label="Created" value={stats.created} color="var(--cm-accent)" />
|
||||
<StatCard icon={<Target size={16} />} label="Completed" value={stats.completed} color="var(--cm-gentle)" />
|
||||
<StatCard
|
||||
icon={<TrendingUp size={16} />}
|
||||
label="On-Time Rate"
|
||||
value={`${Math.round(stats.onTimeRate * 100)}%`}
|
||||
color="var(--cm-standard)"
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Clock size={16} />}
|
||||
label="Focus Time"
|
||||
value={stats.focusMinutes > 60 ? `${(stats.focusMinutes / 60).toFixed(1)}h` : `${stats.focusMinutes}m`}
|
||||
color="var(--cm-accent-secondary)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Timer activity chart */}
|
||||
{chartData.length > 1 && (
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
Timer Activity
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--cm-border)" />
|
||||
<XAxis dataKey="date" stroke="var(--cm-text-tertiary)" fontSize={11} />
|
||||
<YAxis stroke="var(--cm-text-tertiary)" fontSize={11} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--cm-bg-elevated)',
|
||||
border: '1px solid var(--cm-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--cm-text-primary)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="Created" fill="var(--cm-accent)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="Completed" fill="var(--cm-gentle)" radius={[4, 4, 0, 0]} />
|
||||
<Bar dataKey="Dismissed" fill="var(--cm-text-tertiary)" radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Focus time trend */}
|
||||
{focusData.some((d) => d.Minutes > 0) && (
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
Focus Time (minutes)
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={160}>
|
||||
<LineChart data={focusData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--cm-border)" />
|
||||
<XAxis dataKey="date" stroke="var(--cm-text-tertiary)" fontSize={11} />
|
||||
<YAxis stroke="var(--cm-text-tertiary)" fontSize={11} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'var(--cm-bg-elevated)',
|
||||
border: '1px solid var(--cm-border)',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--cm-text-primary)',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="Minutes"
|
||||
stroke="var(--cm-accent-secondary)"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: 'var(--cm-accent-secondary)', r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Category breakdown */}
|
||||
{categoryPieData.length > 0 && (
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
By Category
|
||||
</h3>
|
||||
<div className="flex items-center gap-4">
|
||||
<ResponsiveContainer width={120} height={120}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={categoryPieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={30}
|
||||
outerRadius={55}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
{categoryPieData.map((entry, idx) => (
|
||||
<Cell key={idx} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="flex-1 space-y-1.5">
|
||||
{categoryPieData.map((entry) => (
|
||||
<div key={entry.name} className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||
<span className="text-xs" style={{ color: 'var(--cm-text-secondary)' }}>{entry.name}</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extra stats */}
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<DetailRow label="Dismissed" value={String(stats.dismissed)} />
|
||||
<DetailRow label="Snoozed" value={String(stats.snoozed)} />
|
||||
<DetailRow label="Avg Snoozes" value={stats.averageSnoozeCount.toFixed(1)} />
|
||||
<DetailRow label="Active" value={String(stats.active)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-3"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-1" style={{ color }}>
|
||||
{icon}
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ color: 'var(--cm-text-tertiary)' }}>{label}</span>
|
||||
<span className="font-mono font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
web/src/components/StreakCard.tsx
Normal file
96
web/src/components/StreakCard.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="rounded-xl border p-4"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
<Flame size={16} style={{ color: currentStreak > 0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }} />
|
||||
Streak
|
||||
</h3>
|
||||
{streakFreezeUsed && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: 'rgba(90, 140, 255, 0.15)', color: 'var(--cm-accent)' }}
|
||||
>
|
||||
<Shield size={12} /> Freeze used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-6">
|
||||
{/* Current streak */}
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="text-3xl font-bold font-mono"
|
||||
style={{ color: currentStreak > 0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }}
|
||||
>
|
||||
{currentStreak}
|
||||
</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{currentStreak === 1 ? 'day' : 'days'} current
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Longest streak */}
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Trophy size={14} style={{ color: 'var(--cm-standard)' }} />
|
||||
<span className="text-lg font-bold font-mono" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||
{longestStreak}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
best
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Freeze status */}
|
||||
{streakFreezeAvailable && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Shield size={14} style={{ color: 'var(--cm-accent)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--cm-accent)' }}>
|
||||
1 freeze
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
available
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Streak milestones */}
|
||||
{currentStreak >= 7 && (
|
||||
<div
|
||||
className="mt-3 px-3 py-1.5 rounded-lg text-xs font-medium text-center"
|
||||
style={{
|
||||
backgroundColor: currentStreak >= 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!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
363
web/src/lib/calendar-import.test.ts
Normal file
363
web/src/lib/calendar-import.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
267
web/src/lib/calendar-import.ts
Normal file
267
web/src/lib/calendar-import.ts
Normal file
@ -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<IcsEvent> = {};
|
||||
|
||||
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<UrgencyLevel, CascadeConfig> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
253
web/src/lib/categories.test.ts
Normal file
253
web/src/lib/categories.test.ts
Normal file
@ -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<string, string> = {};
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
202
web/src/lib/categories.ts
Normal file
202
web/src/lib/categories.ts
Normal file
@ -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, 'isBuiltIn'>): 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));
|
||||
}
|
||||
127
web/src/lib/export.ts
Normal file
127
web/src/lib/export.ts
Normal file
@ -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<string> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
352
web/src/lib/recurrence.test.ts
Normal file
352
web/src/lib/recurrence.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
284
web/src/lib/recurrence.ts
Normal file
284
web/src/lib/recurrence.ts
Normal file
@ -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<RecurrenceFrequency, string> = {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
343
web/src/lib/stats.test.ts
Normal file
343
web/src/lib/stats.test.ts
Normal file
@ -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<string, string> = {};
|
||||
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> = {}): 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');
|
||||
});
|
||||
});
|
||||
350
web/src/lib/stats.ts
Normal file
350
web/src/lib/stats.ts
Normal file
@ -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<string>();
|
||||
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<string, number> = {};
|
||||
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<string, Timer[]> = {};
|
||||
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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user