Fix push notifications: Edge runtime compatibility, minute-precision hourly reminders, and timezone sync
This commit is contained in:
parent
9f0eb9a5bd
commit
cec4096e1f
48
bun.lock
48
bun.lock
@ -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
25
cron-worker/src/index.js
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
7
cron-worker/wrangler.toml
Normal file
7
cron-worker/wrangler.toml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
name = "quittraq-cron-trigger"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2024-09-23"
|
||||||
|
|
||||||
|
# Run every minute
|
||||||
|
[triggers]
|
||||||
|
crons = ["* * * * *"]
|
||||||
12
migrations/0004_add_push_subscriptions.sql
Normal file
12
migrations/0004_add_push_subscriptions.sql
Normal 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);
|
||||||
2
migrations/0005_add_timezone.sql
Normal file
2
migrations/0005_add_timezone.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- Add timezone to ReminderSettings
|
||||||
|
ALTER TABLE ReminderSettings ADD COLUMN timezone TEXT DEFAULT 'UTC';
|
||||||
12
package.json
12
package.json
@ -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
49
public/sw.js
Normal 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('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
124
src/app/api/cron/reminders/route.ts
Normal file
124
src/app/api/cron/reminders/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/app/api/notifications/subscribe/route.ts
Normal file
35
src/app/api/notifications/subscribe/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/app/api/notifications/test/route.ts
Normal file
77
src/app/api/notifications/test/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
101
src/lib/d1.ts
101
src/lib/d1.ts
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user