Add dual substance tracking with dedicated pages and enhanced UI

- Add dropdown menu on profile icon with navigation options
- Create separate tracking pages for Nicotine and Marijuana
- Add interactive usage trend graphs using recharts
- Update Log Usage button to prompt for substance selection each time
- Update calendar to show different colors:
  - Gray: No usage
  - Red: Nicotine only
  - Green: Marijuana only
  - Split red/green: Both substances
- Add substance-specific stats cards on dashboard
- Add inspirational message "One day at a time..." on tracking pages
- Show both substance counts in calendar day cells

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Avery Felts 2026-01-24 01:20:59 -07:00
parent 750c5af465
commit 4a26c1ccdc
12 changed files with 664 additions and 140 deletions

100
bun.lock
View File

@ -9,6 +9,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
@ -23,6 +24,7 @@
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0",
},
"devDependencies": {
@ -243,6 +245,8 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
"@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
@ -251,6 +255,8 @@
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="],
"@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
@ -259,6 +265,8 @@
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
@ -285,8 +293,14 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "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" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="],
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
@ -321,6 +335,24 @@
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
@ -333,6 +365,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.53.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/type-utils": "8.53.1", "@typescript-eslint/utils": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.53.1", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-cFYYFZ+oQFi6hUnBTbLRXfTJiaQtYE3t4O692agbBl+2Zy+eqSKWtPjhPXJu1G7j4RLjKgeJPDdq3EqOwmX5Ag=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.53.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", "@typescript-eslint/typescript-estree": "8.53.1", "@typescript-eslint/visitor-keys": "8.53.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg=="],
@ -473,6 +507,28 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
"d3-scale": ["d3-scale@4.0.2", "", { "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" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
"d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
@ -487,6 +543,8 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
@ -525,6 +583,8 @@
"es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="],
"es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
@ -561,6 +621,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
@ -637,12 +699,16 @@
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
"iron-webcrypto": ["iron-webcrypto@2.0.0", "", { "dependencies": { "uint8array-extras": "^1.5.0" } }, "sha512-rtffZKDUHciZElM8mjFCufBC7nVhCxHYyWHESqs89OioEDz4parOofd8/uhrejh/INhQFfYQfByS22LlezR9sQ=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
@ -847,16 +913,26 @@
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"react-redux": ["react-redux@9.2.0", "", { "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" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"recharts": ["recharts@3.7.0", "", { "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" }, "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" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="],
"redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="],
"redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
@ -931,6 +1007,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@ -975,6 +1053,8 @@
"use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="],
"victory-vendor": ["victory-vendor@37.3.6", "", { "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" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
@ -1021,14 +1101,28 @@
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
@ -1037,6 +1131,8 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@reduxjs/toolkit/immer": ["immer@11.1.3", "", {}, "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@ -1079,12 +1175,16 @@
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],

View File

@ -14,6 +14,7 @@
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
@ -28,6 +29,7 @@
"react": "19.2.3",
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {

View File

@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { SubstanceTrackingPage } from '@/components/SubstanceTrackingPage';
export default async function MarijuanaTrackingPage() {
const session = await getSession();
if (!session?.user) {
redirect('/login');
}
return <SubstanceTrackingPage user={session.user} substance="weed" />;
}

View File

@ -0,0 +1,13 @@
import { redirect } from 'next/navigation';
import { getSession } from '@/lib/session';
import { SubstanceTrackingPage } from '@/components/SubstanceTrackingPage';
export default async function NicotineTrackingPage() {
const session = await getSession();
if (!session?.user) {
redirect('/login');
}
return <SubstanceTrackingPage user={session.user} substance="nicotine" />;
}

View File

@ -8,9 +8,6 @@ import {
savePreferencesAsync,
saveUsageEntryAsync,
shouldShowUsagePrompt,
hasOneWeekOfData,
calculateWeeklyAverage,
generateQuitPlan,
UserPreferences,
UsageEntry,
} from '@/lib/storage';
@ -18,7 +15,6 @@ import { UserHeader } from './UserHeader';
import { SetupWizard } from './SetupWizard';
import { UsagePromptDialog } from './UsagePromptDialog';
import { UsageCalendar } from './UsageCalendar';
import { QuitPlanCard } from './QuitPlanCard';
import { StatsCard } from './StatsCard';
import { Button } from '@/components/ui/button';
import { PlusCircle } from 'lucide-react';
@ -80,7 +76,7 @@ export function Dashboard({ user }: DashboardProps) {
setRefreshKey(prev => prev + 1);
};
const handleUsageSubmit = async (count: number) => {
const handleUsageSubmit = async (count: number, substance: 'nicotine' | 'weed') => {
if (!preferences) {
setShowUsagePrompt(false);
return;
@ -91,7 +87,7 @@ export function Dashboard({ user }: DashboardProps) {
await saveUsageEntryAsync({
date: today,
count,
substance: preferences.substance,
substance,
});
}
@ -102,22 +98,6 @@ export function Dashboard({ user }: DashboardProps) {
setRefreshKey(prev => prev + 1);
};
const handleGeneratePlan = async () => {
if (!preferences) return;
const plan = generateQuitPlan(preferences.substance);
const updatedPrefs = { ...preferences, quitPlan: plan };
await savePreferencesAsync(updatedPrefs);
setPreferences(updatedPrefs);
};
const getDaysTracked = (): number => {
if (!preferences?.trackingStartDate) return 0;
const startDate = new Date(preferences.trackingStartDate);
const today = new Date();
return Math.floor((today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
@ -141,7 +121,7 @@ export function Dashboard({ user }: DashboardProps) {
className="h-14 px-6 rounded-full shadow-lg bg-gradient-to-r from-primary to-primary/80 hover:from-primary/90 hover:to-primary/70"
>
<PlusCircle className="mr-2 h-5 w-5" />
Log Puff
Log Usage
</Button>
</div>
@ -150,20 +130,13 @@ export function Dashboard({ user }: DashboardProps) {
<UsageCalendar
key={refreshKey}
usageData={usageData}
substance={preferences.substance}
onDataUpdate={loadData}
userId={user.id}
/>
</div>
<div className="space-y-6">
<StatsCard key={`stats-${refreshKey}`} usageData={usageData} substance={preferences.substance} />
<QuitPlanCard
plan={preferences.quitPlan}
onGeneratePlan={handleGeneratePlan}
hasEnoughData={hasOneWeekOfData(preferences.substance)}
daysTracked={getDaysTracked()}
currentAverage={calculateWeeklyAverage(preferences.substance)}
/>
<StatsCard key={`stats-nicotine-${refreshKey}`} usageData={usageData} substance="nicotine" />
<StatsCard key={`stats-weed-${refreshKey}`} usageData={usageData} substance="weed" />
</div>
</div>
</>
@ -177,7 +150,6 @@ export function Dashboard({ user }: DashboardProps) {
open={showUsagePrompt}
onClose={() => setShowUsagePrompt(false)}
onSubmit={handleUsageSubmit}
substance={preferences.substance}
userId={user.id}
/>
)}

View File

@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageEntry } from '@/lib/storage';
import { Cigarette, Leaf } from 'lucide-react';
interface StatsCardProps {
usageData: UsageEntry[];
@ -48,10 +49,22 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
const totalUsage = substanceData.reduce((sum, e) => sum + e.count, 0);
const totalDays = substanceData.length;
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
const unitLabel = substance === 'nicotine' ? 'puffs' : 'hits';
const iconColor = substance === 'nicotine' ? 'text-red-400' : 'text-green-400';
const borderColor = substance === 'nicotine' ? 'border-red-500/20' : 'border-green-500/20';
const bgGradient = substance === 'nicotine'
? 'from-red-500/5 to-transparent'
: 'from-green-500/5 to-transparent';
return (
<Card className="bg-card/80 backdrop-blur-sm">
<CardHeader>
<CardTitle>Your Stats</CardTitle>
<Card className={`bg-card/80 backdrop-blur-sm border ${borderColor} bg-gradient-to-br ${bgGradient}`}>
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-2">
<SubstanceIcon className={`h-5 w-5 ${iconColor}`} />
<span>{substanceLabel} Stats</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
@ -65,7 +78,7 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
</div>
<div className="bg-muted p-4 rounded-lg text-center">
<p className="text-2xl font-bold">{streak}</p>
<p className="text-sm text-muted-foreground">Smoke-free days</p>
<p className="text-sm text-muted-foreground">Free days</p>
</div>
<div className="bg-muted p-4 rounded-lg text-center">
<p className="text-2xl font-bold">{totalDays}</p>
@ -74,8 +87,8 @@ export function StatsCard({ usageData, substance }: StatsCardProps) {
</div>
{streak > 0 && (
<div className="mt-4 bg-green-100 dark:bg-green-900/30 p-4 rounded-lg text-center">
<p className="text-lg">🎉 {streak} day{streak > 1 ? 's' : ''} smoke-free!</p>
<div className={`mt-4 ${substance === 'nicotine' ? 'bg-red-100 dark:bg-red-900/30' : 'bg-green-100 dark:bg-green-900/30'} p-4 rounded-lg text-center`}>
<p className="text-lg">🎉 {streak} day{streak > 1 ? 's' : ''} {substanceLabel.toLowerCase()}-free!</p>
<p className="text-sm text-muted-foreground">Keep up the great work!</p>
</div>
)}

View File

@ -0,0 +1,79 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { User } from '@/lib/session';
import { fetchUsageData, UsageEntry } from '@/lib/storage';
import { UserHeader } from './UserHeader';
import { StatsCard } from './StatsCard';
import { UsageTrendGraph } from './UsageTrendGraph';
import { Cigarette, Leaf } from 'lucide-react';
interface SubstanceTrackingPageProps {
user: User;
substance: 'nicotine' | 'weed';
}
export function SubstanceTrackingPage({ user, substance }: SubstanceTrackingPageProps) {
const [usageData, setUsageData] = useState<UsageEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const loadData = useCallback(async () => {
const usage = await fetchUsageData();
setUsageData(usage);
setIsLoading(false);
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
const SubstanceIcon = substance === 'nicotine' ? Cigarette : Leaf;
const gradientColors = substance === 'nicotine'
? 'from-red-500/20 to-orange-500/20'
: 'from-green-500/20 to-emerald-500/20';
const borderColor = substance === 'nicotine' ? 'border-red-500/30' : 'border-green-500/30';
const iconColor = substance === 'nicotine' ? 'text-red-400' : 'text-green-400';
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-pulse text-lg text-white">Loading...</div>
</div>
);
}
return (
<div className="min-h-screen">
<UserHeader user={user} />
<main className="container mx-auto px-4 py-8">
{/* Substance Header */}
<div className={`mb-8 p-6 rounded-xl bg-gradient-to-r ${gradientColors} border ${borderColor} backdrop-blur-sm`}>
<div className="flex items-center gap-4">
<div className={`p-3 rounded-full bg-background/50 ${iconColor}`}>
<SubstanceIcon className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold text-white">{substanceLabel} Tracking</h1>
<p className="text-white/70 mt-1">Monitor your {substanceLabel.toLowerCase()} usage and progress</p>
</div>
</div>
</div>
{/* Inspirational Message */}
<div className="mb-8 text-center">
<p className="text-2xl font-light text-white/80 italic">
&quot;One day at a time...&quot;
</p>
</div>
{/* Stats and Graph */}
<div className="grid gap-6 md:grid-cols-2">
<StatsCard usageData={usageData} substance={substance} />
<UsageTrendGraph usageData={usageData} substance={substance} />
</div>
</main>
</div>
);
}

View File

@ -12,21 +12,27 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { UsageEntry, getUsageForDate, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
import { UsageEntry, setUsageForDateAsync, clearDayDataAsync } from '@/lib/storage';
import { ChevronLeftIcon, ChevronRightIcon, Cigarette, Leaf } from 'lucide-react';
interface UsageCalendarProps {
usageData: UsageEntry[];
substance: 'nicotine' | 'weed';
onDataUpdate: () => void;
userId: string;
}
export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalendarProps) {
export function UsageCalendar({ usageData, onDataUpdate }: UsageCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [editCount, setEditCount] = useState('');
const [editNicotineCount, setEditNicotineCount] = useState('');
const [editWeedCount, setEditWeedCount] = useState('');
const [isEditing, setIsEditing] = useState(false);
const getUsageForDate = (date: Date, substance: 'nicotine' | 'weed'): number => {
const dateStr = date.toISOString().split('T')[0];
const entry = usageData.find((e) => e.date === dateStr && e.substance === substance);
return entry?.count ?? 0;
};
const handleDateSelect = (date: Date | undefined) => {
if (!date) return;
@ -36,60 +42,86 @@ export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalen
if (date > today) return;
setSelectedDate(date);
const dateStr = date.toISOString().split('T')[0];
const currentCount = getUsageForDate(dateStr, substance);
setEditCount(currentCount.toString());
const nicotineCount = getUsageForDate(date, 'nicotine');
const weedCount = getUsageForDate(date, 'weed');
setEditNicotineCount(nicotineCount.toString());
setEditWeedCount(weedCount.toString());
setIsEditing(true);
};
const handleSave = async () => {
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
const newCount = parseInt(editCount, 10) || 0;
await setUsageForDateAsync(dateStr, newCount, substance);
const newNicotineCount = parseInt(editNicotineCount, 10) || 0;
const newWeedCount = parseInt(editWeedCount, 10) || 0;
await Promise.all([
setUsageForDateAsync(dateStr, newNicotineCount, 'nicotine'),
setUsageForDateAsync(dateStr, newWeedCount, 'weed'),
]);
onDataUpdate();
}
setIsEditing(false);
setSelectedDate(undefined);
setEditCount('');
setEditNicotineCount('');
setEditWeedCount('');
};
const handleCancel = () => {
setIsEditing(false);
setSelectedDate(undefined);
setEditCount('');
setEditNicotineCount('');
setEditWeedCount('');
};
const handleClearDay = async () => {
if (selectedDate) {
const dateStr = selectedDate.toISOString().split('T')[0];
await clearDayDataAsync(dateStr, substance);
await Promise.all([
clearDayDataAsync(dateStr, 'nicotine'),
clearDayDataAsync(dateStr, 'weed'),
]);
onDataUpdate();
}
setIsEditing(false);
setSelectedDate(undefined);
setEditCount('');
setEditNicotineCount('');
setEditWeedCount('');
};
const getUsageCount = useCallback((date: Date): number => {
const dateStr = date.toISOString().split('T')[0];
const entry = usageData.find((e) => e.date === dateStr && e.substance === substance);
return entry?.count ?? 0;
}, [usageData, substance]);
const getColorStyle = useCallback((nicotineCount: number, weedCount: number): React.CSSProperties => {
const hasNicotine = nicotineCount > 0;
const hasWeed = weedCount > 0;
const getColorStyle = useCallback((count: number): React.CSSProperties => {
if (count === 0) {
if (!hasNicotine && !hasWeed) {
// No usage - neutral gray
return {
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
background: 'linear-gradient(135deg, rgba(100, 100, 100, 0.3) 0%, rgba(80, 80, 80, 0.3) 100%)',
color: 'white',
};
}
// Red gradient for any usage - more intense red for higher counts
const intensity = Math.min(count / 10, 1); // Max intensity at 10+ uses
const lightRed = `rgba(239, 68, 68, ${0.6 + intensity * 0.4})`;
const darkRed = `rgba(185, 28, 28, ${0.7 + intensity * 0.3})`;
if (hasNicotine && hasWeed) {
// Both substances - split gradient (red/green)
return {
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.8) 0%, rgba(239, 68, 68, 0.6) 50%, rgba(34, 197, 94, 0.6) 50%, rgba(34, 197, 94, 0.8) 100%)',
color: 'white',
};
}
if (hasWeed) {
// Marijuana only - green gradient
const intensity = Math.min(weedCount / 10, 1);
return {
background: `linear-gradient(135deg, rgba(34, 197, 94, ${0.5 + intensity * 0.4}) 0%, rgba(22, 163, 74, ${0.6 + intensity * 0.4}) 100%)`,
color: 'white',
};
}
// Nicotine only - red gradient
const intensity = Math.min(nicotineCount / 10, 1);
return {
background: `linear-gradient(135deg, ${lightRed} 0%, ${darkRed} 100%)`,
background: `linear-gradient(135deg, rgba(239, 68, 68, ${0.5 + intensity * 0.4}) 0%, rgba(185, 28, 28, ${0.6 + intensity * 0.4}) 100%)`,
color: 'white',
};
}, []);
@ -101,13 +133,15 @@ export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalen
const dateToCheck = new Date(date);
dateToCheck.setHours(0, 0, 0, 0);
const isFuture = dateToCheck > today;
const count = isFuture ? -1 : getUsageCount(date);
const colorStyle = count >= 0 ? getColorStyle(count) : {};
const nicotineCount = isFuture ? 0 : getUsageForDate(date, 'nicotine');
const weedCount = isFuture ? 0 : getUsageForDate(date, 'weed');
const colorStyle = !isFuture ? getColorStyle(nicotineCount, weedCount) : {};
return (
<button
{...props}
style={count >= 0 ? colorStyle : undefined}
style={!isFuture ? colorStyle : undefined}
className={`relative w-full h-full p-2 text-sm rounded-md transition-all hover:opacity-80 ${
isFuture ? 'text-muted-foreground opacity-30 cursor-not-allowed' : 'cursor-pointer shadow-sm'
} ${modifiers.today ? 'ring-2 ring-white ring-offset-2 ring-offset-background' : ''}`}
@ -115,14 +149,23 @@ export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalen
disabled={isFuture}
>
<span className="font-medium">{date.getDate()}</span>
{count > 0 && (
<span className="absolute bottom-0.5 right-1 text-[10px] font-bold bg-black/20 px-1 rounded">
{count}
</span>
{(nicotineCount > 0 || weedCount > 0) && (
<div className="absolute bottom-0.5 right-1 flex gap-0.5">
{nicotineCount > 0 && (
<span className="text-[9px] font-bold bg-red-900/50 px-1 rounded">
{nicotineCount}
</span>
)}
{weedCount > 0 && (
<span className="text-[9px] font-bold bg-green-900/50 px-1 rounded">
{weedCount}
</span>
)}
</div>
)}
</button>
);
}, [getUsageCount, getColorStyle]);
}, [usageData, getColorStyle]);
return (
<>
@ -147,12 +190,20 @@ export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalen
/>
<div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, #10b981, #059669)' }} />
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, rgba(100,100,100,0.3), rgba(80,80,80,0.3))' }} />
<span>No usage</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.7), rgba(185,28,28,0.8))' }} />
<span>Has usage</span>
<span>Nicotine</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, rgba(34,197,94,0.7), rgba(22,163,74,0.8))' }} />
<span>Marijuana</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded" style={{ background: 'linear-gradient(135deg, rgba(239,68,68,0.8) 50%, rgba(34,197,94,0.8) 50%)' }} />
<span>Both</span>
</div>
</div>
<p className="mt-2 text-xs text-muted-foreground">
@ -169,19 +220,39 @@ export function UsageCalendar({ usageData, substance, onDataUpdate }: UsageCalen
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="editCount">Total puffs for this day</Label>
<Input
id="editCount"
type="number"
min="0"
value={editCount}
onChange={(e) => setEditCount(e.target.value)}
className="text-center text-lg"
/>
<p className="text-xs text-muted-foreground text-center">
This will replace the current value
</p>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-red-500/20">
<Cigarette className="h-4 w-4 text-red-400" />
</div>
<div className="flex-1">
<Label htmlFor="nicotineCount" className="text-sm">Nicotine (puffs)</Label>
<Input
id="nicotineCount"
type="number"
min="0"
value={editNicotineCount}
onChange={(e) => setEditNicotineCount(e.target.value)}
className="text-center"
/>
</div>
</div>
<div className="flex items-center gap-3">
<div className="p-2 rounded-full bg-green-500/20">
<Leaf className="h-4 w-4 text-green-400" />
</div>
<div className="flex-1">
<Label htmlFor="weedCount" className="text-sm">Marijuana (hits)</Label>
<Input
id="weedCount"
type="number"
min="0"
value={editWeedCount}
onChange={(e) => setEditWeedCount(e.target.value)}
className="text-center"
/>
</div>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} className="flex-1">

View File

@ -12,12 +12,12 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { getUsageForDate } from '@/lib/storage';
import { Cigarette, Leaf } from 'lucide-react';
interface UsagePromptDialogProps {
open: boolean;
onClose: () => void;
onSubmit: (count: number) => void;
substance: 'nicotine' | 'weed';
onSubmit: (count: number, substance: 'nicotine' | 'weed') => void;
userId: string;
}
@ -25,85 +25,109 @@ export function UsagePromptDialog({
open,
onClose,
onSubmit,
substance,
userId,
}: UsagePromptDialogProps) {
const [wantsToLog, setWantsToLog] = useState<boolean | null>(null);
const [step, setStep] = useState<'select' | 'count'>('select');
const [selectedSubstance, setSelectedSubstance] = useState<'nicotine' | 'weed' | null>(null);
const [count, setCount] = useState('1');
const substanceLabel = substance === 'nicotine' ? 'a puff or cigarette' : 'a hit';
const substanceLabelPlural = substance === 'nicotine' ? 'puffs/cigarettes' : 'hits';
const today = new Date().toISOString().split('T')[0];
const todayCount = typeof window !== 'undefined' ? getUsageForDate(today, substance, userId) : 0;
const nicotineCount = typeof window !== 'undefined' ? getUsageForDate(today, 'nicotine', userId) : 0;
const weedCount = typeof window !== 'undefined' ? getUsageForDate(today, 'weed', userId) : 0;
const handleSubstanceSelect = (substance: 'nicotine' | 'weed') => {
setSelectedSubstance(substance);
setStep('count');
};
const handleSubmit = () => {
if (wantsToLog === true && count) {
onSubmit(parseInt(count, 10));
if (selectedSubstance && count) {
onSubmit(parseInt(count, 10), selectedSubstance);
}
setWantsToLog(null);
resetAndClose();
};
const handleBack = () => {
setStep('select');
setSelectedSubstance(null);
setCount('1');
};
const handleSkip = () => {
setWantsToLog(null);
const resetAndClose = () => {
setStep('select');
setSelectedSubstance(null);
setCount('1');
onClose();
};
const handleClose = () => {
setWantsToLog(null);
setCount('1');
onClose();
};
const substanceLabel = selectedSubstance === 'nicotine' ? 'puffs/cigarettes' : 'hits';
const singularLabel = selectedSubstance === 'nicotine' ? 'puff' : 'hit';
return (
<Dialog open={open} onOpenChange={handleClose}>
<Dialog open={open} onOpenChange={resetAndClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Log Your Usage</DialogTitle>
<DialogDescription>
Log each time you smoke to track your progress. You can log multiple times throughout the day.
Log each time you smoke to track your progress.
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{todayCount > 0 && (
<div className="bg-muted/50 p-3 rounded-lg text-center">
<p className="text-sm text-muted-foreground">Today&apos;s total so far</p>
<p className="text-2xl font-bold">{todayCount} {substanceLabelPlural}</p>
{/* Today's totals */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-red-500/10 border border-red-500/20 p-3 rounded-lg text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<Cigarette className="h-4 w-4 text-red-400" />
<span className="text-sm text-red-400">Nicotine</span>
</div>
<p className="text-xl font-bold text-white">{nicotineCount}</p>
<p className="text-xs text-muted-foreground">today</p>
</div>
)}
<div className="bg-green-500/10 border border-green-500/20 p-3 rounded-lg text-center">
<div className="flex items-center justify-center gap-2 mb-1">
<Leaf className="h-4 w-4 text-green-400" />
<span className="text-sm text-green-400">Marijuana</span>
</div>
<p className="text-xl font-bold text-white">{weedCount}</p>
<p className="text-xs text-muted-foreground">today</p>
</div>
</div>
{wantsToLog === null ? (
{step === 'select' ? (
<div className="space-y-4">
<p className="text-center font-medium">
Did you just have {substanceLabel}?
What are you logging?
</p>
<div className="flex gap-4 justify-center">
<div className="grid grid-cols-2 gap-4">
<Button
variant="outline"
size="lg"
onClick={() => setWantsToLog(true)}
className="w-32"
onClick={() => handleSubstanceSelect('nicotine')}
className="h-24 flex-col gap-2 border-red-500/30 hover:bg-red-500/10 hover:border-red-500/50"
>
Yes, log it
<Cigarette className="h-8 w-8 text-red-400" />
<span>Nicotine</span>
</Button>
<Button
variant="outline"
size="lg"
onClick={handleSkip}
className="w-32"
onClick={() => handleSubstanceSelect('weed')}
className="h-24 flex-col gap-2 border-green-500/30 hover:bg-green-500/10 hover:border-green-500/50"
>
No, skip
<Leaf className="h-8 w-8 text-green-400" />
<span>Marijuana</span>
</Button>
</div>
<Button variant="ghost" onClick={resetAndClose} className="w-full">
Cancel
</Button>
</div>
) : (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="count">
How many {substanceLabelPlural} did you just have?
How many {substanceLabel} did you just have?
</Label>
<Input
id="count"
@ -113,17 +137,22 @@ export function UsagePromptDialog({
value={count}
onChange={(e) => setCount(e.target.value)}
className="text-center text-lg"
autoFocus
/>
<p className="text-xs text-muted-foreground text-center">
This will be added to today&apos;s total
</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWantsToLog(null)} className="flex-1">
<Button variant="outline" onClick={handleBack} className="flex-1">
Back
</Button>
<Button onClick={handleSubmit} disabled={!count || parseInt(count) < 1} className="flex-1">
Log {count || 1} {parseInt(count) === 1 ? (substance === 'nicotine' ? 'puff' : 'hit') : substanceLabelPlural}
<Button
onClick={handleSubmit}
disabled={!count || parseInt(count) < 1}
className="flex-1"
>
Log {count || 1} {parseInt(count) === 1 ? singularLabel : substanceLabel}
</Button>
</div>
</div>

View File

@ -0,0 +1,134 @@
'use client';
import { useMemo } from 'react';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Area,
AreaChart,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { UsageEntry } from '@/lib/storage';
interface UsageTrendGraphProps {
usageData: UsageEntry[];
substance: 'nicotine' | 'weed';
}
export function UsageTrendGraph({ usageData, substance }: UsageTrendGraphProps) {
const chartData = useMemo(() => {
const substanceData = usageData.filter((e) => e.substance === substance);
const today = new Date();
const last30Days: { date: string; count: number; label: string }[] = [];
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0];
const entry = substanceData.find((e) => e.date === dateStr);
last30Days.push({
date: dateStr,
count: entry?.count ?? 0,
label: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
});
}
return last30Days;
}, [usageData, substance]);
const substanceColor = substance === 'nicotine' ? '#ef4444' : '#22c55e';
const substanceLabel = substance === 'nicotine' ? 'Nicotine' : 'Marijuana';
const average = useMemo(() => {
const total = chartData.reduce((sum, d) => sum + d.count, 0);
const daysWithData = chartData.filter((d) => d.count > 0).length;
return daysWithData > 0 ? Math.round(total / daysWithData) : 0;
}, [chartData]);
const trend = useMemo(() => {
const firstHalf = chartData.slice(0, 15);
const secondHalf = chartData.slice(15);
const firstAvg = firstHalf.reduce((sum, d) => sum + d.count, 0) / 15;
const secondAvg = secondHalf.reduce((sum, d) => sum + d.count, 0) / 15;
if (secondAvg < firstAvg * 0.9) return 'decreasing';
if (secondAvg > firstAvg * 1.1) return 'increasing';
return 'stable';
}, [chartData]);
return (
<Card className="bg-card/80 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span>{substanceLabel} Usage Trend</span>
<span className="text-sm font-normal text-muted-foreground">
Last 30 days
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id={`gradient-${substance}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={substanceColor} stopOpacity={0.3} />
<stop offset="95%" stopColor={substanceColor} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.1)" />
<XAxis
dataKey="label"
stroke="rgba(255,255,255,0.5)"
fontSize={12}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
stroke="rgba(255,255,255,0.5)"
fontSize={12}
tickLine={false}
axisLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(0,0,0,0.8)',
border: 'none',
borderRadius: '8px',
color: 'white',
}}
labelStyle={{ color: 'rgba(255,255,255,0.7)' }}
/>
<Area
type="monotone"
dataKey="count"
stroke={substanceColor}
strokeWidth={2}
fill={`url(#gradient-${substance})`}
name={substance === 'nicotine' ? 'Puffs' : 'Hits'}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="bg-muted/50 p-3 rounded-lg text-center">
<p className="text-2xl font-bold">{average}</p>
<p className="text-sm text-muted-foreground">Daily Average</p>
</div>
<div className="bg-muted/50 p-3 rounded-lg text-center">
<p className="text-2xl font-bold capitalize">{trend}</p>
<p className="text-sm text-muted-foreground">
{trend === 'decreasing' ? '📉 Great progress!' : trend === 'increasing' ? '📈 Stay strong!' : '➡️ Holding steady'}
</p>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -2,9 +2,18 @@
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { User } from '@/lib/session';
import { fetchPreferences } from '@/lib/storage';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Cigarette, Leaf, LogOut, Home } from 'lucide-react';
interface UserHeaderProps {
user: User;
@ -12,6 +21,7 @@ interface UserHeaderProps {
export function UserHeader({ user }: UserHeaderProps) {
const [userName, setUserName] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
const loadUserName = async () => {
@ -37,7 +47,12 @@ export function UserHeader({ user }: UserHeaderProps) {
}}>
<div className="container mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-white">QuitTraq</h1>
<h1
className="text-2xl font-bold text-white cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => router.push('/')}
>
QuitTraq
</h1>
{userName && (
<p className="text-white/90 text-lg hidden sm:block">
Welcome {userName}, you got this!
@ -46,18 +61,36 @@ export function UserHeader({ user }: UserHeaderProps) {
</div>
<div className="flex items-center gap-4">
<Avatar className="h-10 w-10 ring-2 ring-white/30">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-white/20 text-white">{initials}</AvatarFallback>
</Avatar>
<Button
variant="outline"
size="sm"
onClick={handleLogout}
className="bg-white/10 border-white/20 text-white hover:bg-white/20 hover:text-white"
>
Sign out
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="focus:outline-none focus:ring-2 focus:ring-white/30 rounded-full">
<Avatar className="h-10 w-10 ring-2 ring-white/30 cursor-pointer hover:ring-white/50 transition-all">
<AvatarImage src={user.profilePictureUrl ?? undefined} alt={userName || 'User'} />
<AvatarFallback className="bg-white/20 text-white">{initials}</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => router.push('/')} className="cursor-pointer">
<Home className="mr-2 h-4 w-4" />
Dashboard
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/track/nicotine')} className="cursor-pointer">
<Cigarette className="mr-2 h-4 w-4 text-red-500" />
Track Nicotine Usage
</DropdownMenuItem>
<DropdownMenuItem onClick={() => router.push('/track/marijuana')} className="cursor-pointer">
<Leaf className="mr-2 h-4 w-4 text-green-500" />
Track Marijuana Usage
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} className="cursor-pointer text-red-500">
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{userName && (

View File

@ -0,0 +1,65 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
}