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:
saravanakumardb1 2026-02-27 21:59:09 -08:00
parent e6b97fcbf0
commit 38bb2629e9
14 changed files with 3676 additions and 3 deletions

404
web/package-lock.json generated
View File

@ -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",

View 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>
);
}

View File

@ -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>

View 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>
);
}

View 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>
);
}

View 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);
});
});

View 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,
};
}

View 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
View 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
View 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);
});
}

View 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
View 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 (MonFri)',
weekend: 'Weekends (SatSun)',
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
View 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
View 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);
}