diff --git a/bun.lock b/bun.lock index b4a0bdd..d49434b 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,7 @@ "react-dom": "19.2.3", "recharts": "^3.7.0", "tailwind-merge": "^3.4.0", + "web-push": "^3.6.7", }, "devDependencies": { "@cloudflare/workers-types": "^4.20250121.0", @@ -36,12 +37,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "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/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=="], @@ -760,6 +762,8 @@ "@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/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=="], + "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=="], "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=="], + "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=="], "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=="], + "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=="], "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-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=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1010,6 +1022,8 @@ "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=="], "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_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=="], "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=="], + "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=="], "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=="], - "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=="], @@ -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-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-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=="], + "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=="], "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=="], - "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=="], @@ -2464,8 +2492,6 @@ "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=="], "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], diff --git a/cron-worker/src/index.js b/cron-worker/src/index.js new file mode 100644 index 0000000..89687ec --- /dev/null +++ b/cron-worker/src/index.js @@ -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); + } + }, +}; diff --git a/cron-worker/wrangler.toml b/cron-worker/wrangler.toml new file mode 100644 index 0000000..da1c5a3 --- /dev/null +++ b/cron-worker/wrangler.toml @@ -0,0 +1,7 @@ +name = "quittraq-cron-trigger" +main = "src/index.js" +compatibility_date = "2024-09-23" + +# Run every minute +[triggers] +crons = ["* * * * *"] diff --git a/migrations/0004_add_push_subscriptions.sql b/migrations/0004_add_push_subscriptions.sql new file mode 100644 index 0000000..886ff43 --- /dev/null +++ b/migrations/0004_add_push_subscriptions.sql @@ -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); diff --git a/migrations/0005_add_timezone.sql b/migrations/0005_add_timezone.sql new file mode 100644 index 0000000..811a2f0 --- /dev/null +++ b/migrations/0005_add_timezone.sql @@ -0,0 +1,2 @@ +-- Add timezone to ReminderSettings +ALTER TABLE ReminderSettings ADD COLUMN timezone TEXT DEFAULT 'UTC'; diff --git a/package.json b/package.json index 2bee76d..160121b 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "postinstall": "prisma generate", "build:worker": "opennextjs-cloudflare build", "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:prod": "wrangler d1 migrations apply quit-smoking-db --remote" }, @@ -18,7 +20,6 @@ "@opennextjs/cloudflare": "^1.1.1", "@prisma/adapter-d1": "^5.22.0", "@prisma/client": "5", - "@workos-inc/authkit-nextjs": "^0.16.0", "@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-dialog": "^1.1.15", @@ -26,6 +27,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slot": "^1.2.4", + "@workos-inc/authkit-nextjs": "^0.16.0", "@workos-inc/node": "^8.0.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -38,7 +40,8 @@ "react-day-picker": "^9.13.0", "react-dom": "19.2.3", "recharts": "^3.7.0", - "tailwind-merge": "^3.4.0" + "tailwind-merge": "^3.4.0", + "web-push": "^3.6.7" }, "devDependencies": { "@cloudflare/workers-types": "^4.20250121.0", @@ -46,12 +49,13 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "eslint": "^9", "eslint-config-next": "16.1.4", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5", - "wrangler": "^4" + "wrangler": "^4.61.0" }, "ignoreScripts": [ "sharp", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..6aa0ae5 --- /dev/null +++ b/public/sw.js @@ -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('/'); + } + }) + ); +}); diff --git a/src/app/api/cron/reminders/route.ts b/src/app/api/cron/reminders/route.ts new file mode 100644 index 0000000..596784a --- /dev/null +++ b/src/app/api/cron/reminders/route.ts @@ -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, + // @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 }); + } +} diff --git a/src/app/api/notifications/subscribe/route.ts b/src/app/api/notifications/subscribe/route.ts new file mode 100644 index 0000000..4ccdc44 --- /dev/null +++ b/src/app/api/notifications/subscribe/route.ts @@ -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 }); + } +} diff --git a/src/app/api/notifications/test/route.ts b/src/app/api/notifications/test/route.ts new file mode 100644 index 0000000..98888d0 --- /dev/null +++ b/src/app/api/notifications/test/route.ts @@ -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, + // @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 }); + } +} diff --git a/src/app/api/reminders/route.ts b/src/app/api/reminders/route.ts index 4730b8a..21f316b 100644 --- a/src/app/api/reminders/route.ts +++ b/src/app/api/reminders/route.ts @@ -25,6 +25,7 @@ export async function GET() { frequency: settings.frequency || 'daily', hourlyStart: settings.hourlyStart || '09:00', hourlyEnd: settings.hourlyEnd || '21:00', + timezone: settings.timezone || 'UTC', }); } catch (error) { console.error('Error fetching reminder settings:', error); @@ -45,8 +46,9 @@ export async function POST(request: NextRequest) { frequency?: string; hourlyStart?: string; hourlyEnd?: string; + timezone?: string; }; - const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd } = body; + const { enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone } = body; const settings = await upsertReminderSettingsD1( session.user.id, @@ -54,7 +56,8 @@ export async function POST(request: NextRequest) { reminderTime ?? '09:00', frequency ?? 'daily', hourlyStart ?? '09:00', - hourlyEnd ?? '21:00' + hourlyEnd ?? '21:00', + timezone ?? 'UTC' ); if (!settings) { @@ -67,6 +70,7 @@ export async function POST(request: NextRequest) { frequency: settings.frequency || 'daily', hourlyStart: settings.hourlyStart || '09:00', hourlyEnd: settings.hourlyEnd || '21:00', + timezone: settings.timezone || 'UTC', }); } catch (error) { console.error('Error saving reminder settings:', error); diff --git a/src/components/UserHeader.tsx b/src/components/UserHeader.tsx index 483e1a5..52e80cc 100644 --- a/src/components/UserHeader.tsx +++ b/src/components/UserHeader.tsx @@ -29,7 +29,7 @@ import { fetchPreferences, fetchReminderSettings, saveReminderSettings, Reminder import { useNotifications } from '@/hooks/useNotifications'; import { useEffect, useState } from 'react'; 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 { 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 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 (
@@ -152,7 +152,7 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { // Generate options 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(() => { const loadData = async () => { @@ -167,15 +167,24 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { setUserName(prefs.userName); } - setReminderSettings(reminders); - setLocalTime(reminders.reminderTime); - setLocalFrequency(reminders.frequency || 'daily'); + const detectedTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + let settingsToUse = reminders; + + // 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(); }, [preferences]); const handleToggleReminders = async () => { - if (!reminderSettings.enabled && permission !== 'granted') { + if (!reminderSettings.enabled) { const result = await requestPermission(); if (result !== 'granted') return; } @@ -491,12 +500,26 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { {/* Start Time */}
- +
{ - 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); await saveReminderSettings(newSettings); }} @@ -506,12 +529,23 @@ export function UserHeader({ user, preferences }: UserHeaderProps) { {/* End Time */}
- +
{ - 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); await saveReminderSettings(newSettings); }} @@ -526,16 +560,46 @@ export function UserHeader({ user, preferences }: UserHeaderProps) {
)} - {/* Request Permission Button */} - {isSupported && permission === 'default' && ( - + {/* Push Permission / Re-Sync Button */} + {reminderSettings.enabled && isSupported && ( +
+ +

+ {permission === 'granted' + ? 'Tap if you are not receiving alerts' + : 'Required for background alerts'} +

+
)} {/* Denied Message */} diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts index 8e61e46..4b06cfa 100644 --- a/src/hooks/useNotifications.ts +++ b/src/hooks/useNotifications.ts @@ -7,15 +7,88 @@ const LAST_NOTIFICATION_KEY = 'quittraq_last_notification_date'; 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) { const [permission, setPermission] = useState('default'); const [isSupported, setIsSupported] = useState(false); + const [swRegistration, setSwRegistration] = useState(null); - // Check if notifications are supported and get current permission + // Register Service Worker useEffect(() => { - if (typeof window !== 'undefined' && 'Notification' in window) { + if (typeof window !== 'undefined' && 'serviceWorker' in navigator && 'PushManager' in window) { setIsSupported(true); 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 { const result = await Notification.requestPermission(); setPermission(result as NotificationPermission); + + if (result === 'granted') { + // Verify we can subscribe and save to backend + await subscribeToPush(); + } return result; } catch (error) { console.error('Error requesting notification permission:', error); + alert('Error enabling notifications: ' + (error instanceof Error ? error.message : 'Unknown error')); return 'denied'; } - }, [isSupported]); + }, [isSupported, subscribeToPush]); // Send a notification const sendNotification = useCallback( @@ -91,26 +170,13 @@ export function useNotifications(reminderSettings: ReminderSettings) { // Track previous settings to detect changes 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 const checkAndSendReminder = useCallback(() => { if (!reminderSettings.enabled || permission !== 'granted') return; 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') { const LAST_HOURLY_KEY = 'quittraq_last_hourly_notification'; @@ -140,6 +206,7 @@ export function useNotifications(reminderSettings: ReminderSettings) { } else { // Daily logic const lastNotified = localStorage.getItem(LAST_NOTIFICATION_KEY); + if (lastNotified === today) return; // Already notified today const [hours, minutes] = reminderSettings.reminderTime.split(':').map(Number); @@ -159,6 +226,21 @@ export function useNotifications(reminderSettings: ReminderSettings) { } }, [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 useEffect(() => { if (!reminderSettings.enabled || permission !== 'granted') return; diff --git a/src/lib/d1.ts b/src/lib/d1.ts index 1fdcca7..3ccc45e 100644 --- a/src/lib/d1.ts +++ b/src/lib/d1.ts @@ -237,6 +237,7 @@ export interface ReminderSettingsRow { frequency: string; hourlyStart: string | null; hourlyEnd: string | null; + timezone: string; lastNotifiedDate: string | null; createdAt: string; updatedAt: string; @@ -259,7 +260,8 @@ export async function upsertReminderSettingsD1( reminderTime: string, frequency: string = 'daily', hourlyStart: string = '09:00', - hourlyEnd: string = '21:00' + hourlyEnd: string = '21:00', + timezone: string = 'UTC' ): Promise { const db = getD1(); if (!db) return null; @@ -270,18 +272,56 @@ export async function upsertReminderSettingsD1( if (existing) { await db.prepare( - 'UPDATE ReminderSettings SET enabled = ?, reminderTime = ?, frequency = ?, hourlyStart = ?, hourlyEnd = ?, updatedAt = ? WHERE userId = ?' - ).bind(enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, now, userId).run(); + 'UPDATE ReminderSettings SET enabled = ?, reminderTime = ?, frequency = ?, hourlyStart = ?, hourlyEnd = ?, timezone = ?, updatedAt = ? WHERE userId = ?' + ).bind(enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, userId).run(); } else { await db.prepare( - `INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, frequency, hourlyStart, hourlyEnd, createdAt, updatedAt) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` - ).bind(id, userId, enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, now, now).run(); + `INSERT INTO ReminderSettings (id, userId, enabled, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, createdAt, updatedAt) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).bind(id, userId, enabled ? 1 : 0, reminderTime, frequency, hourlyStart, hourlyEnd, timezone, now, now).run(); } 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 { + 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(); + + return result.results || []; +} + +export async function updateLastNotifiedD1(userId: string, dateStr: string): Promise { + 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 ============ export interface SavingsConfigRow { @@ -337,3 +377,52 @@ export async function upsertSavingsConfigD1( 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 { + const db = getD1(); + if (!db) return null; + + const result = await db.prepare( + 'SELECT * FROM PushSubscriptions WHERE userId = ?' + ).bind(userId).first(); + + return result; +} + +export async function upsertPushSubscriptionD1( + userId: string, + endpoint: string, + p256dh: string, + auth: string +): Promise { + 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); +} diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 156011c..76f6fbd 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -41,6 +41,7 @@ export interface ReminderSettings { frequency: 'daily' | 'hourly'; hourlyStart?: string; // HH:MM hourlyEnd?: string; // HH:MM + timezone?: string; // e.g. 'America/New_York' } export interface SavingsConfig {