feat(web): Phase 2 — stats, categories, recurring, export/import, calendar .ics
- Statistics + streaks engine (lib/stats.ts) with daily/weekly/monthly breakdowns, on-time rate, focus time, streak tracking (23 tests) - Categories/tags system (lib/categories.ts) with 6 built-in categories, custom tags, default urgency+cascade per category (29 tests) - Recurring timer engine (lib/recurrence.ts) with daily/weekday/weekend/weekly/biweekly/monthly/custom rules, skip/pause, DST edge cases (37 tests) - Timer export/import as JSON (lib/export.ts) - Calendar .ics import (lib/calendar-import.ts) with RFC 5545 parsing, conflict detection, priority-to-urgency mapping (26 tests) - StatsView component with Recharts (bar, line, pie charts) - StreakCard component with milestone badges - History page (/history) with stats, history search/filter, import/export - Category picker in CreateTimerModal with auto urgency+cascade defaults - Category filter chips on Dashboard + History link in header - Installed recharts dependency - Updated roadmap.md Phase 2 Week 4-5 with completion status - 302 tests passing (up from 82 in Phase 1)
This commit is contained in:
parent
e6b97fcbf0
commit
38bb2629e9
404
web/package-lock.json
generated
404
web/package-lock.json
generated
@ -15,6 +15,7 @@
|
|||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"serwist": "^9.5.6",
|
"serwist": "^9.5.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@ -1956,6 +1957,42 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.59.0",
|
"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",
|
"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",
|
"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",
|
"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==",
|
"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"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
@ -2845,6 +2887,69 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"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": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"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",
|
"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==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/uuid": {
|
||||||
"version": "10.0.0",
|
"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",
|
"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==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"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",
|
"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,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"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",
|
"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,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"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",
|
"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"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.3",
|
"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",
|
"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": ">=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": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"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",
|
"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": ">= 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": {
|
"node_modules/import-fresh": {
|
||||||
"version": "3.3.1",
|
"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",
|
"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": ">= 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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"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",
|
"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",
|
"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==",
|
"integrity": "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.1.6",
|
"@next/env": "16.1.6",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@ -7516,8 +7800,62 @@
|
|||||||
"version": "16.13.1",
|
"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",
|
"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==",
|
"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": {
|
"node_modules/redent": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
@ -7533,6 +7871,22 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"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",
|
"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": ">=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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"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",
|
"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"
|
"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": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"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",
|
"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"
|
"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": {
|
"node_modules/uuid": {
|
||||||
"version": "13.0.0",
|
"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",
|
"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"
|
"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": {
|
"node_modules/vite": {
|
||||||
"version": "7.3.1",
|
"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",
|
"resolved": "https://jfrog-pkg-proxy.it.att.com/artifactory/api/npm/att-npm-proxy-group/vite/-/vite-7.3.1.tgz",
|
||||||
|
|||||||
319
web/src/app/history/page.tsx
Normal file
319
web/src/app/history/page.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useTimerStore } from '@/lib/store';
|
||||||
|
import { computeStreak } from '@/lib/stats';
|
||||||
|
import { StatsView } from '@/components/StatsView';
|
||||||
|
import { StreakCard } from '@/components/StreakCard';
|
||||||
|
import { TimerCard } from '@/components/TimerCard';
|
||||||
|
import { downloadExport, readFileAsText, parseImportData, importTimers } from '@/lib/export';
|
||||||
|
import { importCalendar } from '@/lib/calendar-import';
|
||||||
|
import { ArrowLeft, Download, Upload, Calendar, Search, Filter } from 'lucide-react';
|
||||||
|
import { getCategoryById, getAllCategories } from '@/lib/categories';
|
||||||
|
|
||||||
|
export default function HistoryPage() {
|
||||||
|
const timers = useTimerStore((s) => s.timers);
|
||||||
|
const now = useTimerStore((s) => s.now);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [filterCategory, setFilterCategory] = useState<string | ''>('');
|
||||||
|
const [filterUrgency, setFilterUrgency] = useState<string | ''>('');
|
||||||
|
const [importStatus, setImportStatus] = useState<string | null>(null);
|
||||||
|
const [tab, setTab] = useState<'stats' | 'history' | 'import'>('stats');
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
const streak = useMemo(() => computeStreak(timers, now), [timers, now]);
|
||||||
|
|
||||||
|
const completedTimers = useMemo(() => {
|
||||||
|
return timers
|
||||||
|
.filter((t) => ['dismissed', 'completed'].includes(t.state))
|
||||||
|
.filter((t) => {
|
||||||
|
if (search) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
if (!t.label.toLowerCase().includes(q) && !t.description?.toLowerCase().includes(q)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filterCategory && t.category !== filterCategory) return false;
|
||||||
|
if (filterUrgency && t.urgency !== filterUrgency) return false;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (b.completedAt ?? b.dismissedAt ?? b.createdAt) - (a.completedAt ?? a.dismissedAt ?? a.createdAt));
|
||||||
|
}, [timers, search, filterCategory, filterUrgency]);
|
||||||
|
|
||||||
|
const categories = useMemo(() => getAllCategories(), []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
const handleExport = () => downloadExport(timers);
|
||||||
|
|
||||||
|
const handleJsonImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await readFileAsText(file);
|
||||||
|
const data = parseImportData(text);
|
||||||
|
if (!data) {
|
||||||
|
setImportStatus('Invalid file format. Expected a ChronoMind export JSON.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = importTimers(data, timers);
|
||||||
|
// Add the imported timers to the store
|
||||||
|
const existingIds = new Set(timers.map((t) => t.id));
|
||||||
|
const newTimers = data.timers.filter((t) => !existingIds.has(t.id));
|
||||||
|
if (newTimers.length > 0) {
|
||||||
|
useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] }));
|
||||||
|
}
|
||||||
|
setImportStatus(`Imported ${result.imported} timers. ${result.skipped} skipped.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`);
|
||||||
|
} catch {
|
||||||
|
setImportStatus('Failed to read file.');
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleIcsImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const text = await readFileAsText(file);
|
||||||
|
const result = importCalendar(text, timers);
|
||||||
|
if (result.events.length === 0) {
|
||||||
|
setImportStatus(`No events found.${result.errors.length > 0 ? ` Errors: ${result.errors.join(', ')}` : ''}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newTimers = result.events.map((e) => e.timer);
|
||||||
|
useTimerStore.setState((s) => ({ timers: [...s.timers, ...newTimers] }));
|
||||||
|
const conflictCount = result.events.filter((e) => e.conflicts.length > 0).length;
|
||||||
|
setImportStatus(`Imported ${result.events.length} calendar events.${conflictCount > 0 ? ` ${conflictCount} have conflicts with existing timers.` : ''}`);
|
||||||
|
} catch {
|
||||||
|
setImportStatus('Failed to parse .ics file.');
|
||||||
|
}
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
||||||
|
<div className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm mb-6"
|
||||||
|
style={{ color: 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} /> Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold mb-6" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
History & Stats
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex gap-1 mb-6 border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
||||||
|
{[
|
||||||
|
{ key: 'stats' as const, label: 'Statistics' },
|
||||||
|
{ key: 'history' as const, label: 'History' },
|
||||||
|
{ key: 'import' as const, label: 'Import / Export' },
|
||||||
|
].map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.key}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
className="px-4 py-2.5 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
color: tab === t.key ? 'var(--cm-accent)' : 'var(--cm-text-tertiary)',
|
||||||
|
borderBottom: tab === t.key ? '2px solid var(--cm-accent)' : '2px solid transparent',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats tab */}
|
||||||
|
{tab === 'stats' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<StreakCard streak={streak} />
|
||||||
|
<StatsView />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* History tab */}
|
||||||
|
{tab === 'history' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Search + filters */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search
|
||||||
|
size={16}
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2"
|
||||||
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search timers..."
|
||||||
|
className="w-full pl-9 pr-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
color: 'var(--cm-text-primary)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
value={filterCategory}
|
||||||
|
onChange={(e) => setFilterCategory(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
color: 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Categories</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={filterUrgency}
|
||||||
|
onChange={(e) => setFilterUrgency(e.target.value)}
|
||||||
|
className="px-3 py-2 rounded-lg border text-xs cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
color: 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Urgency</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
<option value="important">Important</option>
|
||||||
|
<option value="standard">Standard</option>
|
||||||
|
<option value="gentle">Gentle</option>
|
||||||
|
<option value="passive">Passive</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer list */}
|
||||||
|
{completedTimers.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{completedTimers.length} timer{completedTimers.length !== 1 ? 's' : ''} in history
|
||||||
|
</p>
|
||||||
|
{completedTimers.map((timer) => (
|
||||||
|
<TimerCard key={timer.id} timer={timer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Filter size={48} className="mx-auto mb-3 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
|
||||||
|
<p className="text-sm" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{search || filterCategory || filterUrgency
|
||||||
|
? 'No timers match your filters.'
|
||||||
|
: 'No completed timers yet.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Import/Export tab */}
|
||||||
|
{tab === 'import' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Warning */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||||
|
borderColor: 'rgba(245, 158, 11, 0.3)',
|
||||||
|
color: 'var(--cm-warning)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Your timers are stored locally in your browser. Export regularly to back up your data.
|
||||||
|
Cloud sync is coming in a future version.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Download size={16} /> Export Timers
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Download all {timers.length} timers as a JSON file.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={timers.length === 0}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm font-medium cursor-pointer disabled:opacity-30"
|
||||||
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
Export JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import JSON */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Upload size={16} /> Import Timers (JSON)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Restore timers from a previously exported ChronoMind JSON file.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Choose File
|
||||||
|
<input type="file" accept=".json" onChange={handleJsonImport} className="hidden" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import .ics */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-2 flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Calendar size={16} /> Import Calendar (.ics)
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
Import events from a .ics file (Google Calendar, Outlook, Apple Calendar exports).
|
||||||
|
Events become alarms with auto-generated pre-warning cascades.
|
||||||
|
</p>
|
||||||
|
<label
|
||||||
|
className="inline-block px-4 py-2 rounded-lg text-sm font-medium cursor-pointer"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||||
|
>
|
||||||
|
Choose .ics File
|
||||||
|
<input type="file" accept=".ics,.ical" onChange={handleIcsImport} className="hidden" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Import status */}
|
||||||
|
{importStatus && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--cm-surface-card)',
|
||||||
|
borderColor: 'var(--cm-border)',
|
||||||
|
color: 'var(--cm-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{importStatus}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ import type { UrgencyLevel } from '@/lib/urgency';
|
|||||||
import { CASCADE_PRESET_LABELS } from '@/lib/cascade';
|
import { CASCADE_PRESET_LABELS } from '@/lib/cascade';
|
||||||
import type { CascadePreset } from '@/lib/cascade';
|
import type { CascadePreset } from '@/lib/cascade';
|
||||||
import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react';
|
import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react';
|
||||||
|
import { BUILT_IN_CATEGORIES, getCategoryById } from '@/lib/categories';
|
||||||
import { parseNaturalLanguage } from '@/lib/nl-parser';
|
import { parseNaturalLanguage } from '@/lib/nl-parser';
|
||||||
import type { ParseResult } 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 [label, setLabel] = useState('');
|
||||||
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
|
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
|
||||||
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('standard');
|
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('standard');
|
||||||
|
const [category, setCategory] = useState<string>('');
|
||||||
|
|
||||||
// Alarm fields
|
// Alarm fields
|
||||||
const [alarmTime, setAlarmTime] = useState('');
|
const [alarmTime, setAlarmTime] = useState('');
|
||||||
@ -82,8 +84,21 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
onClose();
|
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 handleCreate = () => {
|
||||||
const cascade = { preset: cascadePreset, intervals: [] as number[] };
|
const cascade = { preset: cascadePreset, intervals: [] as number[] };
|
||||||
|
const catOrUndef = category || undefined;
|
||||||
|
|
||||||
if (tab === 'alarm') {
|
if (tab === 'alarm') {
|
||||||
if (!alarmTime) return;
|
if (!alarmTime) return;
|
||||||
@ -98,6 +113,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
targetTime: target.getTime(),
|
targetTime: target.getTime(),
|
||||||
urgency,
|
urgency,
|
||||||
cascade,
|
cascade,
|
||||||
|
category: catOrUndef,
|
||||||
});
|
});
|
||||||
} else if (tab === 'countdown') {
|
} else if (tab === 'countdown') {
|
||||||
const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
|
const durationMs = (hours * 3600 + minutes * 60 + seconds) * 1000;
|
||||||
@ -107,6 +123,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
durationMs,
|
durationMs,
|
||||||
urgency,
|
urgency,
|
||||||
cascade,
|
cascade,
|
||||||
|
category: catOrUndef,
|
||||||
});
|
});
|
||||||
} else if (tab === 'pomodoro') {
|
} else if (tab === 'pomodoro') {
|
||||||
addPomodoro({
|
addPomodoro({
|
||||||
@ -127,6 +144,7 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
setHours(0);
|
setHours(0);
|
||||||
setMinutes(25);
|
setMinutes(25);
|
||||||
setSeconds(0);
|
setSeconds(0);
|
||||||
|
setCategory('');
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -355,6 +373,42 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
|||||||
</div>
|
</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) */}
|
{/* Urgency (non-pomodoro) */}
|
||||||
{tab !== 'pomodoro' && (
|
{tab !== 'pomodoro' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
265
web/src/components/StatsView.tsx
Normal file
265
web/src/components/StatsView.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useTimerStore } from '@/lib/store';
|
||||||
|
import {
|
||||||
|
computeStats,
|
||||||
|
computeDailyStats,
|
||||||
|
computeCategoryBreakdown,
|
||||||
|
type TimeRange,
|
||||||
|
} from '@/lib/stats';
|
||||||
|
import { getCategoryById } from '@/lib/categories';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||||
|
ResponsiveContainer, PieChart, Pie, Cell,
|
||||||
|
} from 'recharts';
|
||||||
|
import { TrendingUp, Target, Clock, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
const RANGE_LABELS: Record<TimeRange, string> = {
|
||||||
|
daily: 'Today',
|
||||||
|
weekly: '7 Days',
|
||||||
|
monthly: '30 Days',
|
||||||
|
all: 'All Time',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatsView() {
|
||||||
|
const timers = useTimerStore((s) => s.timers);
|
||||||
|
const now = useTimerStore((s) => s.now);
|
||||||
|
const [range, setRange] = useState<TimeRange>('weekly');
|
||||||
|
|
||||||
|
const stats = useMemo(() => computeStats(timers, range, now), [timers, range, now]);
|
||||||
|
const dailyData = useMemo(
|
||||||
|
() => computeDailyStats(timers, range === 'daily' ? 1 : range === 'monthly' ? 30 : 7, now),
|
||||||
|
[timers, range, now]
|
||||||
|
);
|
||||||
|
const categoryBreakdown = useMemo(
|
||||||
|
() => computeCategoryBreakdown(timers, range, now),
|
||||||
|
[timers, range, now]
|
||||||
|
);
|
||||||
|
|
||||||
|
const chartData = dailyData.map((d) => ({
|
||||||
|
date: d.date.slice(5), // MM-DD
|
||||||
|
Created: d.created,
|
||||||
|
Completed: d.completed,
|
||||||
|
Dismissed: d.dismissed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const focusData = dailyData.map((d) => ({
|
||||||
|
date: d.date.slice(5),
|
||||||
|
Minutes: d.focusMinutes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categoryPieData = categoryBreakdown.map((c) => {
|
||||||
|
const cat = getCategoryById(c.categoryId);
|
||||||
|
return {
|
||||||
|
name: cat?.label ?? c.categoryId,
|
||||||
|
value: c.count,
|
||||||
|
color: cat?.color ?? '#5A8CFF',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Range selector */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(Object.keys(RANGE_LABELS) as TimeRange[]).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
onClick={() => setRange(r)}
|
||||||
|
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||||
|
style={{
|
||||||
|
backgroundColor: range === r ? 'var(--cm-accent)' : 'var(--cm-surface-muted)',
|
||||||
|
color: range === r ? '#fff' : 'var(--cm-text-tertiary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{RANGE_LABELS[r]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<StatCard icon={<Zap size={16} />} label="Created" value={stats.created} color="var(--cm-accent)" />
|
||||||
|
<StatCard icon={<Target size={16} />} label="Completed" value={stats.completed} color="var(--cm-gentle)" />
|
||||||
|
<StatCard
|
||||||
|
icon={<TrendingUp size={16} />}
|
||||||
|
label="On-Time Rate"
|
||||||
|
value={`${Math.round(stats.onTimeRate * 100)}%`}
|
||||||
|
color="var(--cm-standard)"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Clock size={16} />}
|
||||||
|
label="Focus Time"
|
||||||
|
value={stats.focusMinutes > 60 ? `${(stats.focusMinutes / 60).toFixed(1)}h` : `${stats.focusMinutes}m`}
|
||||||
|
color="var(--cm-accent-secondary)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timer activity chart */}
|
||||||
|
{chartData.length > 1 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Timer Activity
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={200}>
|
||||||
|
<BarChart data={chartData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--cm-border)" />
|
||||||
|
<XAxis dataKey="date" stroke="var(--cm-text-tertiary)" fontSize={11} />
|
||||||
|
<YAxis stroke="var(--cm-text-tertiary)" fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--cm-bg-elevated)',
|
||||||
|
border: '1px solid var(--cm-border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--cm-text-primary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey="Created" fill="var(--cm-accent)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Completed" fill="var(--cm-gentle)" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Dismissed" fill="var(--cm-text-tertiary)" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Focus time trend */}
|
||||||
|
{focusData.some((d) => d.Minutes > 0) && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Focus Time (minutes)
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={160}>
|
||||||
|
<LineChart data={focusData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--cm-border)" />
|
||||||
|
<XAxis dataKey="date" stroke="var(--cm-text-tertiary)" fontSize={11} />
|
||||||
|
<YAxis stroke="var(--cm-text-tertiary)" fontSize={11} allowDecimals={false} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--cm-bg-elevated)',
|
||||||
|
border: '1px solid var(--cm-border)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'var(--cm-text-primary)',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="Minutes"
|
||||||
|
stroke="var(--cm-accent-secondary)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ fill: 'var(--cm-accent-secondary)', r: 3 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category breakdown */}
|
||||||
|
{categoryPieData.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
By Category
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<ResponsiveContainer width={120} height={120}>
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={categoryPieData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={30}
|
||||||
|
outerRadius={55}
|
||||||
|
paddingAngle={2}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{categoryPieData.map((entry, idx) => (
|
||||||
|
<Cell key={idx} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="flex-1 space-y-1.5">
|
||||||
|
{categoryPieData.map((entry) => (
|
||||||
|
<div key={entry.name} className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full" style={{ backgroundColor: entry.color }} />
|
||||||
|
<span className="text-xs" style={{ color: 'var(--cm-text-secondary)' }}>{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{entry.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extra stats */}
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold mb-3" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
Details
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<DetailRow label="Dismissed" value={String(stats.dismissed)} />
|
||||||
|
<DetailRow label="Snoozed" value={String(stats.snoozed)} />
|
||||||
|
<DetailRow label="Avg Snoozes" value={stats.averageSnoozeCount.toFixed(1)} />
|
||||||
|
<DetailRow label="Active" value={String(stats.active)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
color: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-3"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-1" style={{ color }}>
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span style={{ color: 'var(--cm-text-tertiary)' }}>{label}</span>
|
||||||
|
<span className="font-mono font-medium" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
web/src/components/StreakCard.tsx
Normal file
96
web/src/components/StreakCard.tsx
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Flame, Trophy, Shield } from 'lucide-react';
|
||||||
|
import type { StreakInfo } from '@/lib/stats';
|
||||||
|
|
||||||
|
interface StreakCardProps {
|
||||||
|
streak: StreakInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StreakCard({ streak }: StreakCardProps) {
|
||||||
|
const { currentStreak, longestStreak, streakFreezeUsed, streakFreezeAvailable } = streak;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-4"
|
||||||
|
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-2" style={{ color: 'var(--cm-text-primary)' }}>
|
||||||
|
<Flame size={16} style={{ color: currentStreak > 0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }} />
|
||||||
|
Streak
|
||||||
|
</h3>
|
||||||
|
{streakFreezeUsed && (
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1 text-xs px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: 'rgba(90, 140, 255, 0.15)', color: 'var(--cm-accent)' }}
|
||||||
|
>
|
||||||
|
<Shield size={12} /> Freeze used
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-6">
|
||||||
|
{/* Current streak */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div
|
||||||
|
className="text-3xl font-bold font-mono"
|
||||||
|
style={{ color: currentStreak > 0 ? 'var(--cm-important)' : 'var(--cm-text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{currentStreak}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
{currentStreak === 1 ? 'day' : 'days'} current
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Longest streak */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Trophy size={14} style={{ color: 'var(--cm-standard)' }} />
|
||||||
|
<span className="text-lg font-bold font-mono" style={{ color: 'var(--cm-text-secondary)' }}>
|
||||||
|
{longestStreak}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
best
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Freeze status */}
|
||||||
|
{streakFreezeAvailable && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Shield size={14} style={{ color: 'var(--cm-accent)' }} />
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--cm-accent)' }}>
|
||||||
|
1 freeze
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||||
|
available
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Streak milestones */}
|
||||||
|
{currentStreak >= 7 && (
|
||||||
|
<div
|
||||||
|
className="mt-3 px-3 py-1.5 rounded-lg text-xs font-medium text-center"
|
||||||
|
style={{
|
||||||
|
backgroundColor: currentStreak >= 30
|
||||||
|
? 'rgba(255, 159, 67, 0.15)'
|
||||||
|
: 'rgba(46, 213, 115, 0.15)',
|
||||||
|
color: currentStreak >= 30 ? 'var(--cm-important)' : 'var(--cm-gentle)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStreak >= 100
|
||||||
|
? '🏆 100+ day streak! Legendary!'
|
||||||
|
: currentStreak >= 30
|
||||||
|
? '🔥 30+ day streak! On fire!'
|
||||||
|
: '✨ 7+ day streak! Keep it up!'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
web/src/lib/calendar-import.test.ts
Normal file
363
web/src/lib/calendar-import.test.ts
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
parseIcs,
|
||||||
|
mapPriorityToUrgency,
|
||||||
|
eventToTimer,
|
||||||
|
detectConflicts,
|
||||||
|
importCalendar,
|
||||||
|
} from './calendar-import';
|
||||||
|
import type { Timer } from './timer-engine';
|
||||||
|
|
||||||
|
// ── Fixtures ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const SIMPLE_ICS = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Test//Test//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:test-1@example.com
|
||||||
|
SUMMARY:Team Standup
|
||||||
|
DTSTART:20260315T090000
|
||||||
|
DTEND:20260315T093000
|
||||||
|
DESCRIPTION:Daily standup meeting
|
||||||
|
LOCATION:Room 42
|
||||||
|
PRIORITY:3
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const MULTI_EVENT_ICS = `BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:evt-1
|
||||||
|
SUMMARY:Morning Meeting
|
||||||
|
DTSTART:20260315T090000
|
||||||
|
DTEND:20260315T100000
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:evt-2
|
||||||
|
SUMMARY:Lunch
|
||||||
|
DTSTART:20260315T120000
|
||||||
|
DTEND:20260315T130000
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:evt-3
|
||||||
|
SUMMARY:Code Review
|
||||||
|
DTSTART:20260315T140000
|
||||||
|
DTEND:20260315T150000
|
||||||
|
PRIORITY:2
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const UTC_ICS = `BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:utc-1
|
||||||
|
SUMMARY:UTC Event
|
||||||
|
DTSTART:20260315T140000Z
|
||||||
|
DTEND:20260315T150000Z
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const DATE_ONLY_ICS = `BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:date-1
|
||||||
|
SUMMARY:All Day Event
|
||||||
|
DTSTART:20260315
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const ESCAPED_ICS = `BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:esc-1
|
||||||
|
SUMMARY:Meeting\\, Important
|
||||||
|
DESCRIPTION:Details:\\nLine 2\\nLine 3
|
||||||
|
DTSTART:20260315T090000
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const FOLDED_ICS = `BEGIN:VCALENDAR\r
|
||||||
|
BEGIN:VEVENT\r
|
||||||
|
UID:fold-1\r
|
||||||
|
SUMMARY:A very long summary that gets\r
|
||||||
|
folded across multiple lines\r
|
||||||
|
DTSTART:20260315T090000\r
|
||||||
|
END:VEVENT\r
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
const INVALID_ICS = `BEGIN:VCALENDAR
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:bad-1
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR`;
|
||||||
|
|
||||||
|
// ── Tests ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('parseIcs', () => {
|
||||||
|
it('parses a simple .ics file', () => {
|
||||||
|
const { events, errors } = parseIcs(SIMPLE_ICS);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
const evt = events[0];
|
||||||
|
expect(evt.uid).toBe('test-1@example.com');
|
||||||
|
expect(evt.summary).toBe('Team Standup');
|
||||||
|
expect(evt.description).toBe('Daily standup meeting');
|
||||||
|
expect(evt.location).toBe('Room 42');
|
||||||
|
expect(evt.priority).toBe(3);
|
||||||
|
expect(evt.dtstart.getFullYear()).toBe(2026);
|
||||||
|
expect(evt.dtstart.getMonth()).toBe(2); // March = 2 (0-indexed)
|
||||||
|
expect(evt.dtstart.getDate()).toBe(15);
|
||||||
|
expect(evt.dtstart.getHours()).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses multiple events', () => {
|
||||||
|
const { events, errors } = parseIcs(MULTI_EVENT_ICS);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
expect(events).toHaveLength(3);
|
||||||
|
expect(events[0].summary).toBe('Morning Meeting');
|
||||||
|
expect(events[1].summary).toBe('Lunch');
|
||||||
|
expect(events[2].summary).toBe('Code Review');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses UTC datetime', () => {
|
||||||
|
const { events } = parseIcs(UTC_ICS);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
// UTC time: should be converted properly
|
||||||
|
expect(events[0].dtstart.getUTCHours()).toBe(14);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses date-only events', () => {
|
||||||
|
const { events } = parseIcs(DATE_ONLY_ICS);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].dtstart.getFullYear()).toBe(2026);
|
||||||
|
expect(events[0].dtstart.getMonth()).toBe(2);
|
||||||
|
expect(events[0].dtstart.getDate()).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unescapes special characters', () => {
|
||||||
|
const { events } = parseIcs(ESCAPED_ICS);
|
||||||
|
expect(events[0].summary).toBe('Meeting, Important');
|
||||||
|
expect(events[0].description).toBe('Details:\nLine 2\nLine 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unfolds continuation lines', () => {
|
||||||
|
const { events } = parseIcs(FOLDED_ICS);
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].summary).toBe('A very long summary that gets folded across multiple lines');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports errors for events missing required fields', () => {
|
||||||
|
const { events, errors } = parseIcs(INVALID_ICS);
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0]).toContain('missing required fields');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty content', () => {
|
||||||
|
const { events, errors } = parseIcs('');
|
||||||
|
expect(events).toHaveLength(0);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('mapPriorityToUrgency', () => {
|
||||||
|
it('maps priority 1 to critical', () => {
|
||||||
|
expect(mapPriorityToUrgency(1)).toBe('critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps priority 2 to critical', () => {
|
||||||
|
expect(mapPriorityToUrgency(2)).toBe('critical');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps priority 3 to important', () => {
|
||||||
|
expect(mapPriorityToUrgency(3)).toBe('important');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps priority 5 to standard', () => {
|
||||||
|
expect(mapPriorityToUrgency(5)).toBe('standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps priority 7 to gentle', () => {
|
||||||
|
expect(mapPriorityToUrgency(7)).toBe('gentle');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps priority 9 to passive', () => {
|
||||||
|
expect(mapPriorityToUrgency(9)).toBe('passive');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to standard for undefined priority', () => {
|
||||||
|
expect(mapPriorityToUrgency(undefined)).toBe('standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to standard for out-of-range priority', () => {
|
||||||
|
expect(mapPriorityToUrgency(0)).toBe('standard');
|
||||||
|
expect(mapPriorityToUrgency(10)).toBe('standard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('eventToTimer', () => {
|
||||||
|
it('converts an event to a timer', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'test-1',
|
||||||
|
summary: 'Meeting',
|
||||||
|
dtstart: new Date(Date.now() + 3_600_000),
|
||||||
|
description: 'Important meeting',
|
||||||
|
priority: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = eventToTimer(event);
|
||||||
|
expect(timer.type).toBe('alarm');
|
||||||
|
expect(timer.label).toBe('Meeting');
|
||||||
|
expect(timer.urgency).toBe('important');
|
||||||
|
expect(timer.state).toBe('active');
|
||||||
|
expect(timer.cascade.preset).toBe('standard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks past events as dismissed', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'past-1',
|
||||||
|
summary: 'Past Event',
|
||||||
|
dtstart: new Date(Date.now() - 3_600_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = eventToTimer(event);
|
||||||
|
expect(timer.state).toBe('dismissed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes location in description', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'loc-1',
|
||||||
|
summary: 'Meeting',
|
||||||
|
dtstart: new Date(Date.now() + 3_600_000),
|
||||||
|
location: 'Room 42',
|
||||||
|
description: 'Notes here',
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = eventToTimer(event);
|
||||||
|
expect(timer.description).toContain('Room 42');
|
||||||
|
expect(timer.description).toContain('Notes here');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectConflicts', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
function makeExistingTimer(targetOffset: number): Timer {
|
||||||
|
return {
|
||||||
|
id: `existing-${Math.random()}`,
|
||||||
|
type: 'alarm',
|
||||||
|
label: 'Existing',
|
||||||
|
urgency: 'standard',
|
||||||
|
state: 'active',
|
||||||
|
targetTime: now + targetOffset,
|
||||||
|
duration: null,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: now,
|
||||||
|
pausedAt: null,
|
||||||
|
firedAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
elapsedBeforePause: 0,
|
||||||
|
cascade: { preset: 'none', intervals: [] },
|
||||||
|
warnings: [],
|
||||||
|
snoozeCount: 0,
|
||||||
|
snoozedUntil: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('detects overlapping timers', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'conflict-1',
|
||||||
|
summary: 'New Event',
|
||||||
|
dtstart: new Date(now + 3_600_000),
|
||||||
|
dtend: new Date(now + 7_200_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = [
|
||||||
|
makeExistingTimer(3_600_000 + 5 * 60_000), // 5 min after event start
|
||||||
|
];
|
||||||
|
|
||||||
|
const conflicts = detectConflicts(event, existing);
|
||||||
|
expect(conflicts).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag non-overlapping timers', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'no-conflict',
|
||||||
|
summary: 'New Event',
|
||||||
|
dtstart: new Date(now + 3_600_000),
|
||||||
|
dtend: new Date(now + 7_200_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = [
|
||||||
|
makeExistingTimer(24 * 3_600_000), // 24 hours later
|
||||||
|
];
|
||||||
|
|
||||||
|
const conflicts = detectConflicts(event, existing);
|
||||||
|
expect(conflicts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores dismissed timers', () => {
|
||||||
|
const event = {
|
||||||
|
uid: 'dismissed-test',
|
||||||
|
summary: 'New Event',
|
||||||
|
dtstart: new Date(now + 3_600_000),
|
||||||
|
dtend: new Date(now + 7_200_000),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dismissed = makeExistingTimer(3_600_000 + 60_000);
|
||||||
|
dismissed.state = 'dismissed';
|
||||||
|
|
||||||
|
const conflicts = detectConflicts(event, [dismissed]);
|
||||||
|
expect(conflicts).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('importCalendar', () => {
|
||||||
|
it('imports a full .ics file', () => {
|
||||||
|
const result = importCalendar(SIMPLE_ICS, [], Date.now());
|
||||||
|
expect(result.totalParsed).toBe(1);
|
||||||
|
expect(result.events).toHaveLength(1);
|
||||||
|
expect(result.events[0].timer.label).toBe('Team Standup');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('imports multiple events', () => {
|
||||||
|
const result = importCalendar(MULTI_EVENT_ICS, []);
|
||||||
|
expect(result.totalParsed).toBe(3);
|
||||||
|
expect(result.events).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports parse errors', () => {
|
||||||
|
const result = importCalendar(INVALID_ICS, []);
|
||||||
|
expect(result.errors.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects conflicts with existing timers', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
// Create an existing timer at March 15, 2026 9:15 AM
|
||||||
|
const target = new Date(2026, 2, 15, 9, 15, 0).getTime();
|
||||||
|
const existing: Timer[] = [{
|
||||||
|
id: 'conflict-timer',
|
||||||
|
type: 'alarm',
|
||||||
|
label: 'Conflicting',
|
||||||
|
urgency: 'standard',
|
||||||
|
state: 'active',
|
||||||
|
targetTime: target,
|
||||||
|
duration: null,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: now,
|
||||||
|
pausedAt: null,
|
||||||
|
firedAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
elapsedBeforePause: 0,
|
||||||
|
cascade: { preset: 'none', intervals: [] },
|
||||||
|
warnings: [],
|
||||||
|
snoozeCount: 0,
|
||||||
|
snoozedUntil: null,
|
||||||
|
}];
|
||||||
|
|
||||||
|
const result = importCalendar(SIMPLE_ICS, existing, now);
|
||||||
|
// The "Team Standup" event is at 9:00 AM March 15 — the existing timer at 9:15 is within window
|
||||||
|
const standup = result.events[0];
|
||||||
|
expect(standup.conflicts.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
267
web/src/lib/calendar-import.ts
Normal file
267
web/src/lib/calendar-import.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
// ── Calendar .ics Import ──────────────────────────────────────
|
||||||
|
// Parse iCalendar (.ics) files and import events as timers
|
||||||
|
|
||||||
|
import type { Timer } from './timer-engine';
|
||||||
|
import type { UrgencyLevel } from './urgency';
|
||||||
|
import type { CascadeConfig } from './cascade';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import { calculateCascadeWarnings, getCascadeIntervals } from './cascade';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface IcsEvent {
|
||||||
|
uid: string;
|
||||||
|
summary: string;
|
||||||
|
description?: string;
|
||||||
|
dtstart: Date;
|
||||||
|
dtend?: Date;
|
||||||
|
location?: string;
|
||||||
|
priority?: number; // 1-9, lower = higher priority
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportedEvent {
|
||||||
|
event: IcsEvent;
|
||||||
|
timer: Timer;
|
||||||
|
conflicts: string[]; // IDs of existing timers that overlap
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarImportResult {
|
||||||
|
events: ImportedEvent[];
|
||||||
|
errors: string[];
|
||||||
|
totalParsed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── .ics Parser ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an .ics file content into events.
|
||||||
|
*/
|
||||||
|
export function parseIcs(content: string): { events: IcsEvent[]; errors: string[] } {
|
||||||
|
const events: IcsEvent[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
// Unfold lines (RFC 5545: continuation lines start with space/tab)
|
||||||
|
const unfolded = content.replace(/\r\n[ \t]/g, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
const lines = unfolded.split('\n');
|
||||||
|
|
||||||
|
let inEvent = false;
|
||||||
|
let currentEvent: Partial<IcsEvent> = {};
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (trimmed === 'BEGIN:VEVENT') {
|
||||||
|
inEvent = true;
|
||||||
|
currentEvent = {};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed === 'END:VEVENT') {
|
||||||
|
inEvent = false;
|
||||||
|
if (currentEvent.summary && currentEvent.dtstart) {
|
||||||
|
events.push({
|
||||||
|
uid: currentEvent.uid ?? uuidv4(),
|
||||||
|
summary: currentEvent.summary,
|
||||||
|
description: currentEvent.description,
|
||||||
|
dtstart: currentEvent.dtstart,
|
||||||
|
dtend: currentEvent.dtend,
|
||||||
|
location: currentEvent.location,
|
||||||
|
priority: currentEvent.priority,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
errors.push(`Event missing required fields (SUMMARY or DTSTART)`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inEvent) continue;
|
||||||
|
|
||||||
|
// Parse property
|
||||||
|
const colonIdx = trimmed.indexOf(':');
|
||||||
|
if (colonIdx === -1) continue;
|
||||||
|
|
||||||
|
const propPart = trimmed.slice(0, colonIdx);
|
||||||
|
const value = trimmed.slice(colonIdx + 1);
|
||||||
|
|
||||||
|
// Strip parameters (e.g., DTSTART;TZID=America/New_York:20260315T090000)
|
||||||
|
const propName = propPart.split(';')[0].toUpperCase();
|
||||||
|
|
||||||
|
switch (propName) {
|
||||||
|
case 'UID':
|
||||||
|
currentEvent.uid = value;
|
||||||
|
break;
|
||||||
|
case 'SUMMARY':
|
||||||
|
currentEvent.summary = unescapeIcsText(value);
|
||||||
|
break;
|
||||||
|
case 'DESCRIPTION':
|
||||||
|
currentEvent.description = unescapeIcsText(value);
|
||||||
|
break;
|
||||||
|
case 'LOCATION':
|
||||||
|
currentEvent.location = unescapeIcsText(value);
|
||||||
|
break;
|
||||||
|
case 'DTSTART':
|
||||||
|
currentEvent.dtstart = parseIcsDateTime(value);
|
||||||
|
break;
|
||||||
|
case 'DTEND':
|
||||||
|
currentEvent.dtend = parseIcsDateTime(value);
|
||||||
|
break;
|
||||||
|
case 'PRIORITY':
|
||||||
|
currentEvent.priority = parseInt(value, 10) || undefined;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { events, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse iCalendar date-time string.
|
||||||
|
* Formats: 20260315T090000, 20260315T090000Z, 20260315
|
||||||
|
*/
|
||||||
|
function parseIcsDateTime(value: string): Date {
|
||||||
|
const cleaned = value.trim();
|
||||||
|
|
||||||
|
// Date only: YYYYMMDD
|
||||||
|
if (cleaned.length === 8) {
|
||||||
|
const y = parseInt(cleaned.slice(0, 4));
|
||||||
|
const m = parseInt(cleaned.slice(4, 6)) - 1;
|
||||||
|
const d = parseInt(cleaned.slice(6, 8));
|
||||||
|
return new Date(y, m, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DateTime: YYYYMMDDTHHMMSS or YYYYMMDDTHHMMSSZ
|
||||||
|
const isUtc = cleaned.endsWith('Z');
|
||||||
|
const dt = isUtc ? cleaned.slice(0, -1) : cleaned;
|
||||||
|
const parts = dt.split('T');
|
||||||
|
|
||||||
|
const y = parseInt(parts[0].slice(0, 4));
|
||||||
|
const m = parseInt(parts[0].slice(4, 6)) - 1;
|
||||||
|
const d = parseInt(parts[0].slice(6, 8));
|
||||||
|
const h = parts[1] ? parseInt(parts[1].slice(0, 2)) : 0;
|
||||||
|
const min = parts[1] ? parseInt(parts[1].slice(2, 4)) : 0;
|
||||||
|
const s = parts[1] ? parseInt(parts[1].slice(4, 6)) : 0;
|
||||||
|
|
||||||
|
if (isUtc) {
|
||||||
|
return new Date(Date.UTC(y, m, d, h, min, s));
|
||||||
|
}
|
||||||
|
return new Date(y, m, d, h, min, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unescape iCalendar text values.
|
||||||
|
*/
|
||||||
|
function unescapeIcsText(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
.replace(/\\,/g, ',')
|
||||||
|
.replace(/\\;/g, ';')
|
||||||
|
.replace(/\\\\/g, '\\');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Event → Timer Conversion ──────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map iCalendar priority (1-9) to ChronoMind urgency level.
|
||||||
|
* 1-2: critical, 3-4: important, 5: standard, 6-7: gentle, 8-9: passive
|
||||||
|
*/
|
||||||
|
export function mapPriorityToUrgency(priority?: number): UrgencyLevel {
|
||||||
|
if (!priority || priority < 1 || priority > 9) return 'standard';
|
||||||
|
if (priority <= 2) return 'critical';
|
||||||
|
if (priority <= 4) return 'important';
|
||||||
|
if (priority <= 5) return 'standard';
|
||||||
|
if (priority <= 7) return 'gentle';
|
||||||
|
return 'passive';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an IcsEvent to a Timer with auto-generated cascade.
|
||||||
|
*/
|
||||||
|
export function eventToTimer(event: IcsEvent, now: number = Date.now()): Timer {
|
||||||
|
const urgency = mapPriorityToUrgency(event.priority);
|
||||||
|
const targetTime = event.dtstart.getTime();
|
||||||
|
|
||||||
|
// Auto-cascade based on urgency
|
||||||
|
const cascadePresetMap: Record<UrgencyLevel, CascadeConfig> = {
|
||||||
|
critical: { preset: 'aggressive', intervals: [] },
|
||||||
|
important: { preset: 'standard', intervals: [] },
|
||||||
|
standard: { preset: 'light', intervals: [] },
|
||||||
|
gentle: { preset: 'minimal', intervals: [] },
|
||||||
|
passive: { preset: 'none', intervals: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const cascade = cascadePresetMap[urgency];
|
||||||
|
const intervals = getCascadeIntervals(cascade);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
type: 'alarm',
|
||||||
|
label: event.summary,
|
||||||
|
description: [event.description, event.location].filter(Boolean).join(' — '),
|
||||||
|
urgency,
|
||||||
|
state: targetTime > now ? 'active' : 'dismissed',
|
||||||
|
targetTime,
|
||||||
|
duration: event.dtend ? event.dtend.getTime() - targetTime : null,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: targetTime > now ? now : null,
|
||||||
|
pausedAt: null,
|
||||||
|
firedAt: targetTime <= now ? targetTime : null,
|
||||||
|
dismissedAt: targetTime <= now ? now : null,
|
||||||
|
completedAt: null,
|
||||||
|
elapsedBeforePause: 0,
|
||||||
|
cascade,
|
||||||
|
warnings: calculateCascadeWarnings(targetTime, intervals, now),
|
||||||
|
snoozeCount: 0,
|
||||||
|
snoozedUntil: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conflict Detection ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect conflicts between an event and existing timers.
|
||||||
|
* Conflict = existing timer fires within 15 minutes of event start.
|
||||||
|
*/
|
||||||
|
export function detectConflicts(
|
||||||
|
event: IcsEvent,
|
||||||
|
existingTimers: Timer[],
|
||||||
|
conflictWindowMs: number = 15 * 60 * 1000
|
||||||
|
): string[] {
|
||||||
|
const eventStart = event.dtstart.getTime();
|
||||||
|
const eventEnd = event.dtend?.getTime() ?? eventStart + 60 * 60 * 1000;
|
||||||
|
|
||||||
|
return existingTimers
|
||||||
|
.filter((t) => {
|
||||||
|
if (['dismissed', 'completed'].includes(t.state)) return false;
|
||||||
|
const timerTime = t.targetTime;
|
||||||
|
// Timer fires within event window or event starts within timer's range
|
||||||
|
return (
|
||||||
|
(timerTime >= eventStart - conflictWindowMs && timerTime <= eventEnd + conflictWindowMs)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((t) => t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full Import Pipeline ──────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse .ics content and convert to importable timers with conflict detection.
|
||||||
|
*/
|
||||||
|
export function importCalendar(
|
||||||
|
icsContent: string,
|
||||||
|
existingTimers: Timer[],
|
||||||
|
now: number = Date.now()
|
||||||
|
): CalendarImportResult {
|
||||||
|
const { events: icsEvents, errors: parseErrors } = parseIcs(icsContent);
|
||||||
|
|
||||||
|
const importedEvents: ImportedEvent[] = icsEvents.map((event) => {
|
||||||
|
const timer = eventToTimer(event, now);
|
||||||
|
const conflicts = detectConflicts(event, existingTimers);
|
||||||
|
return { event, timer, conflicts };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
events: importedEvents,
|
||||||
|
errors: parseErrors,
|
||||||
|
totalParsed: icsEvents.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
253
web/src/lib/categories.test.ts
Normal file
253
web/src/lib/categories.test.ts
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
BUILT_IN_CATEGORIES,
|
||||||
|
getAllCategories,
|
||||||
|
getCustomCategories,
|
||||||
|
saveCustomCategory,
|
||||||
|
removeCustomCategory,
|
||||||
|
getCategoryById,
|
||||||
|
getCategoryColor,
|
||||||
|
getCustomTags,
|
||||||
|
addCustomTag,
|
||||||
|
removeCustomTag,
|
||||||
|
matchesCategory,
|
||||||
|
matchesTags,
|
||||||
|
} from './categories';
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const storage: Record<string, string> = {};
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn((key: string) => storage[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete storage[key]; }),
|
||||||
|
clear: vi.fn(() => { Object.keys(storage).forEach((k) => delete storage[k]); }),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
||||||
|
|
||||||
|
describe('Categories', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BUILT_IN_CATEGORIES', () => {
|
||||||
|
it('has 6 built-in categories', () => {
|
||||||
|
expect(BUILT_IN_CATEGORIES).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all built-in categories have isBuiltIn=true', () => {
|
||||||
|
for (const cat of BUILT_IN_CATEGORIES) {
|
||||||
|
expect(cat.isBuiltIn).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes Work, Personal, Health, Cooking, Exercise, Study', () => {
|
||||||
|
const ids = BUILT_IN_CATEGORIES.map((c) => c.id);
|
||||||
|
expect(ids).toEqual(['work', 'personal', 'health', 'cooking', 'exercise', 'study']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAllCategories', () => {
|
||||||
|
it('returns built-in categories when no custom ones exist', () => {
|
||||||
|
const cats = getAllCategories();
|
||||||
|
expect(cats).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes custom categories alongside built-in', () => {
|
||||||
|
saveCustomCategory({
|
||||||
|
id: 'custom1',
|
||||||
|
label: 'Custom',
|
||||||
|
color: '#FF0000',
|
||||||
|
icon: 'Star',
|
||||||
|
defaultUrgency: 'gentle',
|
||||||
|
defaultCascade: 'minimal',
|
||||||
|
});
|
||||||
|
const cats = getAllCategories();
|
||||||
|
expect(cats).toHaveLength(7);
|
||||||
|
expect(cats[6].id).toBe('custom1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveCustomCategory', () => {
|
||||||
|
it('saves a new custom category', () => {
|
||||||
|
const result = saveCustomCategory({
|
||||||
|
id: 'gaming',
|
||||||
|
label: 'Gaming',
|
||||||
|
color: '#9333EA',
|
||||||
|
icon: 'Gamepad2',
|
||||||
|
defaultUrgency: 'gentle',
|
||||||
|
defaultCascade: 'none',
|
||||||
|
});
|
||||||
|
expect(result.isBuiltIn).toBe(false);
|
||||||
|
expect(result.label).toBe('Gaming');
|
||||||
|
|
||||||
|
const customs = getCustomCategories();
|
||||||
|
expect(customs).toHaveLength(1);
|
||||||
|
expect(customs[0].id).toBe('gaming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates existing custom category by ID', () => {
|
||||||
|
saveCustomCategory({
|
||||||
|
id: 'gaming',
|
||||||
|
label: 'Gaming',
|
||||||
|
color: '#9333EA',
|
||||||
|
icon: 'Gamepad2',
|
||||||
|
defaultUrgency: 'gentle',
|
||||||
|
defaultCascade: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
saveCustomCategory({
|
||||||
|
id: 'gaming',
|
||||||
|
label: 'Gaming (Updated)',
|
||||||
|
color: '#7C3AED',
|
||||||
|
icon: 'Gamepad2',
|
||||||
|
defaultUrgency: 'standard',
|
||||||
|
defaultCascade: 'light',
|
||||||
|
});
|
||||||
|
|
||||||
|
const customs = getCustomCategories();
|
||||||
|
expect(customs).toHaveLength(1);
|
||||||
|
expect(customs[0].label).toBe('Gaming (Updated)');
|
||||||
|
expect(customs[0].color).toBe('#7C3AED');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeCustomCategory', () => {
|
||||||
|
it('removes existing custom category', () => {
|
||||||
|
saveCustomCategory({
|
||||||
|
id: 'gaming',
|
||||||
|
label: 'Gaming',
|
||||||
|
color: '#9333EA',
|
||||||
|
icon: 'Gamepad2',
|
||||||
|
defaultUrgency: 'gentle',
|
||||||
|
defaultCascade: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = removeCustomCategory('gaming');
|
||||||
|
expect(result).toBe(true);
|
||||||
|
expect(getCustomCategories()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when removing non-existent category', () => {
|
||||||
|
const result = removeCustomCategory('nonexistent');
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCategoryById', () => {
|
||||||
|
it('finds built-in category', () => {
|
||||||
|
const cat = getCategoryById('work');
|
||||||
|
expect(cat).toBeDefined();
|
||||||
|
expect(cat!.label).toBe('Work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds custom category', () => {
|
||||||
|
saveCustomCategory({
|
||||||
|
id: 'gaming',
|
||||||
|
label: 'Gaming',
|
||||||
|
color: '#9333EA',
|
||||||
|
icon: 'Gamepad2',
|
||||||
|
defaultUrgency: 'gentle',
|
||||||
|
defaultCascade: 'none',
|
||||||
|
});
|
||||||
|
const cat = getCategoryById('gaming');
|
||||||
|
expect(cat).toBeDefined();
|
||||||
|
expect(cat!.label).toBe('Gaming');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown ID', () => {
|
||||||
|
expect(getCategoryById('unknown')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCategoryColor', () => {
|
||||||
|
it('returns color for valid category', () => {
|
||||||
|
expect(getCategoryColor('work')).toBe('#5A8CFF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for undefined category', () => {
|
||||||
|
expect(getCategoryColor(undefined)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for unknown category', () => {
|
||||||
|
expect(getCategoryColor('nope')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom Tags', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts with empty tags', () => {
|
||||||
|
expect(getCustomTags()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds a tag (normalized to lowercase)', () => {
|
||||||
|
const tags = addCustomTag('Urgent');
|
||||||
|
expect(tags).toEqual(['urgent']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deduplicates tags', () => {
|
||||||
|
addCustomTag('focus');
|
||||||
|
const tags = addCustomTag('Focus');
|
||||||
|
expect(tags).toEqual(['focus']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty tags', () => {
|
||||||
|
addCustomTag('valid');
|
||||||
|
const tags = addCustomTag(' ');
|
||||||
|
expect(tags).toEqual(['valid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes a tag', () => {
|
||||||
|
addCustomTag('a');
|
||||||
|
addCustomTag('b');
|
||||||
|
const tags = removeCustomTag('a');
|
||||||
|
expect(tags).toEqual(['b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Filter helpers', () => {
|
||||||
|
describe('matchesCategory', () => {
|
||||||
|
it('returns true when no filter', () => {
|
||||||
|
expect(matchesCategory('work', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when timer category matches filter', () => {
|
||||||
|
expect(matchesCategory('work', 'work')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when timer category does not match filter', () => {
|
||||||
|
expect(matchesCategory('personal', 'work')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when timer has no category but filter is set', () => {
|
||||||
|
expect(matchesCategory(undefined, 'work')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('matchesTags', () => {
|
||||||
|
it('returns true when no filter tags', () => {
|
||||||
|
expect(matchesTags(['a', 'b'], [])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when timer has a matching tag', () => {
|
||||||
|
expect(matchesTags(['focus', 'deep'], ['focus'])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when timer has no matching tags', () => {
|
||||||
|
expect(matchesTags(['a'], ['b'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when timer has no tags', () => {
|
||||||
|
expect(matchesTags(undefined, ['b'])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when timer tags are empty', () => {
|
||||||
|
expect(matchesTags([], ['b'])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
202
web/src/lib/categories.ts
Normal file
202
web/src/lib/categories.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
// ── Categories / Tags System ──────────────────────────────────
|
||||||
|
// Built-in categories with default urgency + cascade presets, plus custom tags
|
||||||
|
|
||||||
|
import type { UrgencyLevel } from './urgency';
|
||||||
|
import type { CascadePreset } from './cascade';
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
icon: string; // Lucide icon name
|
||||||
|
defaultUrgency: UrgencyLevel;
|
||||||
|
defaultCascade: CascadePreset;
|
||||||
|
isBuiltIn: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Built-in Categories ───────────────────────────────────────
|
||||||
|
|
||||||
|
export const BUILT_IN_CATEGORIES: Category[] = [
|
||||||
|
{
|
||||||
|
id: 'work',
|
||||||
|
label: 'Work',
|
||||||
|
color: '#5A8CFF',
|
||||||
|
icon: 'Briefcase',
|
||||||
|
defaultUrgency: 'important',
|
||||||
|
defaultCascade: 'standard',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'personal',
|
||||||
|
label: 'Personal',
|
||||||
|
color: '#A78BFA',
|
||||||
|
icon: 'User',
|
||||||
|
defaultUrgency: 'standard',
|
||||||
|
defaultCascade: 'light',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'health',
|
||||||
|
label: 'Health',
|
||||||
|
color: '#34D399',
|
||||||
|
icon: 'Heart',
|
||||||
|
defaultUrgency: 'important',
|
||||||
|
defaultCascade: 'standard',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cooking',
|
||||||
|
label: 'Cooking',
|
||||||
|
color: '#F59E0B',
|
||||||
|
icon: 'ChefHat',
|
||||||
|
defaultUrgency: 'standard',
|
||||||
|
defaultCascade: 'light',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exercise',
|
||||||
|
label: 'Exercise',
|
||||||
|
color: '#2EE6D6',
|
||||||
|
icon: 'Dumbbell',
|
||||||
|
defaultUrgency: 'standard',
|
||||||
|
defaultCascade: 'minimal',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'study',
|
||||||
|
label: 'Study',
|
||||||
|
color: '#FF9F43',
|
||||||
|
icon: 'BookOpen',
|
||||||
|
defaultUrgency: 'standard',
|
||||||
|
defaultCascade: 'light',
|
||||||
|
isBuiltIn: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Category Helpers ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'chronomind-custom-categories';
|
||||||
|
const TAGS_STORAGE_KEY = 'chronomind-custom-tags';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all categories (built-in + custom)
|
||||||
|
*/
|
||||||
|
export function getAllCategories(): Category[] {
|
||||||
|
return [...BUILT_IN_CATEGORIES, ...getCustomCategories()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get custom user-defined categories from localStorage
|
||||||
|
*/
|
||||||
|
export function getCustomCategories(): Category[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a custom category
|
||||||
|
*/
|
||||||
|
export function saveCustomCategory(category: Omit<Category, 'isBuiltIn'>): Category {
|
||||||
|
const full: Category = { ...category, isBuiltIn: false };
|
||||||
|
const existing = getCustomCategories();
|
||||||
|
const idx = existing.findIndex((c) => c.id === full.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
existing[idx] = full;
|
||||||
|
} else {
|
||||||
|
existing.push(full);
|
||||||
|
}
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(existing));
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a custom category (cannot remove built-in)
|
||||||
|
*/
|
||||||
|
export function removeCustomCategory(id: string): boolean {
|
||||||
|
const existing = getCustomCategories();
|
||||||
|
const filtered = existing.filter((c) => c.id !== id);
|
||||||
|
if (filtered.length === existing.length) return false;
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a category by ID (built-in or custom)
|
||||||
|
*/
|
||||||
|
export function getCategoryById(id: string): Category | undefined {
|
||||||
|
return getAllCategories().find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the category color for display (returns undefined if no category)
|
||||||
|
*/
|
||||||
|
export function getCategoryColor(categoryId: string | undefined): string | undefined {
|
||||||
|
if (!categoryId) return undefined;
|
||||||
|
return getCategoryById(categoryId)?.color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Custom Tags ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all custom tags from localStorage
|
||||||
|
*/
|
||||||
|
export function getCustomTags(): string[] {
|
||||||
|
if (typeof window === 'undefined') return [];
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TAGS_STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a custom tag (deduped, lowercased)
|
||||||
|
*/
|
||||||
|
export function addCustomTag(tag: string): string[] {
|
||||||
|
const normalized = tag.trim().toLowerCase();
|
||||||
|
if (!normalized) return getCustomTags();
|
||||||
|
const existing = getCustomTags();
|
||||||
|
if (existing.includes(normalized)) return existing;
|
||||||
|
const updated = [...existing, normalized];
|
||||||
|
localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a custom tag
|
||||||
|
*/
|
||||||
|
export function removeCustomTag(tag: string): string[] {
|
||||||
|
const existing = getCustomTags();
|
||||||
|
const updated = existing.filter((t) => t !== tag.trim().toLowerCase());
|
||||||
|
localStorage.setItem(TAGS_STORAGE_KEY, JSON.stringify(updated));
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter timers by category ID. Returns true if timer matches.
|
||||||
|
*/
|
||||||
|
export function matchesCategory(
|
||||||
|
timerCategory: string | undefined,
|
||||||
|
filterCategoryId: string | null
|
||||||
|
): boolean {
|
||||||
|
if (!filterCategoryId) return true; // no filter = show all
|
||||||
|
return timerCategory === filterCategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter timers by tags. Returns true if timer has at least one matching tag.
|
||||||
|
*/
|
||||||
|
export function matchesTags(
|
||||||
|
timerTags: string[] | undefined,
|
||||||
|
filterTags: string[]
|
||||||
|
): boolean {
|
||||||
|
if (filterTags.length === 0) return true; // no filter = show all
|
||||||
|
if (!timerTags || timerTags.length === 0) return false;
|
||||||
|
return filterTags.some((ft) => timerTags.includes(ft));
|
||||||
|
}
|
||||||
127
web/src/lib/export.ts
Normal file
127
web/src/lib/export.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// ── Timer Export / Import ─────────────────────────────────────
|
||||||
|
// Export all timers as JSON, import from JSON file
|
||||||
|
|
||||||
|
import type { Timer } from './timer-engine';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ExportData {
|
||||||
|
version: 1;
|
||||||
|
exportedAt: number;
|
||||||
|
app: 'chronomind';
|
||||||
|
timers: Timer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
success: boolean;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export timers as a downloadable JSON blob.
|
||||||
|
*/
|
||||||
|
export function exportTimers(timers: Timer[]): ExportData {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
exportedAt: Date.now(),
|
||||||
|
app: 'chronomind',
|
||||||
|
timers: timers.map((t) => ({ ...t })), // shallow clone
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger a browser download of the export data.
|
||||||
|
*/
|
||||||
|
export function downloadExport(timers: Timer[]): void {
|
||||||
|
const data = exportTimers(timers);
|
||||||
|
const json = JSON.stringify(data, null, 2);
|
||||||
|
const blob = new Blob([json], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `chronomind-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Import ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and parse an import file.
|
||||||
|
*/
|
||||||
|
export function parseImportData(jsonStr: string): ExportData | null {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(jsonStr);
|
||||||
|
if (!data || data.app !== 'chronomind' || !Array.isArray(data.timers)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data as ExportData;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import timers from parsed export data, deduplicating by ID.
|
||||||
|
*/
|
||||||
|
export function importTimers(
|
||||||
|
exportData: ExportData,
|
||||||
|
existingTimers: Timer[]
|
||||||
|
): ImportResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const existingIds = new Set(existingTimers.map((t) => t.id));
|
||||||
|
const newTimers: Timer[] = [];
|
||||||
|
|
||||||
|
for (const timer of exportData.timers) {
|
||||||
|
// Basic validation
|
||||||
|
if (!timer.id || !timer.type || !timer.label) {
|
||||||
|
errors.push(`Invalid timer: missing required fields (id, type, or label)`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingIds.has(timer.id)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required numeric fields
|
||||||
|
if (typeof timer.createdAt !== 'number' || typeof timer.targetTime !== 'number') {
|
||||||
|
errors.push(`Timer "${timer.label}": invalid timestamps`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
newTimers.push(timer);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0,
|
||||||
|
imported,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a File object and return the parsed content as string.
|
||||||
|
*/
|
||||||
|
export function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||||
|
reader.readAsText(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
352
web/src/lib/recurrence.test.ts
Normal file
352
web/src/lib/recurrence.test.ts
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
getNextOccurrence,
|
||||||
|
getNextNOccurrences,
|
||||||
|
getOccurrenceAfterSkip,
|
||||||
|
createDailyRule,
|
||||||
|
createWeekdayRule,
|
||||||
|
createWeekendRule,
|
||||||
|
createWeeklyRule,
|
||||||
|
createBiweeklyRule,
|
||||||
|
createMonthlyRule,
|
||||||
|
createCustomRule,
|
||||||
|
formatTimeOfDay,
|
||||||
|
describeRecurrence,
|
||||||
|
RECURRENCE_LABELS,
|
||||||
|
} from './recurrence';
|
||||||
|
|
||||||
|
// Helper: create a date at a specific time
|
||||||
|
function makeDate(year: number, month: number, day: number, hour = 0, minute = 0): number {
|
||||||
|
return new Date(year, month - 1, day, hour, minute, 0, 0).getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateParts(ts: number) {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return {
|
||||||
|
year: d.getFullYear(),
|
||||||
|
month: d.getMonth() + 1,
|
||||||
|
day: d.getDate(),
|
||||||
|
hour: d.getHours(),
|
||||||
|
minute: d.getMinutes(),
|
||||||
|
dayOfWeek: d.getDay(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Recurrence Engine', () => {
|
||||||
|
describe('Daily', () => {
|
||||||
|
const rule = createDailyRule(9 * 60); // 9:00 AM
|
||||||
|
|
||||||
|
it('returns next day at 9 AM if after 9 AM today', () => {
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0); // 10:00 AM
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.day).toBe(16);
|
||||||
|
expect(parts.hour).toBe(9);
|
||||||
|
expect(parts.minute).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns same day at 9 AM if before 9 AM today', () => {
|
||||||
|
const after = makeDate(2026, 3, 15, 7, 0); // 7:00 AM
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.day).toBe(15);
|
||||||
|
expect(parts.hour).toBe(9);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crosses month boundary', () => {
|
||||||
|
const after = makeDate(2026, 3, 31, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.month).toBe(4);
|
||||||
|
expect(parts.day).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('crosses year boundary', () => {
|
||||||
|
const after = makeDate(2026, 12, 31, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.year).toBe(2027);
|
||||||
|
expect(parts.month).toBe(1);
|
||||||
|
expect(parts.day).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Weekday', () => {
|
||||||
|
const rule = createWeekdayRule(8 * 60 + 30); // 8:30 AM
|
||||||
|
|
||||||
|
it('skips weekends', () => {
|
||||||
|
// Friday March 13, 2026 at 9:00 AM → should be Monday March 16
|
||||||
|
const friday = makeDate(2026, 3, 13, 9, 0);
|
||||||
|
const next = getNextOccurrence(rule, friday)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(1); // Monday
|
||||||
|
expect(parts.day).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns same day if before time on a weekday', () => {
|
||||||
|
// Wednesday at 7:00 AM → same day at 8:30
|
||||||
|
const wed = makeDate(2026, 3, 11, 7, 0);
|
||||||
|
const next = getNextOccurrence(rule, wed)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.day).toBe(11);
|
||||||
|
expect(parts.hour).toBe(8);
|
||||||
|
expect(parts.minute).toBe(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips Saturday', () => {
|
||||||
|
const sat = makeDate(2026, 3, 14, 7, 0); // Saturday
|
||||||
|
const next = getNextOccurrence(rule, sat)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(1); // Monday
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips Sunday', () => {
|
||||||
|
const sun = makeDate(2026, 3, 15, 7, 0); // Sunday
|
||||||
|
const next = getNextOccurrence(rule, sun)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(1); // Monday
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Weekend', () => {
|
||||||
|
const rule = createWeekendRule(10 * 60); // 10:00 AM
|
||||||
|
|
||||||
|
it('skips weekdays', () => {
|
||||||
|
const mon = makeDate(2026, 3, 9, 11, 0); // Monday
|
||||||
|
const next = getNextOccurrence(rule, mon)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek === 0 || parts.dayOfWeek === 6).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns Saturday if on Friday evening', () => {
|
||||||
|
const fri = makeDate(2026, 3, 13, 18, 0);
|
||||||
|
const next = getNextOccurrence(rule, fri)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(6); // Saturday
|
||||||
|
expect(parts.day).toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Weekly', () => {
|
||||||
|
it('returns next week same day', () => {
|
||||||
|
const rule = createWeeklyRule(14 * 60); // 2:00 PM
|
||||||
|
// Monday at 3pm → next Monday
|
||||||
|
const mon = makeDate(2026, 3, 9, 15, 0);
|
||||||
|
const next = getNextOccurrence(rule, mon)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.day).toBe(16);
|
||||||
|
expect(parts.dayOfWeek).toBe(1); // Monday
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Biweekly', () => {
|
||||||
|
it('returns occurrence in 2 weeks', () => {
|
||||||
|
const rule = createBiweeklyRule(9 * 60);
|
||||||
|
const start = makeDate(2026, 3, 9, 10, 0); // Monday
|
||||||
|
const next = getNextOccurrence(rule, start)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
// Should be the same day of week (Monday) within 14 days
|
||||||
|
expect(parts.dayOfWeek).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Monthly', () => {
|
||||||
|
const rule = createMonthlyRule(9 * 60); // 9:00 AM
|
||||||
|
|
||||||
|
it('returns same day next month', () => {
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.month).toBe(4);
|
||||||
|
expect(parts.day).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles month with fewer days (31st → 30th)', () => {
|
||||||
|
// Jan 31 → Feb should be 28 (non-leap) or clamp
|
||||||
|
const after = makeDate(2026, 1, 31, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.month).toBe(2);
|
||||||
|
expect(parts.day).toBe(28); // Feb 2026 has 28 days
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles leap year (Feb 29)', () => {
|
||||||
|
// 2028 is a leap year
|
||||||
|
const after = makeDate(2028, 1, 29, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.month).toBe(2);
|
||||||
|
expect(parts.day).toBe(29); // Feb 29 exists in 2028
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles month boundary Dec → Jan', () => {
|
||||||
|
const after = makeDate(2026, 12, 15, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.year).toBe(2027);
|
||||||
|
expect(parts.month).toBe(1);
|
||||||
|
expect(parts.day).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom days', () => {
|
||||||
|
it('returns next matching day (Mon, Wed, Fri)', () => {
|
||||||
|
const rule = createCustomRule([1, 3, 5], 9 * 60); // Mon, Wed, Fri
|
||||||
|
const tue = makeDate(2026, 3, 10, 10, 0); // Tuesday
|
||||||
|
const next = getNextOccurrence(rule, tue)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(3); // Wednesday
|
||||||
|
expect(parts.day).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles single day (Sunday only)', () => {
|
||||||
|
const rule = createCustomRule([0], 12 * 60); // Sunday noon
|
||||||
|
const mon = makeDate(2026, 3, 9, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, mon)!;
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.dayOfWeek).toBe(0); // Sunday
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null with empty daysOfWeek', () => {
|
||||||
|
const rule = createCustomRule([], 9 * 60);
|
||||||
|
const result = getNextOccurrence(rule, Date.now());
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('End date', () => {
|
||||||
|
it('returns null when next occurrence would be after end date', () => {
|
||||||
|
const rule = createDailyRule(9 * 60, makeDate(2026, 3, 10, 23, 59));
|
||||||
|
const after = makeDate(2026, 3, 10, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after);
|
||||||
|
expect(next).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns occurrence if before end date', () => {
|
||||||
|
const rule = createDailyRule(9 * 60, makeDate(2026, 3, 20, 23, 59));
|
||||||
|
const after = makeDate(2026, 3, 10, 10, 0);
|
||||||
|
const next = getNextOccurrence(rule, after);
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getNextNOccurrences', () => {
|
||||||
|
it('returns N daily occurrences', () => {
|
||||||
|
const rule = createDailyRule(9 * 60);
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0);
|
||||||
|
const occs = getNextNOccurrences(rule, after, 5);
|
||||||
|
expect(occs).toHaveLength(5);
|
||||||
|
|
||||||
|
// Should be consecutive days
|
||||||
|
for (let i = 1; i < occs.length; i++) {
|
||||||
|
const diff = occs[i] - occs[i - 1];
|
||||||
|
expect(diff).toBe(24 * 60 * 60 * 1000); // exactly 1 day apart
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns fewer than N if end date limits', () => {
|
||||||
|
const rule = createDailyRule(9 * 60, makeDate(2026, 3, 18, 23, 59));
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0);
|
||||||
|
const occs = getNextNOccurrences(rule, after, 10);
|
||||||
|
expect(occs.length).toBeLessThanOrEqual(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns weekday-only occurrences', () => {
|
||||||
|
const rule = createWeekdayRule(9 * 60);
|
||||||
|
const sun = makeDate(2026, 3, 8, 10, 0); // Sunday
|
||||||
|
const occs = getNextNOccurrences(rule, sun, 5);
|
||||||
|
expect(occs).toHaveLength(5);
|
||||||
|
occs.forEach((ts) => {
|
||||||
|
const dow = new Date(ts).getDay();
|
||||||
|
expect(dow).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(dow).toBeLessThanOrEqual(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOccurrenceAfterSkip', () => {
|
||||||
|
it('skips the next occurrence and returns the one after', () => {
|
||||||
|
const rule = createDailyRule(9 * 60);
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0);
|
||||||
|
const skipped = getOccurrenceAfterSkip(rule, after)!;
|
||||||
|
const parts = getDateParts(skipped);
|
||||||
|
expect(parts.day).toBe(17); // skipped 16, got 17
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null if no occurrence after skip', () => {
|
||||||
|
const rule = createDailyRule(9 * 60, makeDate(2026, 3, 16, 23, 59));
|
||||||
|
const after = makeDate(2026, 3, 15, 10, 0);
|
||||||
|
const skipped = getOccurrenceAfterSkip(rule, after);
|
||||||
|
expect(skipped).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DST edge cases', () => {
|
||||||
|
it('handles spring forward (March DST transition)', () => {
|
||||||
|
// In 2026, US DST starts March 8. Timer at 2:30 AM should still work.
|
||||||
|
const rule = createDailyRule(2 * 60 + 30); // 2:30 AM
|
||||||
|
const before = makeDate(2026, 3, 7, 3, 0);
|
||||||
|
const next = getNextOccurrence(rule, before)!;
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
// Should return March 8 (or 9) at the target time
|
||||||
|
expect(parts.month).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles fall back (November DST transition)', () => {
|
||||||
|
// In 2026, US DST ends November 1
|
||||||
|
const rule = createDailyRule(1 * 60 + 30); // 1:30 AM
|
||||||
|
const before = makeDate(2026, 10, 31, 2, 0);
|
||||||
|
const next = getNextOccurrence(rule, before)!;
|
||||||
|
expect(next).not.toBeNull();
|
||||||
|
const parts = getDateParts(next);
|
||||||
|
expect(parts.month).toBe(11);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Display helpers', () => {
|
||||||
|
describe('formatTimeOfDay', () => {
|
||||||
|
it('formats midnight', () => {
|
||||||
|
expect(formatTimeOfDay(0)).toBe('12:00 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats noon', () => {
|
||||||
|
expect(formatTimeOfDay(12 * 60)).toBe('12:00 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats 9:00 AM', () => {
|
||||||
|
expect(formatTimeOfDay(9 * 60)).toBe('9:00 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats 3:30 PM', () => {
|
||||||
|
expect(formatTimeOfDay(15 * 60 + 30)).toBe('3:30 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats 11:59 PM', () => {
|
||||||
|
expect(formatTimeOfDay(23 * 60 + 59)).toBe('11:59 PM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('describeRecurrence', () => {
|
||||||
|
it('describes daily', () => {
|
||||||
|
expect(describeRecurrence(createDailyRule(9 * 60))).toBe('Every day at 9:00 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes weekday', () => {
|
||||||
|
expect(describeRecurrence(createWeekdayRule(8 * 60 + 30))).toBe('Weekdays at 8:30 AM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('describes custom with days', () => {
|
||||||
|
const desc = describeRecurrence(createCustomRule([1, 3, 5], 9 * 60));
|
||||||
|
expect(desc).toBe('Mon, Wed, Fri at 9:00 AM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RECURRENCE_LABELS', () => {
|
||||||
|
it('has labels for all frequencies', () => {
|
||||||
|
expect(Object.keys(RECURRENCE_LABELS)).toHaveLength(7);
|
||||||
|
expect(RECURRENCE_LABELS.daily).toBe('Every day');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
284
web/src/lib/recurrence.ts
Normal file
284
web/src/lib/recurrence.ts
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
// ── Recurring Timer Engine ────────────────────────────────────
|
||||||
|
// Recurrence rules, next-occurrence calculation, skip/pause logic
|
||||||
|
|
||||||
|
export type RecurrenceFrequency =
|
||||||
|
| 'daily'
|
||||||
|
| 'weekday'
|
||||||
|
| 'weekend'
|
||||||
|
| 'weekly'
|
||||||
|
| 'biweekly'
|
||||||
|
| 'monthly'
|
||||||
|
| 'custom';
|
||||||
|
|
||||||
|
export interface RecurrenceRule {
|
||||||
|
frequency: RecurrenceFrequency;
|
||||||
|
daysOfWeek?: number[]; // 0=Sun, 1=Mon, ..., 6=Sat (for 'custom' frequency)
|
||||||
|
interval?: number; // Every N periods (default 1)
|
||||||
|
endDate?: number; // epoch ms — stop recurring after this
|
||||||
|
timeOfDay: number; // minutes since midnight (e.g., 540 = 9:00 AM)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecurringTimer {
|
||||||
|
id: string;
|
||||||
|
recurrence: RecurrenceRule;
|
||||||
|
paused: boolean;
|
||||||
|
skipNext: boolean;
|
||||||
|
lastOccurrence: number | null; // epoch ms of last generated occurrence
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECURRENCE_LABELS: Record<RecurrenceFrequency, string> = {
|
||||||
|
daily: 'Every day',
|
||||||
|
weekday: 'Weekdays (Mon–Fri)',
|
||||||
|
weekend: 'Weekends (Sat–Sun)',
|
||||||
|
weekly: 'Every week',
|
||||||
|
biweekly: 'Every 2 weeks',
|
||||||
|
monthly: 'Every month',
|
||||||
|
custom: 'Custom days',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// ── Next Occurrence Calculation ───────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the next occurrence of a recurring timer after `afterDate`.
|
||||||
|
* Returns epoch ms of next occurrence, or null if no more occurrences.
|
||||||
|
*/
|
||||||
|
export function getNextOccurrence(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: number,
|
||||||
|
maxLookaheadDays: number = 366
|
||||||
|
): number | null {
|
||||||
|
const { frequency, timeOfDay, endDate, interval = 1 } = rule;
|
||||||
|
|
||||||
|
// Start from the day after afterDate
|
||||||
|
const after = new Date(afterDate);
|
||||||
|
const startDay = new Date(after.getFullYear(), after.getMonth(), after.getDate());
|
||||||
|
|
||||||
|
// Set time of day
|
||||||
|
const setTimeOnDate = (d: Date): Date => {
|
||||||
|
const result = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||||
|
result.setMinutes(timeOfDay);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a candidate on the same day as afterDate still works
|
||||||
|
const sameDayCandidate = setTimeOnDate(startDay);
|
||||||
|
const candidates: Date[] = [];
|
||||||
|
|
||||||
|
if (sameDayCandidate.getTime() > afterDate) {
|
||||||
|
candidates.push(sameDayCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate candidates going forward
|
||||||
|
const limit = new Date(startDay.getTime() + maxLookaheadDays * DAY_MS);
|
||||||
|
|
||||||
|
for (let d = new Date(startDay.getTime() + DAY_MS); d <= limit; d = new Date(d.getTime() + DAY_MS)) {
|
||||||
|
candidates.push(setTimeOnDate(d));
|
||||||
|
if (candidates.length > maxLookaheadDays) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const ts = candidate.getTime();
|
||||||
|
|
||||||
|
// Check end date
|
||||||
|
if (endDate && ts > endDate) return null;
|
||||||
|
|
||||||
|
// Check if this day matches the frequency
|
||||||
|
if (matchesFrequency(candidate, rule, afterDate, interval)) {
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a given date matches the recurrence frequency rule.
|
||||||
|
*/
|
||||||
|
function matchesFrequency(
|
||||||
|
date: Date,
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
_afterDate: number,
|
||||||
|
interval: number
|
||||||
|
): boolean {
|
||||||
|
const dayOfWeek = date.getDay(); // 0=Sun
|
||||||
|
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case 'daily':
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case 'weekday':
|
||||||
|
return dayOfWeek >= 1 && dayOfWeek <= 5;
|
||||||
|
|
||||||
|
case 'weekend':
|
||||||
|
return dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
|
||||||
|
case 'weekly': {
|
||||||
|
// Same day of week as the reference day, with interval
|
||||||
|
const refDay = new Date(_afterDate).getDay();
|
||||||
|
if (dayOfWeek !== refDay) return false;
|
||||||
|
if (interval <= 1) return true;
|
||||||
|
const refDate = new Date(_afterDate);
|
||||||
|
const refStart = new Date(refDate.getFullYear(), refDate.getMonth(), refDate.getDate());
|
||||||
|
const diffDays = Math.round((date.getTime() - refStart.getTime()) / DAY_MS);
|
||||||
|
return diffDays % (interval * 7) === 0 || diffDays % (interval * 7) === 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'biweekly': {
|
||||||
|
const refDay2 = new Date(_afterDate).getDay();
|
||||||
|
if (dayOfWeek !== refDay2) return false;
|
||||||
|
const refDate2 = new Date(_afterDate);
|
||||||
|
const refStart2 = new Date(refDate2.getFullYear(), refDate2.getMonth(), refDate2.getDate());
|
||||||
|
const diffDays2 = Math.round((date.getTime() - refStart2.getTime()) / DAY_MS);
|
||||||
|
return diffDays2 >= 0 && diffDays2 % 14 < 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'monthly': {
|
||||||
|
// Same day of month — handle months with fewer days
|
||||||
|
const refDayOfMonth = new Date(_afterDate).getDate();
|
||||||
|
const daysInMonth = new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
|
||||||
|
const targetDay = Math.min(refDayOfMonth, daysInMonth);
|
||||||
|
return date.getDate() === targetDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
return rule.daysOfWeek ? rule.daysOfWeek.includes(dayOfWeek) : false;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bulk Helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next N occurrences of a recurring timer.
|
||||||
|
*/
|
||||||
|
export function getNextNOccurrences(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: number,
|
||||||
|
count: number
|
||||||
|
): number[] {
|
||||||
|
const occurrences: number[] = [];
|
||||||
|
let cursor = afterDate;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const next = getNextOccurrence(rule, cursor);
|
||||||
|
if (!next) break;
|
||||||
|
occurrences.push(next);
|
||||||
|
cursor = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
return occurrences;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply "skip next" — get the occurrence after the next one.
|
||||||
|
*/
|
||||||
|
export function getOccurrenceAfterSkip(
|
||||||
|
rule: RecurrenceRule,
|
||||||
|
afterDate: number
|
||||||
|
): number | null {
|
||||||
|
const next = getNextOccurrence(rule, afterDate);
|
||||||
|
if (!next) return null;
|
||||||
|
return getNextOccurrence(rule, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rule Builders ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a daily recurrence rule at a specific time.
|
||||||
|
*/
|
||||||
|
export function createDailyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'daily', timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a weekday recurrence rule.
|
||||||
|
*/
|
||||||
|
export function createWeekdayRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'weekday', timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a weekend recurrence rule.
|
||||||
|
*/
|
||||||
|
export function createWeekendRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'weekend', timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a weekly recurrence rule.
|
||||||
|
*/
|
||||||
|
export function createWeeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'weekly', timeOfDay: timeOfDayMinutes, interval: 1, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a biweekly recurrence rule.
|
||||||
|
*/
|
||||||
|
export function createBiweeklyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'biweekly', timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a monthly recurrence rule.
|
||||||
|
*/
|
||||||
|
export function createMonthlyRule(timeOfDayMinutes: number, endDate?: number): RecurrenceRule {
|
||||||
|
return { frequency: 'monthly', timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a custom recurrence rule for specific days of the week.
|
||||||
|
*/
|
||||||
|
export function createCustomRule(
|
||||||
|
daysOfWeek: number[],
|
||||||
|
timeOfDayMinutes: number,
|
||||||
|
endDate?: number
|
||||||
|
): RecurrenceRule {
|
||||||
|
return { frequency: 'custom', daysOfWeek, timeOfDay: timeOfDayMinutes, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Display Helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time-of-day minutes as "HH:MM AM/PM"
|
||||||
|
*/
|
||||||
|
export function formatTimeOfDay(minutes: number): string {
|
||||||
|
const h = Math.floor(minutes / 60);
|
||||||
|
const m = minutes % 60;
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM';
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
return `${h12}:${String(m).padStart(2, '0')} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable description of a recurrence rule.
|
||||||
|
*/
|
||||||
|
export function describeRecurrence(rule: RecurrenceRule): string {
|
||||||
|
const time = formatTimeOfDay(rule.timeOfDay);
|
||||||
|
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
|
||||||
|
switch (rule.frequency) {
|
||||||
|
case 'daily':
|
||||||
|
return `Every day at ${time}`;
|
||||||
|
case 'weekday':
|
||||||
|
return `Weekdays at ${time}`;
|
||||||
|
case 'weekend':
|
||||||
|
return `Weekends at ${time}`;
|
||||||
|
case 'weekly':
|
||||||
|
return `Every week at ${time}`;
|
||||||
|
case 'biweekly':
|
||||||
|
return `Every 2 weeks at ${time}`;
|
||||||
|
case 'monthly':
|
||||||
|
return `Monthly at ${time}`;
|
||||||
|
case 'custom': {
|
||||||
|
if (!rule.daysOfWeek || rule.daysOfWeek.length === 0) return `Custom at ${time}`;
|
||||||
|
const days = rule.daysOfWeek.sort().map((d) => dayNames[d]).join(', ');
|
||||||
|
return `${days} at ${time}`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return `At ${time}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
343
web/src/lib/stats.test.ts
Normal file
343
web/src/lib/stats.test.ts
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import type { Timer } from './timer-engine';
|
||||||
|
import {
|
||||||
|
computeStats,
|
||||||
|
computeDailyStats,
|
||||||
|
computeStreak,
|
||||||
|
computeWeeklySummary,
|
||||||
|
computeCategoryBreakdown,
|
||||||
|
} from './stats';
|
||||||
|
|
||||||
|
// Mock localStorage for streak persistence
|
||||||
|
const storage: Record<string, string> = {};
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: vi.fn((key: string) => storage[key] ?? null),
|
||||||
|
setItem: vi.fn((key: string, value: string) => { storage[key] = value; }),
|
||||||
|
removeItem: vi.fn((key: string) => { delete storage[key]; }),
|
||||||
|
clear: vi.fn(() => { Object.keys(storage).forEach((k) => delete storage[k]); }),
|
||||||
|
};
|
||||||
|
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function makeTimer(overrides: Partial<Timer> = {}): Timer {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id: `timer-${Math.random().toString(36).slice(2)}`,
|
||||||
|
type: 'countdown',
|
||||||
|
label: 'Test',
|
||||||
|
urgency: 'standard',
|
||||||
|
state: 'active',
|
||||||
|
targetTime: now + 60_000,
|
||||||
|
duration: 60_000,
|
||||||
|
createdAt: now,
|
||||||
|
startedAt: now,
|
||||||
|
pausedAt: null,
|
||||||
|
firedAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
elapsedBeforePause: 0,
|
||||||
|
cascade: { preset: 'none', intervals: [] },
|
||||||
|
warnings: [],
|
||||||
|
snoozeCount: 0,
|
||||||
|
snoozedUntil: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(n: number, now: number = Date.now()): number {
|
||||||
|
return now - n * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeStats', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts created timers', () => {
|
||||||
|
const timers = [makeTimer(), makeTimer(), makeTimer()];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.created).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts completed timers', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ state: 'completed', completedAt: Date.now() }),
|
||||||
|
makeTimer({ state: 'completed', completedAt: Date.now() }),
|
||||||
|
makeTimer({ state: 'active' }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.completed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts dismissed timers', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ state: 'dismissed', dismissedAt: Date.now() }),
|
||||||
|
makeTimer({ state: 'active' }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.dismissed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts snoozed timers', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ snoozeCount: 2 }),
|
||||||
|
makeTimer({ snoozeCount: 0 }),
|
||||||
|
makeTimer({ snoozeCount: 1 }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.snoozed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes on-time rate correctly', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
// Acted on within 2 min
|
||||||
|
makeTimer({
|
||||||
|
state: 'completed',
|
||||||
|
firedAt: now - 60_000,
|
||||||
|
completedAt: now - 60_000 + 30_000, // 30s after firing
|
||||||
|
}),
|
||||||
|
// Acted on after 2 min
|
||||||
|
makeTimer({
|
||||||
|
state: 'dismissed',
|
||||||
|
firedAt: now - 300_000,
|
||||||
|
dismissedAt: now - 300_000 + 180_000, // 3 min after firing
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.onTimeRate).toBe(0.5); // 1 out of 2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('on-time rate is 0 when no timers have fired', () => {
|
||||||
|
const timers = [makeTimer()];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.onTimeRate).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes focus minutes from completed Pomodoros', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({
|
||||||
|
type: 'pomodoro',
|
||||||
|
state: 'completed',
|
||||||
|
pomodoroConfig: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: 4 },
|
||||||
|
pomodoroState: { currentRound: 4, isBreak: false, isLongBreak: false, completedRounds: 4 },
|
||||||
|
}),
|
||||||
|
makeTimer({
|
||||||
|
type: 'pomodoro',
|
||||||
|
state: 'completed',
|
||||||
|
pomodoroConfig: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: 4 },
|
||||||
|
pomodoroState: { currentRound: 2, isBreak: false, isLongBreak: false, completedRounds: 2 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.focusMinutes).toBe(4 * 25 + 2 * 25); // 150 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes average snooze count', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ snoozeCount: 3 }),
|
||||||
|
makeTimer({ snoozeCount: 1 }),
|
||||||
|
makeTimer({ snoozeCount: 0 }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers);
|
||||||
|
expect(stats.averageSnoozeCount).toBe(2); // (3+1)/2
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by daily range', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ createdAt: now }),
|
||||||
|
makeTimer({ createdAt: daysAgo(2, now) }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers, 'daily', now);
|
||||||
|
expect(stats.created).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by weekly range', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ createdAt: now }),
|
||||||
|
makeTimer({ createdAt: daysAgo(5, now) }),
|
||||||
|
makeTimer({ createdAt: daysAgo(10, now) }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers, 'weekly', now);
|
||||||
|
expect(stats.created).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by monthly range', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ createdAt: now }),
|
||||||
|
makeTimer({ createdAt: daysAgo(20, now) }),
|
||||||
|
makeTimer({ createdAt: daysAgo(40, now) }),
|
||||||
|
];
|
||||||
|
const stats = computeStats(timers, 'monthly', now);
|
||||||
|
expect(stats.created).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeDailyStats', () => {
|
||||||
|
it('returns correct number of days', () => {
|
||||||
|
const daily = computeDailyStats([], 7);
|
||||||
|
expect(daily).toHaveLength(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each day has a valid date string', () => {
|
||||||
|
const daily = computeDailyStats([], 3);
|
||||||
|
for (const d of daily) {
|
||||||
|
expect(d.date).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts timers in correct day buckets', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const today = new Date(now);
|
||||||
|
today.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ createdAt: today.getTime(), state: 'completed', completedAt: today.getTime() }),
|
||||||
|
makeTimer({ createdAt: today.getTime() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const daily = computeDailyStats(timers, 1, now);
|
||||||
|
expect(daily).toHaveLength(1);
|
||||||
|
expect(daily[0].created).toBe(2);
|
||||||
|
expect(daily[0].completed).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeStreak', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 streak with no completed timers', () => {
|
||||||
|
const streak = computeStreak([]);
|
||||||
|
expect(streak.currentStreak).toBe(0);
|
||||||
|
expect(streak.streakStartDate).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts consecutive days with completed timers', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
// Yesterday
|
||||||
|
makeTimer({
|
||||||
|
state: 'completed',
|
||||||
|
completedAt: daysAgo(1, now),
|
||||||
|
createdAt: daysAgo(1, now),
|
||||||
|
}),
|
||||||
|
// Day before yesterday
|
||||||
|
makeTimer({
|
||||||
|
state: 'completed',
|
||||||
|
completedAt: daysAgo(2, now),
|
||||||
|
createdAt: daysAgo(2, now),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const streak = computeStreak(timers, now);
|
||||||
|
expect(streak.currentStreak).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('streak breaks on missed day', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
// Yesterday
|
||||||
|
makeTimer({
|
||||||
|
state: 'completed',
|
||||||
|
completedAt: daysAgo(1, now),
|
||||||
|
createdAt: daysAgo(1, now),
|
||||||
|
}),
|
||||||
|
// 3 days ago (gap on day before yesterday)
|
||||||
|
makeTimer({
|
||||||
|
state: 'completed',
|
||||||
|
completedAt: daysAgo(3, now),
|
||||||
|
createdAt: daysAgo(3, now),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const streak = computeStreak(timers, now);
|
||||||
|
// Streak should be 1 (only yesterday) unless freeze kicks in
|
||||||
|
expect(streak.currentStreak).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('longestStreak is at least currentStreak', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ state: 'completed', completedAt: daysAgo(1, now), createdAt: daysAgo(1, now) }),
|
||||||
|
];
|
||||||
|
const streak = computeStreak(timers, now);
|
||||||
|
expect(streak.longestStreak).toBeGreaterThanOrEqual(streak.currentStreak);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeWeeklySummary', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorageMock.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a weekly summary object', () => {
|
||||||
|
const summary = computeWeeklySummary([]);
|
||||||
|
expect(summary.weekStart).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(summary.weekEnd).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
expect(summary.stats).toBeDefined();
|
||||||
|
expect(summary.dailyBreakdown).toHaveLength(7);
|
||||||
|
expect(summary.streak).toBe(0);
|
||||||
|
expect(summary.topCategory).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies top category', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1); // Monday
|
||||||
|
weekStart.setHours(12, 0, 0, 0);
|
||||||
|
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ category: 'work', createdAt: weekStart.getTime() }),
|
||||||
|
makeTimer({ category: 'work', createdAt: weekStart.getTime() }),
|
||||||
|
makeTimer({ category: 'health', createdAt: weekStart.getTime() }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const summary = computeWeeklySummary(timers, now);
|
||||||
|
expect(summary.topCategory).toBe('work');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeCategoryBreakdown', () => {
|
||||||
|
it('returns empty array when no categories', () => {
|
||||||
|
const result = computeCategoryBreakdown([makeTimer()]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups timers by category', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ category: 'work' }),
|
||||||
|
makeTimer({ category: 'work', state: 'completed' }),
|
||||||
|
makeTimer({ category: 'health' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const breakdown = computeCategoryBreakdown(timers);
|
||||||
|
expect(breakdown).toHaveLength(2);
|
||||||
|
|
||||||
|
const work = breakdown.find((b) => b.categoryId === 'work');
|
||||||
|
expect(work!.count).toBe(2);
|
||||||
|
expect(work!.completed).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sorts by count descending', () => {
|
||||||
|
const timers = [
|
||||||
|
makeTimer({ category: 'health' }),
|
||||||
|
makeTimer({ category: 'work' }),
|
||||||
|
makeTimer({ category: 'work' }),
|
||||||
|
makeTimer({ category: 'work' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const breakdown = computeCategoryBreakdown(timers);
|
||||||
|
expect(breakdown[0].categoryId).toBe('work');
|
||||||
|
});
|
||||||
|
});
|
||||||
350
web/src/lib/stats.ts
Normal file
350
web/src/lib/stats.ts
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
// ── Statistics + Streaks Engine ────────────────────────────────
|
||||||
|
// Timer analytics: created/completed/snoozed/dismissed, on-time rate, focus time, streaks
|
||||||
|
|
||||||
|
import type { Timer } from './timer-engine';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TimerStats {
|
||||||
|
created: number;
|
||||||
|
completed: number;
|
||||||
|
dismissed: number;
|
||||||
|
snoozed: number;
|
||||||
|
active: number;
|
||||||
|
onTimeRate: number; // 0-1 (% acted on within 2 min of firing)
|
||||||
|
focusMinutes: number; // total Pomodoro focus minutes
|
||||||
|
averageSnoozeCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
created: number;
|
||||||
|
completed: number;
|
||||||
|
dismissed: number;
|
||||||
|
snoozed: number;
|
||||||
|
focusMinutes: number;
|
||||||
|
onTimeCount: number; // timers acted on within 2 min
|
||||||
|
firedCount: number; // total timers that fired (for on-time rate)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreakInfo {
|
||||||
|
currentStreak: number; // consecutive days with ≥1 completed timer
|
||||||
|
longestStreak: number;
|
||||||
|
streakStartDate: string | null; // YYYY-MM-DD
|
||||||
|
lastActiveDate: string | null; // YYYY-MM-DD
|
||||||
|
streakFreezeUsed: boolean;
|
||||||
|
streakFreezeAvailable: boolean; // 1 free per week
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeeklySummary {
|
||||||
|
weekStart: string; // YYYY-MM-DD (Monday)
|
||||||
|
weekEnd: string; // YYYY-MM-DD (Sunday)
|
||||||
|
stats: TimerStats;
|
||||||
|
dailyBreakdown: DailyStats[];
|
||||||
|
streak: number;
|
||||||
|
topCategory: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeRange = 'daily' | 'weekly' | 'monthly' | 'all';
|
||||||
|
|
||||||
|
// ── Constants ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ON_TIME_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||||
|
const STREAK_STORAGE_KEY = 'chronomind-streak';
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function toDateStr(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekStart(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const day = d.getDay(); // 0=Sun
|
||||||
|
const diff = day === 0 ? 6 : day - 1; // shift so Monday=0
|
||||||
|
const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate() - diff);
|
||||||
|
return toDateStr(monday.getTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinRange(ts: number, range: TimeRange, now: number): boolean {
|
||||||
|
if (range === 'all') return true;
|
||||||
|
const d = new Date(now);
|
||||||
|
const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||||
|
|
||||||
|
switch (range) {
|
||||||
|
case 'daily':
|
||||||
|
return ts >= startOfDay;
|
||||||
|
case 'weekly':
|
||||||
|
return ts >= startOfDay - 6 * 24 * 60 * 60 * 1000;
|
||||||
|
case 'monthly':
|
||||||
|
return ts >= startOfDay - 29 * 24 * 60 * 60 * 1000;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Core Stats Computation ────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute aggregate timer statistics.
|
||||||
|
*/
|
||||||
|
export function computeStats(timers: Timer[], range: TimeRange = 'all', now: number = Date.now()): TimerStats {
|
||||||
|
const filtered = timers.filter((t) => isWithinRange(t.createdAt, range, now));
|
||||||
|
|
||||||
|
const created = filtered.length;
|
||||||
|
const completed = filtered.filter((t) => t.state === 'completed').length;
|
||||||
|
const dismissed = filtered.filter((t) => t.state === 'dismissed').length;
|
||||||
|
const snoozed = filtered.filter((t) => t.snoozeCount > 0).length;
|
||||||
|
const active = filtered.filter((t) =>
|
||||||
|
['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state)
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// On-time rate: % of fired timers acted on within 2 minutes
|
||||||
|
const firedTimers = filtered.filter((t) => t.firedAt);
|
||||||
|
let onTimeCount = 0;
|
||||||
|
for (const t of firedTimers) {
|
||||||
|
const actedAt = t.dismissedAt ?? t.completedAt;
|
||||||
|
if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) {
|
||||||
|
onTimeCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const onTimeRate = firedTimers.length > 0 ? onTimeCount / firedTimers.length : 0;
|
||||||
|
|
||||||
|
// Focus time: Pomodoro completed work minutes
|
||||||
|
const focusMinutes = filtered
|
||||||
|
.filter((t) => t.type === 'pomodoro' && t.state === 'completed' && t.pomodoroConfig)
|
||||||
|
.reduce((sum, t) => {
|
||||||
|
const config = t.pomodoroConfig!;
|
||||||
|
const completedRounds = t.pomodoroState?.completedRounds ?? config.rounds;
|
||||||
|
return sum + completedRounds * config.workMinutes;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
// Average snooze count
|
||||||
|
const timersWithSnooze = filtered.filter((t) => t.snoozeCount > 0);
|
||||||
|
const averageSnoozeCount = timersWithSnooze.length > 0
|
||||||
|
? timersWithSnooze.reduce((s, t) => s + t.snoozeCount, 0) / timersWithSnooze.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { created, completed, dismissed, snoozed, active, onTimeRate, focusMinutes, averageSnoozeCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute daily breakdown over the last N days.
|
||||||
|
*/
|
||||||
|
export function computeDailyStats(timers: Timer[], days: number = 7, now: number = Date.now()): DailyStats[] {
|
||||||
|
const result: DailyStats[] = [];
|
||||||
|
|
||||||
|
for (let i = days - 1; i >= 0; i--) {
|
||||||
|
const dayStart = new Date(now);
|
||||||
|
dayStart.setDate(dayStart.getDate() - i);
|
||||||
|
dayStart.setHours(0, 0, 0, 0);
|
||||||
|
const dayEnd = new Date(dayStart);
|
||||||
|
dayEnd.setDate(dayEnd.getDate() + 1);
|
||||||
|
|
||||||
|
const dateStr = toDateStr(dayStart.getTime());
|
||||||
|
const dayTimers = timers.filter(
|
||||||
|
(t) => t.createdAt >= dayStart.getTime() && t.createdAt < dayEnd.getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const firedTimers = dayTimers.filter((t) => t.firedAt);
|
||||||
|
let onTimeCount = 0;
|
||||||
|
for (const t of firedTimers) {
|
||||||
|
const actedAt = t.dismissedAt ?? t.completedAt;
|
||||||
|
if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) {
|
||||||
|
onTimeCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusMinutes = dayTimers
|
||||||
|
.filter((t) => t.type === 'pomodoro' && t.state === 'completed' && t.pomodoroConfig)
|
||||||
|
.reduce((sum, t) => {
|
||||||
|
const config = t.pomodoroConfig!;
|
||||||
|
const completedRounds = t.pomodoroState?.completedRounds ?? config.rounds;
|
||||||
|
return sum + completedRounds * config.workMinutes;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
date: dateStr,
|
||||||
|
created: dayTimers.length,
|
||||||
|
completed: dayTimers.filter((t) => t.state === 'completed').length,
|
||||||
|
dismissed: dayTimers.filter((t) => t.state === 'dismissed').length,
|
||||||
|
snoozed: dayTimers.filter((t) => t.snoozeCount > 0).length,
|
||||||
|
focusMinutes,
|
||||||
|
onTimeCount,
|
||||||
|
firedCount: firedTimers.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Streak Tracking ───────────────────────────────────────────
|
||||||
|
|
||||||
|
interface PersistedStreak {
|
||||||
|
longestStreak: number;
|
||||||
|
lastFreezeWeek: string | null; // ISO week string when freeze was last used
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPersistedStreak(): PersistedStreak {
|
||||||
|
if (typeof window === 'undefined') return { longestStreak: 0, lastFreezeWeek: null };
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STREAK_STORAGE_KEY);
|
||||||
|
return raw ? JSON.parse(raw) : { longestStreak: 0, lastFreezeWeek: null };
|
||||||
|
} catch {
|
||||||
|
return { longestStreak: 0, lastFreezeWeek: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function savePersistedStreak(data: PersistedStreak): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
localStorage.setItem(STREAK_STORAGE_KEY, JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute streak info from timer history.
|
||||||
|
*/
|
||||||
|
export function computeStreak(timers: Timer[], now: number = Date.now()): StreakInfo {
|
||||||
|
// Get all dates that had ≥1 completed timer
|
||||||
|
const completedDates = new Set<string>();
|
||||||
|
for (const t of timers) {
|
||||||
|
if (t.state === 'completed' && t.completedAt) {
|
||||||
|
completedDates.add(toDateStr(t.completedAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = toDateStr(now);
|
||||||
|
const persisted = loadPersistedStreak();
|
||||||
|
|
||||||
|
// Walk backwards from today counting consecutive days
|
||||||
|
let currentStreak = 0;
|
||||||
|
let streakStartDate: string | null = null;
|
||||||
|
let lastActiveDate: string | null = null;
|
||||||
|
let streakFreezeUsed = false;
|
||||||
|
let missedOneDay = false;
|
||||||
|
const currentWeek = getWeekStart(now);
|
||||||
|
const freezeAvailable = persisted.lastFreezeWeek !== currentWeek;
|
||||||
|
|
||||||
|
const d = new Date(now);
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
for (let i = 0; i < 365; i++) {
|
||||||
|
const dateStr = toDateStr(d.getTime());
|
||||||
|
|
||||||
|
if (completedDates.has(dateStr)) {
|
||||||
|
currentStreak++;
|
||||||
|
streakStartDate = dateStr;
|
||||||
|
if (!lastActiveDate) lastActiveDate = dateStr;
|
||||||
|
} else if (i === 0) {
|
||||||
|
// Today doesn't count yet (day not over) — check if yesterday had one
|
||||||
|
// Don't break streak if today hasn't ended
|
||||||
|
} else if (!missedOneDay && freezeAvailable && i <= 1) {
|
||||||
|
// Allow one streak freeze per week
|
||||||
|
missedOneDay = true;
|
||||||
|
streakFreezeUsed = true;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
d.setDate(d.getDate() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update longest streak
|
||||||
|
const longestStreak = Math.max(persisted.longestStreak, currentStreak);
|
||||||
|
if (longestStreak > persisted.longestStreak) {
|
||||||
|
savePersistedStreak({ ...persisted, longestStreak });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If freeze was used, persist it
|
||||||
|
if (streakFreezeUsed) {
|
||||||
|
savePersistedStreak({ ...persisted, longestStreak, lastFreezeWeek: currentWeek });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStreak,
|
||||||
|
longestStreak,
|
||||||
|
streakStartDate,
|
||||||
|
lastActiveDate,
|
||||||
|
streakFreezeUsed,
|
||||||
|
streakFreezeAvailable: freezeAvailable && !streakFreezeUsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Weekly Summary ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a weekly summary for sharing.
|
||||||
|
*/
|
||||||
|
export function computeWeeklySummary(timers: Timer[], now: number = Date.now()): WeeklySummary {
|
||||||
|
const weekStartStr = getWeekStart(now);
|
||||||
|
const weekStart = new Date(weekStartStr);
|
||||||
|
const weekEnd = new Date(weekStart);
|
||||||
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||||
|
|
||||||
|
const weekTimers = timers.filter(
|
||||||
|
(t) => t.createdAt >= weekStart.getTime() && t.createdAt < weekEnd.getTime() + 24 * 60 * 60 * 1000
|
||||||
|
);
|
||||||
|
|
||||||
|
const stats = computeStats(weekTimers, 'all', now);
|
||||||
|
const dailyBreakdown = computeDailyStats(timers, 7, now);
|
||||||
|
const { currentStreak } = computeStreak(timers, now);
|
||||||
|
|
||||||
|
// Top category
|
||||||
|
const categoryCounts: Record<string, number> = {};
|
||||||
|
for (const t of weekTimers) {
|
||||||
|
if (t.category) {
|
||||||
|
categoryCounts[t.category] = (categoryCounts[t.category] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const topCategory = Object.entries(categoryCounts).sort(([, a], [, b]) => b - a)[0]?.[0] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekStart: weekStartStr,
|
||||||
|
weekEnd: toDateStr(weekEnd.getTime()),
|
||||||
|
stats,
|
||||||
|
dailyBreakdown,
|
||||||
|
streak: currentStreak,
|
||||||
|
topCategory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Category Breakdown ────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CategoryStats {
|
||||||
|
categoryId: string;
|
||||||
|
count: number;
|
||||||
|
completed: number;
|
||||||
|
onTimeRate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break down stats by category.
|
||||||
|
*/
|
||||||
|
export function computeCategoryBreakdown(timers: Timer[], range: TimeRange = 'all', now: number = Date.now()): CategoryStats[] {
|
||||||
|
const filtered = timers.filter((t) => isWithinRange(t.createdAt, range, now) && t.category);
|
||||||
|
|
||||||
|
const groups: Record<string, Timer[]> = {};
|
||||||
|
for (const t of filtered) {
|
||||||
|
const cat = t.category!;
|
||||||
|
if (!groups[cat]) groups[cat] = [];
|
||||||
|
groups[cat].push(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(groups).map(([categoryId, catTimers]) => {
|
||||||
|
const firedTimers = catTimers.filter((t) => t.firedAt);
|
||||||
|
let onTimeCount = 0;
|
||||||
|
for (const t of firedTimers) {
|
||||||
|
const actedAt = t.dismissedAt ?? t.completedAt;
|
||||||
|
if (actedAt && t.firedAt && actedAt - t.firedAt <= ON_TIME_THRESHOLD_MS) {
|
||||||
|
onTimeCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
categoryId,
|
||||||
|
count: catTimers.length,
|
||||||
|
completed: catTimers.filter((t) => t.state === 'completed').length,
|
||||||
|
onTimeRate: firedTimers.length > 0 ? onTimeCount / firedTimers.length : 0,
|
||||||
|
};
|
||||||
|
}).sort((a, b) => b.count - a.count);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user