Fix push notifications: Edge runtime compatibility, minute-precision hourly reminders, and timezone sync

This commit is contained in:
Avery Felts 2026-01-27 16:47:51 -07:00
parent 9f0eb9a5bd
commit cec4096e1f
15 changed files with 663 additions and 62 deletions

View File

@ -29,6 +29,7 @@
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"web-push": "^3.6.7",
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0", "@cloudflare/workers-types": "^4.20250121.0",
@ -36,12 +37,13 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.4", "eslint-config-next": "16.1.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "^5",
"wrangler": "^4", "wrangler": "^4.61.0",
}, },
}, },
}, },
@ -216,15 +218,15 @@
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="], "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.11.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260115.0" }, "optionalPeers": ["workerd"] }, "sha512-z3hxFajL765VniNPGV0JRStZolNz63gU3B3AktwoGdDlnQvz5nP+Ah4RL04PONlZQjwmDdGHowEStJ94+RsaJg=="],
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260120.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-JLHx3p5dpwz4wjVSis45YNReftttnI3ndhdMh5BUbbpdreN/g0jgxNt5Qp9tDFqEKl++N63qv+hxJiIIvSLR+Q=="], "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260124.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-VuqscLhiiVIf7t/dcfkjtT0LKJH+a06KUFwFTHgdTcqyLbFZ44u1SLpOONu5fyva4A9MdaKh9a+Z/tBC1d76nw=="],
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260120.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1Md2tCRhZjwajsZNOiBeOVGiS3zbpLPzUDjHr4+XGTXWOA6FzzwScJwQZLa0Doc28Cp4Nr1n7xGL0Dwiz1XuOA=="], "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260124.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PfnjoFooPgRKFUIZcEP9irnn5Y7OgXinjM+IMlKTdEyLWjMblLsbsqAgydf75+ii0715xAeUlWQjZrWdyOZjMw=="],
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260120.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O0mIfJfvU7F8N5siCoRDaVDuI12wkz2xlG4zK6/Ct7U9c9FiE0ViXNFWXFQm5PPj+qbkNRyhjUwhP+GCKTk5EQ=="], "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260124.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KSkZl4kwcWeFXI7qsaLlMnKwjgdZwI0OEARjyZpiHCxJCqAqla9XxQKNDscL2Z3qUflIo30i+uteGbFrhzuVGQ=="],
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260120.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-aRHO/7bjxVpjZEmVVcpmhbzpN6ITbFCxuLLZSW0H9O0C0w40cDCClWSi19T87Ax/PQcYjFNT22pTewKsupkckA=="], "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260124.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-61xjSUNk745EVV4vXZP0KGyLCatcmamfBB+dcdQ8kDr6PrNU4IJ1kuQFSJdjybyDhJRm4TpGVywq+9hREuF7xA=="],
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260120.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ASZIz1E8sqZQqQCgcfY1PJbBpUDrxPt8NZ+lqNil0qxnO4qX38hbCsdDF2/TDAuq0Txh7nu8ztgTelfNDlb4EA=="], "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260124.0", "", { "os": "win32", "cpu": "x64" }, "sha512-j9O11pwQQV6Vi3peNrJoyIas3SrZHlPj0Ah+z1hDW9o1v35euVBQJw/PuzjPOXxTFUlGQoMJdfzPsO9xP86g7A=="],
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260124.0", "", {}, "sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA=="], "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260124.0", "", {}, "sha512-h6TJlew6AtGuEXFc+k5ifalk+tg3fkg0lla6XbMAb2AKKfJGwlFNTwW2xyT/Ha92KY631CIJ+Ace08DPdFohdA=="],
@ -760,6 +762,8 @@
"@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="],
"@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
"@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/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=="], "@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=="],
@ -830,6 +834,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
@ -862,6 +868,8 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="], "asn1js": ["asn1js@3.0.7", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ=="],
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
@ -886,6 +894,8 @@
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="], "bowser": ["bowser@2.13.1", "", {}, "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw=="],
@ -898,6 +908,8 @@
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
@ -1010,6 +1022,8 @@
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"eciesjs": ["eciesjs@0.4.16", "", { "dependencies": { "@ecies/ciphers": "^0.2.4", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw=="], "eciesjs": ["eciesjs@0.4.16", "", { "dependencies": { "@ecies/ciphers": "^0.2.4", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-dS5cbA9rA2VR4Ybuvhg6jvdmp46ubLn3E+px8cG/35aEDNclrqoCjg6mt0HYZ/M+OoESS3jSkCrqk1kWAEhWAw=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@ -1198,6 +1212,10 @@
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], "human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
@ -1312,6 +1330,10 @@
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
@ -1378,7 +1400,9 @@
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
"miniflare": ["miniflare@4.20260120.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260120.0", "ws": "8.18.0", "youch": "4.1.0-beta.10", "zod": "^3.25.76" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-XXZyE2pDKMtP5OLuv0LPHEAzIYhov4jrYjcqrhhqtxGGtXneWOHvXIPo+eV8sqwqWd3R7j4DlEKcyb+87BR49Q=="], "miniflare": ["miniflare@4.20260124.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260124.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-Co8onUh+POwOuLty4myQg+Nzg9/xZ5eAJc1oqYBzRovHd/XIpb5WAnRVaubcfAQJ85awWtF3yXUHCDx6cIaN3w=="],
"minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@ -1534,6 +1558,8 @@
"safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
@ -1696,6 +1722,8 @@
"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=="], "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=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webcrypto-core": ["webcrypto-core@1.8.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.13", "@peculiar/json-schema": "^1.1.12", "asn1js": "^3.0.5", "pvtsutils": "^1.3.5", "tslib": "^2.7.0" } }, "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A=="], "webcrypto-core": ["webcrypto-core@1.8.1", "", { "dependencies": { "@peculiar/asn1-schema": "^2.3.13", "@peculiar/json-schema": "^1.1.12", "asn1js": "^3.0.5", "pvtsutils": "^1.3.5", "tslib": "^2.7.0" } }, "sha512-P+x1MvlNCXlKbLSOY4cYrdreqPG5hbzkmawbcXLKN/mf6DZW0SdNNkZ+sjwsqVkI4A4Ko2sPZmkZtCKY58w83A=="],
@ -1716,9 +1744,9 @@
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"workerd": ["workerd@1.20260120.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260120.0", "@cloudflare/workerd-darwin-arm64": "1.20260120.0", "@cloudflare/workerd-linux-64": "1.20260120.0", "@cloudflare/workerd-linux-arm64": "1.20260120.0", "@cloudflare/workerd-windows-64": "1.20260120.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-R6X/VQOkwLTBGLp4VRUwLQZZVxZ9T9J8pGiJ6GQUMaRkY7TVWrCSkVfoNMM1/YyFsY5UYhhPoQe5IehnhZ3Pdw=="], "workerd": ["workerd@1.20260124.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260124.0", "@cloudflare/workerd-darwin-arm64": "1.20260124.0", "@cloudflare/workerd-linux-64": "1.20260124.0", "@cloudflare/workerd-linux-arm64": "1.20260124.0", "@cloudflare/workerd-windows-64": "1.20260124.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-JN6voV/fUQK342a39Rl+20YVmtIXZVbpxc7V/m809lUnlTGPy4aa5MI7PMoc+9qExgAEOw9cojvN5zOfqmMWLg=="],
"wrangler": ["wrangler@4.60.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.11.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260120.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260120.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260120.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-n4kibm/xY0Qd5G2K/CbAQeVeOIlwPNVglmFjlDRCCYk3hZh8IggO/rg8AXt/vByK2Sxsugl5Z7yvgWxrUbmS6g=="], "wrangler": ["wrangler@4.61.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.11.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.0", "miniflare": "4.20260124.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260124.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260124.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-Kb8NMe1B/HM7/ds3hU+fcV1U7T996vRKJ0UU/qqgNUMwdemTRA+sSaH3mQvQslIBbprHHU81s0huA6fDIcwiaQ=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
@ -2464,8 +2492,6 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"miniflare/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],

25
cron-worker/src/index.js Normal file
View File

@ -0,0 +1,25 @@
export default {
async scheduled(event, env, ctx) {
console.log('Cron triggered: Pinging /api/cron/reminders');
const headers = {};
if (env.CRON_SECRET) {
headers['Authorization'] = `Bearer ${env.CRON_SECRET}`;
}
try {
const response = await fetch('https://quittraq.com/api/cron/reminders', {
headers
});
console.log(`Ping status: ${response.status} ${response.statusText}`);
if (!response.ok) {
const text = await response.text();
console.error(`Cron trigger failed: ${text}`);
}
} catch (err) {
console.error('Failed to ping cron endpoint:', err);
}
},
};

View File

@ -0,0 +1,7 @@
name = "quittraq-cron-trigger"
main = "src/index.js"
compatibility_date = "2024-09-23"
# Run every minute
[triggers]
crons = ["* * * * *"]

View File

@ -0,0 +1,12 @@
-- Create PushSubscriptions table
CREATE TABLE IF NOT EXISTS PushSubscriptions (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
createdAt TEXT DEFAULT (datetime('now')),
updatedAt TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_push_subscriptions_userId ON PushSubscriptions(userId);

View File

@ -0,0 +1,2 @@
-- Add timezone to ReminderSettings
ALTER TABLE ReminderSettings ADD COLUMN timezone TEXT DEFAULT 'UTC';

View File

@ -10,7 +10,9 @@
"postinstall": "prisma generate", "postinstall": "prisma generate",
"build:worker": "opennextjs-cloudflare build", "build:worker": "opennextjs-cloudflare build",
"dev:worker": "wrangler dev", "dev:worker": "wrangler dev",
"deploy": "bun run build:worker && wrangler deploy", "deploy:app": "bun run build:worker && wrangler deploy",
"deploy:cron": "cd cron-worker && wrangler deploy",
"deploy": "bun run deploy:app && bun run deploy:cron",
"d1:migrate": "wrangler d1 migrations apply quit-smoking-db --local", "d1:migrate": "wrangler d1 migrations apply quit-smoking-db --local",
"d1:migrate:prod": "wrangler d1 migrations apply quit-smoking-db --remote" "d1:migrate:prod": "wrangler d1 migrations apply quit-smoking-db --remote"
}, },
@ -18,7 +20,6 @@
"@opennextjs/cloudflare": "^1.1.1", "@opennextjs/cloudflare": "^1.1.1",
"@prisma/adapter-d1": "^5.22.0", "@prisma/adapter-d1": "^5.22.0",
"@prisma/client": "5", "@prisma/client": "5",
"@workos-inc/authkit-nextjs": "^0.16.0",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
@ -26,6 +27,7 @@
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@workos-inc/authkit-nextjs": "^0.16.0",
"@workos-inc/node": "^8.0.0", "@workos-inc/node": "^8.0.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -38,7 +40,8 @@
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"tailwind-merge": "^3.4.0" "tailwind-merge": "^3.4.0",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20250121.0", "@cloudflare/workers-types": "^4.20250121.0",
@ -46,12 +49,13 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/web-push": "^3.6.4",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.1.4", "eslint-config-next": "16.1.4",
"tailwindcss": "^4", "tailwindcss": "^4",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5", "typescript": "^5",
"wrangler": "^4" "wrangler": "^4.61.0"
}, },
"ignoreScripts": [ "ignoreScripts": [
"sharp", "sharp",

49
public/sw.js Normal file
View File

@ -0,0 +1,49 @@
self.addEventListener('push', function (event) {
if (!(self.Notification && self.Notification.permission === 'granted')) {
return;
}
let data = {};
if (event.data) {
try {
data = event.data.json();
} catch (e) {
data = { title: 'QuitTraq', body: event.data.text() };
}
}
const title = data.title || 'QuitTraq Update';
const options = {
body: data.body || 'Check in on your progress!',
icon: '/icon-192.png',
badge: '/icon-192.png',
tag: data.tag || 'generic-notification',
data: data.data || {},
requireInteraction: true
};
event.waitUntil(
self.registration.showNotification(title, options)
);
});
self.addEventListener('notificationclick', function (event) {
event.notification.close();
// This looks to see if the current is already open and focuses if it is
event.waitUntil(
clients.matchAll({
type: "window"
})
.then(function (clientList) {
for (var i = 0; i < clientList.length; i++) {
var client = clientList[i];
if (client.url == '/' && 'focus' in client)
return client.focus();
}
if (clients.openWindow) {
return clients.openWindow('/');
}
})
);
});

View File

@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from 'next/server';
import webPush from 'web-push';
import { getUsersForRemindersD1, updateLastNotifiedD1 } from '@/lib/d1';
// Configure web-push
if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
webPush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:example@yourdomain.org',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
export async function GET(request: NextRequest) {
try {
// Protect with a secret if configured
const authHeader = request.headers.get('authorization');
if (process.env.CRON_SECRET && authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const users = await getUsersForRemindersD1();
console.log(`Cron Ping: Processing ${users.length} users...`);
const now = new Date();
const processed = [];
for (const user of users) {
if (!user.endpoint || !user.p256dh || !user.auth) continue;
try {
// Determine User's Local Time
const userTimezone = user.timezone || 'UTC';
const userDateString = now.toLocaleDateString('en-CA', { timeZone: userTimezone }); // YYYY-MM-DD
const userTimeString = now.toLocaleTimeString('en-GB', { timeZone: userTimezone, hour: '2-digit', minute: '2-digit' }); // HH:MM
console.log(`Checking user ${user.userId}: Time ${userTimeString} (${userTimezone}) - Freq: ${user.frequency}`);
let shouldSend = false;
let notificationBody = '';
let tag = '';
if (user.frequency === 'hourly') {
const [currentH, currentM] = userTimeString.split(':');
const [startH, startM] = (user.hourlyStart || '09:00').split(':');
const currentHourKey = `${userDateString}-${currentH}`; // YYYY-MM-DD-HH
// Check active hours
const startStr = user.hourlyStart || '09:00';
const endStr = user.hourlyEnd || '21:00';
// Only send if we are in the time window AND the current minute matches the start minute
if (userTimeString >= startStr && userTimeString <= endStr && currentM === startM) {
if (user.lastNotifiedDate !== currentHourKey) {
shouldSend = true;
notificationBody = "How are you doing? Log your status to stay on track!";
tag = 'hourly-reminder';
// Update to match current Hour Key so we don't send again this hour
await updateLastNotifiedD1(user.userId, currentHourKey);
}
}
} else {
// Daily
// Check if current user time matches reminder time
// We allow a small window (e.g. within the same minute). Cron runs every minute.
// But we also check lastNotifiedDate to ensure we only send ONCE per day.
if (userTimeString === user.reminderTime) {
if (user.lastNotifiedDate !== userDateString) {
shouldSend = true;
notificationBody = "Time to log your daily usage! Every day counts.";
tag = 'daily-reminder';
await updateLastNotifiedD1(user.userId, userDateString);
}
}
}
if (shouldSend) {
const payload = JSON.stringify({
title: 'QuitTraq',
body: notificationBody,
tag: tag,
url: '/'
});
// Use generateRequestDetails + fetch to avoid https dependency
const requestDetails = await webPush.generateRequestDetails(
{
endpoint: user.endpoint,
keys: { p256dh: user.p256dh, auth: user.auth }
},
payload
);
const response = await fetch(requestDetails.endpoint, {
method: 'POST',
headers: requestDetails.headers as Record<string, string>,
// @ts-expect-error - WebPush returns a Buffer which fetch in Workers supports but TS definitions might not match
body: requestDetails.body
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream error: ${response.status} ${text}`);
}
processed.push({ userId: user.userId, status: 'sent', tag });
}
} catch (err) {
console.error(`Failed to process user ${user.userId}:`, err);
processed.push({ userId: user.userId, status: 'error', error: String(err) });
// If 410 Gone, we should delete subscription, but for now just log
}
}
return NextResponse.json({ success: true, processed });
} catch (error) {
console.error('Cron error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { getSession } from '@/lib/session';
import { upsertPushSubscriptionD1 } from '@/lib/d1';
export async function POST(request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json() as { subscription?: { endpoint: string; keys: { p256dh: string; auth: string } } };
const { subscription } = body;
if (!subscription) {
return NextResponse.json({ error: 'Invalid subscription' }, { status: 400 });
}
const { endpoint, keys } = subscription;
if (!endpoint || !keys || !keys.p256dh || !keys.auth) {
return NextResponse.json({ error: 'Invalid subscription data' }, { status: 400 });
}
await upsertPushSubscriptionD1(
session.user.id,
endpoint,
keys.p256dh,
keys.auth
);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error saving subscription:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import webPush from 'web-push';
import { getPushSubscriptionD1, getReminderSettingsD1 } from '@/lib/d1';
import { getSession } from '@/lib/session';
// Configure web-push
if (process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY && process.env.VAPID_PRIVATE_KEY) {
webPush.setVapidDetails(
process.env.VAPID_SUBJECT || 'mailto:example@yourdomain.org',
process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
process.env.VAPID_PRIVATE_KEY
);
}
export async function POST(_request: NextRequest) {
try {
const session = await getSession();
if (!session?.user) {
console.error('Test Push: Unauthorized - No session');
return NextResponse.json({ error: 'Unauthorized: No session found. Please refresh the page.' }, { status: 401 });
}
const userId = session.user.id;
// 1. Get Subscription and Settings
const [subscriptionRow, settings] = await Promise.all([
getPushSubscriptionD1(userId),
getReminderSettingsD1(userId)
]);
if (!subscriptionRow) {
console.error(`Test Push: No subscription found for user ${userId}`);
return NextResponse.json({ error: 'No subscription found for your user. Please click Enable again.' }, { status: 404 });
}
const userTimezone = settings?.timezone || 'UTC (Default/Missing)';
console.log(`Test Push: User ${userId} is in zone ${userTimezone}. Server time: ${new Date().toISOString()}`);
// 3. Send Notification
try {
const payload = JSON.stringify({
title: 'QuitTraq Test',
body: `Success! Device linked. Server sees you in ${userTimezone}.`,
tag: 'test-notification',
url: '/'
});
const requestDetails = await webPush.generateRequestDetails(
{
endpoint: subscriptionRow.endpoint,
keys: { p256dh: subscriptionRow.p256dh, auth: subscriptionRow.auth }
},
payload
);
const response = await fetch(requestDetails.endpoint, {
method: 'POST',
headers: requestDetails.headers as Record<string, string>,
// @ts-expect-error - WebPush returns a Buffer
body: requestDetails.body
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upstream error: ${response.status} ${text}`);
}
return NextResponse.json({ success: true, timezone: userTimezone });
} catch (sendError) {
console.error('Error sending test push:', sendError);
return NextResponse.json({ error: `Failed to send to device: ${sendError}` }, { status: 500 });
}
} catch (error) {
console.error('Test endpoint error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@ -25,6 +25,7 @@ export async function GET() {
frequency: settings.frequency || 'daily', frequency: settings.frequency || 'daily',
hourlyStart: settings.hourlyStart || '09:00', hourlyStart: settings.hourlyStart || '09:00',
hourlyEnd: settings.hourlyEnd || '21:00', hourlyEnd: settings.hourlyEnd || '21:00',
timezone: settings.timezone || 'UTC',
}); });
} catch (error) { } catch (error) {
console.error('Error fetching reminder settings:', error); console.error('Error fetching reminder settings:', error);
@ -45,8 +46,9 @@ export async function POST(request: NextRequest) {
frequency?: string; frequency?: string;
hourlyStart?: string; hourlyStart?: string;
hourlyEnd?: string; hourlyEnd?: string;
timezone?: string;
}; };
const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd } = body; const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body;
const settings = await upsertReminderSettingsD1( const settings = await upsertReminderSettingsD1(
session.user.id, session.user.id,
@ -54,7 +56,8 @@ export async function POST(request: NextRequest) {
reminderTime ?? '09:00', reminderTime ?? '09:00',
frequency ?? 'daily', frequency ?? 'daily',
hourlyStart ?? '09:00', hourlyStart ?? '09:00',
hourlyEnd ?? '21:00' hourlyEnd ?? '21:00',
timezone ?? 'UTC'
); );
if (!settings) { if (!settings) {
@ -67,6 +70,7 @@ export async function POST(request: NextRequest) {
frequency: settings.frequency || 'daily', frequency: settings.frequency || 'daily',
hourlyStart: settings.hourlyStart || '09:00', hourlyStart: settings.hourlyStart || '09:00',
hourlyEnd: settings.hourlyEnd || '21:00', hourlyEnd: settings.hourlyEnd || '21:00',
timezone: settings.timezone || 'UTC',
}); });
} catch (error) { } catch (error) {
console.error('Error saving reminder settings:', error); console.error('Error saving reminder settings:', error);

View File

@ -29,7 +29,7 @@ import { fetchPreferences, fetchReminderSettings, saveReminderSettings, Reminder
import { useNotifications } from '@/hooks/useNotifications'; import { useNotifications } from '@/hooks/useNotifications';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles } from 'lucide-react'; import { Cigarette, Leaf, LogOut, Home, ChevronDown, Sun, Moon, Bell, BellOff, BellRing, Menu, Sparkles, Link as LinkIcon } from 'lucide-react';
import { useTheme } from '@/lib/theme-context'; import { useTheme } from '@/lib/theme-context';
import { InstallAppButton } from './InstallAppButton'; import { InstallAppButton } from './InstallAppButton';
@ -59,7 +59,7 @@ function HourlyTimePicker({ value, onChange }: HourlyTimePickerProps) {
}; };
const hoursOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0')); const hoursOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
const minutesOptions = Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, '0')); const minutesOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
return ( return (
<div className="flex gap-2 w-full"> <div className="flex gap-2 w-full">
@ -152,7 +152,7 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
// Generate options // Generate options
const hoursOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0')); const hoursOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'));
const minutesOptions = Array.from({ length: 12 }, (_, i) => (i * 5).toString().padStart(2, '0')); const minutesOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'));
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
@ -167,15 +167,24 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
setUserName(prefs.userName); setUserName(prefs.userName);
} }
setReminderSettings(reminders); const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
setLocalTime(reminders.reminderTime); let settingsToUse = reminders;
setLocalFrequency(reminders.frequency || 'daily');
// If timezone is missing or different, update it
if (reminders.timezone !== detectedTimezone) {
settingsToUse = { ...reminders, timezone: detectedTimezone };
await saveReminderSettings(settingsToUse);
}
setReminderSettings(settingsToUse);
setLocalTime(settingsToUse.reminderTime);
setLocalFrequency(settingsToUse.frequency || 'daily');
}; };
loadData(); loadData();
}, [preferences]); }, [preferences]);
const handleToggleReminders = async () => { const handleToggleReminders = async () => {
if (!reminderSettings.enabled && permission !== 'granted') { if (!reminderSettings.enabled) {
const result = await requestPermission(); const result = await requestPermission();
if (result !== 'granted') return; if (result !== 'granted') return;
} }
@ -491,12 +500,26 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
{/* Start Time */} {/* Start Time */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">Start Time</Label> <Label className="text-sm flex items-center justify-between">
Start Time
<span className="flex items-center gap-1 text-[10px] text-indigo-400 font-normal">
<LinkIcon className="w-3 h-3" />
Minute Link
</span>
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<HourlyTimePicker <HourlyTimePicker
value={reminderSettings.hourlyStart || '09:00'} value={reminderSettings.hourlyStart || '09:00'}
onChange={async (newTime) => { onChange={async (newTime) => {
const newSettings = { ...reminderSettings, hourlyStart: newTime }; const [h, m] = newTime.split(':');
const end = (reminderSettings.hourlyEnd || '21:00').split(':');
const newEnd = `${end[0]}:${m}`;
const newSettings = {
...reminderSettings,
hourlyStart: newTime,
hourlyEnd: newEnd
};
setReminderSettings(newSettings); setReminderSettings(newSettings);
await saveReminderSettings(newSettings); await saveReminderSettings(newSettings);
}} }}
@ -506,12 +529,23 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
{/* End Time */} {/* End Time */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm">End Time</Label> <Label className="text-sm flex items-center justify-between">
End Time
<span className="text-[10px] text-indigo-400/70 font-normal">Minutes synced with Start</span>
</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<HourlyTimePicker <HourlyTimePicker
value={reminderSettings.hourlyEnd || '21:00'} value={reminderSettings.hourlyEnd || '21:00'}
onChange={async (newTime) => { onChange={async (newTime) => {
const newSettings = { ...reminderSettings, hourlyEnd: newTime }; const [h, m] = newTime.split(':');
const start = (reminderSettings.hourlyStart || '09:00').split(':');
const newStart = `${start[0]}:${m}`;
const newSettings = {
...reminderSettings,
hourlyEnd: newTime,
hourlyStart: newStart
};
setReminderSettings(newSettings); setReminderSettings(newSettings);
await saveReminderSettings(newSettings); await saveReminderSettings(newSettings);
}} }}
@ -526,16 +560,46 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
</div> </div>
)} )}
{/* Request Permission Button */} {/* Push Permission / Re-Sync Button */}
{isSupported && permission === 'default' && ( {reminderSettings.enabled && isSupported && (
<Button <div className="pt-2 border-t border-border/50 space-y-2">
onClick={requestPermission} <button
variant="outline" onClick={async () => {
className="w-full" // 1. Request/Refresh Permission & Subscription
> const result = await requestPermission();
<Bell className="mr-2 h-4 w-4" />
Enable Notifications // 2. If granted, try to send a test notification immediately
</Button> if (result === 'granted') {
try {
const res = await fetch('/api/notifications/test', { method: 'POST' });
if (!res.ok) {
const errData = await res.json() as { error?: string };
throw new Error(errData.error || `Server error ${res.status}`);
}
alert("Success! Push notifications are now active.");
} catch (err) {
console.error(err);
// @ts-expect-error - err is unknown
alert(`Error: ${err.message}`);
}
} else {
alert("Please enable notifications in your browser settings.");
}
}}
className={`w-full py-3 text-sm font-semibold rounded-lg transition-colors flex items-center justify-center gap-2 shadow-sm ${permission === 'granted'
? 'text-emerald-600 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200'
: 'text-white bg-emerald-600 hover:bg-emerald-500'
}`}
>
<Bell className="w-4 h-4" />
Enable Push
</button>
<p className="text-[10px] text-muted-foreground text-center">
{permission === 'granted'
? 'Tap if you are not receiving alerts'
: 'Required for background alerts'}
</p>
</div>
)} )}
{/* Denied Message */} {/* Denied Message */}

View File

@ -7,15 +7,88 @@ const LAST_NOTIFICATION_KEY = 'quittraq_last_notification_date';
export type NotificationPermission = 'default' | 'granted' | 'denied'; export type NotificationPermission = 'default' | 'granted' | 'denied';
// Helper to convert VAPID key
function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export function useNotifications(reminderSettings: ReminderSettings) { export function useNotifications(reminderSettings: ReminderSettings) {
const [permission, setPermission] = useState<NotificationPermission>('default'); const [permission, setPermission] = useState<NotificationPermission>('default');
const [isSupported, setIsSupported] = useState(false); const [isSupported, setIsSupported] = useState(false);
const [swRegistration, setSwRegistration] = useState<ServiceWorkerRegistration | null>(null);
// Check if notifications are supported and get current permission // Register Service Worker
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined' && 'Notification' in window) { if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) {
setIsSupported(true); setIsSupported(true);
setPermission(Notification.permission as NotificationPermission); setPermission(Notification.permission as NotificationPermission);
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('Service Worker registered:', registration);
setSwRegistration(registration);
})
.catch(error => {
console.error('Service Worker registration failed:', error);
});
}
}, []);
const subscribeToPush = useCallback(async () => {
try {
const registration = await navigator.serviceWorker.ready;
if (!registration) {
throw new Error('Service Worker not ready');
}
const publicVapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!publicVapidKey) {
throw new Error('Missing VAPID key');
}
// 1. Ensure we have a subscription
let subscription = await registration.pushManager.getSubscription();
if (!subscription) {
subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicVapidKey)
});
console.log('New push notification subscribed');
} else {
console.log('Existing push subscription found, syncing with server...');
}
// 2. Refresh it on the backend (ALWAYS)
const res = await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ subscription }),
});
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error || 'Failed to sync subscription to server');
}
// Success
console.log('Subscription synced with server');
} catch (error) {
console.error('Failed to subscribe:', error);
throw error; // Propagate to caller
} }
}, []); }, []);
@ -26,12 +99,18 @@ export function useNotifications(reminderSettings: ReminderSettings) {
try { try {
const result = await Notification.requestPermission(); const result = await Notification.requestPermission();
setPermission(result as NotificationPermission); setPermission(result as NotificationPermission);
if (result === 'granted') {
// Verify we can subscribe and save to backend
await subscribeToPush();
}
return result; return result;
} catch (error) { } catch (error) {
console.error('Error requesting notification permission:', error); console.error('Error requesting notification permission:', error);
alert('Error enabling notifications: ' + (error instanceof Error ? error.message : 'Unknown error'));
return 'denied'; return 'denied';
} }
}, [isSupported]); }, [isSupported, subscribeToPush]);
// Send a notification // Send a notification
const sendNotification = useCallback( const sendNotification = useCallback(
@ -91,26 +170,13 @@ export function useNotifications(reminderSettings: ReminderSettings) {
// Track previous settings to detect changes // Track previous settings to detect changes
const [prevSettings, setPrevSettings] = useState(reminderSettings); const [prevSettings, setPrevSettings] = useState(reminderSettings);
// If settings change, we might need to reset notification history to allow "update and resend"
useEffect(() => {
if (JSON.stringify(prevSettings) !== JSON.stringify(reminderSettings)) {
// Only reset if time changed significantly or if toggled.
// For simplicity, if the user updates settings, we clear the daily lock *if* the time is in the future relative to previous check?
// Actually, the user wants "sent out again" if edited.
// We can simply clear the 'last notified' date if the reminderTime changed.
if (prevSettings.reminderTime !== reminderSettings.reminderTime && reminderSettings.frequency === 'daily') {
localStorage.removeItem(LAST_NOTIFICATION_KEY);
}
setPrevSettings(reminderSettings);
}
}, [reminderSettings, prevSettings]);
// Check and send reminder // Check and send reminder
const checkAndSendReminder = useCallback(() => { const checkAndSendReminder = useCallback(() => {
if (!reminderSettings.enabled || permission !== 'granted') return; if (!reminderSettings.enabled || permission !== 'granted') return;
const now = new Date(); const now = new Date();
const today = now.toISOString().split('T')[0]; // 'en-CA' outputs YYYY-MM-DD in local timezone
const today = now.toLocaleDateString('en-CA');
if (reminderSettings.frequency === 'hourly') { if (reminderSettings.frequency === 'hourly') {
const LAST_HOURLY_KEY = 'quittraq_last_hourly_notification'; const LAST_HOURLY_KEY = 'quittraq_last_hourly_notification';
@ -140,6 +206,7 @@ export function useNotifications(reminderSettings: ReminderSettings) {
} else { } else {
// Daily logic // Daily logic
const lastNotified = localStorage.getItem(LAST_NOTIFICATION_KEY); const lastNotified = localStorage.getItem(LAST_NOTIFICATION_KEY);
if (lastNotified === today) return; // Already notified today if (lastNotified === today) return; // Already notified today
const [hours, minutes] = reminderSettings.reminderTime.split(':').map(Number); const [hours, minutes] = reminderSettings.reminderTime.split(':').map(Number);
@ -159,6 +226,21 @@ export function useNotifications(reminderSettings: ReminderSettings) {
} }
}, [reminderSettings, permission, sendNotification, playNotificationSound]); }, [reminderSettings, permission, sendNotification, playNotificationSound]);
// If settings change, we might need to reset notification history to allow "update and resend"
// This effect MUST be after checkAndSendReminder is defined so we can call it immediately
useEffect(() => {
if (JSON.stringify(prevSettings) !== JSON.stringify(reminderSettings)) {
// Only reset if time changed significantly or if toggled.
// We can simply clear the 'last notified' date if the reminderTime changed.
if (prevSettings.reminderTime !== reminderSettings.reminderTime && reminderSettings.frequency === 'daily') {
localStorage.removeItem(LAST_NOTIFICATION_KEY);
// Force immediate check
checkAndSendReminder();
}
setPrevSettings(reminderSettings);
}
}, [reminderSettings, prevSettings, checkAndSendReminder]);
// Set up interval to check for reminder time // Set up interval to check for reminder time
useEffect(() => { useEffect(() => {
if (!reminderSettings.enabled || permission !== 'granted') return; if (!reminderSettings.enabled || permission !== 'granted') return;

View File

@ -237,6 +237,7 @@ export interface ReminderSettingsRow {
frequency: string; frequency: string;
hourlyStart: string | null; hourlyStart: string | null;
hourlyEnd: string | null; hourlyEnd: string | null;
timezone: string;
lastNotifiedDate: string | null; lastNotifiedDate: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@ -259,7 +260,8 @@ export async function upsertReminderSettingsD1(
reminderTime: string, reminderTime: string,
frequency: string = 'daily', frequency: string = 'daily',
hourlyStart: string = '09:00', hourlyStart: string = '09:00',
hourlyEnd: string = '21:00' hourlyEnd: string = '21:00',
timezone: string = 'UTC'
): Promise<ReminderSettingsRow | null> { ): Promise<ReminderSettingsRow | null> {
const db = getD1(); const db = getD1();
if (!db) return null; if (!db) return null;
@ -270,18 +272,56 @@ export async function upsertReminderSettingsD1(
if (existing) { if (existing) {
await db.prepare( await db.prepare(
'UPDATE ReminderSettings SET enabled = ?, reminderTime = ?, frequency = ?, hourlyStart = ?, hourlyEnd = ?, updatedAt = ? WHERE userId = ?' 'UPDATE ReminderSettings SET enabled = ?, reminderTime = ?, frequency = ?, hourlyStart = ?, hourlyEnd = ?, timezone = ?, updatedAt = ? WHERE userId = ?'
).bind(enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, now, userId).run(); ).bind(enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, userId).run();
} else { } else {
await db.prepare( await db.prepare(
`INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, frequency, hourlyStart, hourlyEnd, createdAt, updatedAt) `INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(id, userId, enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, now, now).run(); ).bind(id, userId, enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, now).run();
} }
return getReminderSettingsD1(userId); return getReminderSettingsD1(userId);
} }
export interface ReminderUserRow {
userId: string;
reminderTime: string;
frequency: string;
hourlyStart: string | null;
hourlyEnd: string | null;
timezone: string;
lastNotifiedDate: string | null;
endpoint: string;
p256dh: string;
auth: string;
}
export async function getUsersForRemindersD1(): Promise<ReminderUserRow[]> {
const db = getD1();
if (!db) return [];
const result = await db.prepare(
`SELECT
r.userId, r.reminderTime, r.frequency, r.hourlyStart, r.hourlyEnd, r.timezone, r.lastNotifiedDate,
p.endpoint, p.p256dh, p.auth
FROM ReminderSettings r
JOIN PushSubscriptions p ON r.userId = p.userId
WHERE r.enabled = 1`
).all<ReminderUserRow>();
return result.results || [];
}
export async function updateLastNotifiedD1(userId: string, dateStr: string): Promise<void> {
const db = getD1();
if (!db) return;
await db.prepare(
'UPDATE ReminderSettings SET lastNotifiedDate = ?, updatedAt = ? WHERE userId = ?'
).bind(dateStr, new Date().toISOString(), userId).run();
}
// ============ SAVINGS CONFIG ============ // ============ SAVINGS CONFIG ============
export interface SavingsConfigRow { export interface SavingsConfigRow {
@ -337,3 +377,52 @@ export async function upsertSavingsConfigD1(
return getSavingsConfigD1(userId); return getSavingsConfigD1(userId);
} }
// ============ PUSH SUBSCRIPTIONS ============
export interface PushSubscriptionRow {
id: string;
userId: string;
endpoint: string;
p256dh: string;
auth: string;
createdAt: string;
updatedAt: string;
}
export async function getPushSubscriptionD1(userId: string): Promise<PushSubscriptionRow | null> {
const db = getD1();
if (!db) return null;
const result = await db.prepare(
'SELECT * FROM PushSubscriptions WHERE userId = ?'
).bind(userId).first<PushSubscriptionRow>();
return result;
}
export async function upsertPushSubscriptionD1(
userId: string,
endpoint: string,
p256dh: string,
auth: string
): Promise<PushSubscriptionRow | null> {
const db = getD1();
if (!db) return null;
const existing = await getPushSubscriptionD1(userId);
const now = new Date().toISOString();
const id = existing?.id || crypto.randomUUID();
if (existing) {
await db.prepare(
'UPDATE PushSubscriptions SET endpoint = ?, p256dh = ?, auth = ?, updatedAt = ? WHERE userId = ?'
).bind(endpoint, p256dh, auth, now, userId).run();
} else {
await db.prepare(
`INSERT INTO PushSubscriptions (id, userId, endpoint, p256dh, auth, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`
).bind(id, userId, endpoint, p256dh, auth, now, now).run();
}
return getPushSubscriptionD1(userId);
}

View File

@ -41,6 +41,7 @@ export interface ReminderSettings {
frequency: 'daily' | 'hourly'; frequency: 'daily' | 'hourly';
hourlyStart?: string; // HH:MM hourlyStart?: string; // HH:MM
hourlyEnd?: string; // HH:MM hourlyEnd?: string; // HH:MM
timezone?: string; // e.g. 'America/New_York'
} }
export interface SavingsConfig { export interface SavingsConfig {