diff --git a/package-lock.json b/package-lock.json index 4ca5d08..6400254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,10 +15,12 @@ "enhanced-postgres-mcp-server": "^1.0.1", "express": "^4.18.2", "express-session": "^1.17.3", + "node-cron": "^4.2.1", "pg": "^8.11.3", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7" }, "devDependencies": { "nodemon": "^3.0.1" @@ -714,6 +716,18 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -853,6 +867,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -906,6 +926,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1253,6 +1279,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1978,6 +2013,15 @@ "node": ">= 0.4" } }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -2239,6 +2283,27 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -2360,6 +2425,12 @@ "node": ">= 0.6" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2371,6 +2442,15 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -2451,6 +2531,15 @@ "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", "license": "MIT" }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3981,6 +4070,70 @@ "node": ">= 0.8" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "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" + }, + "engines": { + "node": ">= 16" + } + }, + "node_modules/web-push/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/web-push/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/web-push/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -4681,6 +4834,17 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -4765,6 +4929,11 @@ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true }, + "bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==" + }, "body-parser": { "version": "1.20.3", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", @@ -4807,6 +4976,11 @@ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==" }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -5037,6 +5211,14 @@ "gopd": "^1.2.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -5520,6 +5702,11 @@ "function-bind": "^1.1.2" } }, + "http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==" + }, "http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -5705,6 +5892,25 @@ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" }, + "jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "requires": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -5783,6 +5989,11 @@ "mime-db": "1.52.0" } }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5791,6 +6002,11 @@ "brace-expansion": "^1.1.7" } }, + "minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==" + }, "minipass": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", @@ -5845,6 +6061,11 @@ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==" }, + "node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==" + }, "node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6885,6 +7106,47 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==" }, + "web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "requires": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "dependencies": { + "agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==" + }, + "debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "requires": { + "ms": "^2.1.3" + } + }, + "https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "requires": { + "agent-base": "^7.1.2", + "debug": "4" + } + }, + "ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + } + } + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index e99d48c..b03a2c2 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,12 @@ "enhanced-postgres-mcp-server": "^1.0.1", "express": "^4.18.2", "express-session": "^1.17.3", + "node-cron": "^4.2.1", "pg": "^8.11.3", "socket.io": "^4.8.1", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "web-push": "^3.6.7" }, "devDependencies": { "nodemon": "^3.0.1" diff --git a/public/css/dashboard.css b/public/css/dashboard.css index c1f1aad..9d99218 100644 --- a/public/css/dashboard.css +++ b/public/css/dashboard.css @@ -1374,3 +1374,9 @@ body { max-width: none; } } + + +.last-location-info small { + color: #b3e5fc; + font-size: 0.85rem; +} diff --git a/public/dashboard.html b/public/dashboard.html index 518ed75..d15f3e3 100644 --- a/public/dashboard.html +++ b/public/dashboard.html @@ -5,10 +5,68 @@ SPEEDRUN ARENA - Admin Dashboard + + + + + + + +
@@ -34,6 +92,7 @@

Dein Dashboard 🥷

Willkommen in Deinem Dashboard-Panel! Deine übersichtliche Übersicht aller deiner Läufe.

+
@@ -252,6 +311,6 @@ - + diff --git a/public/index.html b/public/index.html index 13850a1..eb6e1e4 100644 --- a/public/index.html +++ b/public/index.html @@ -6,6 +6,7 @@ Timer Leaderboard + diff --git a/public/js/cookie-utils.js b/public/js/cookie-utils.js new file mode 100644 index 0000000..1264cf2 --- /dev/null +++ b/public/js/cookie-utils.js @@ -0,0 +1,105 @@ +// Cookie Utility Functions +class CookieManager { + // Set a cookie + static setCookie(name, value, days = 30) { + const expires = new Date(); + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; + } + + // Get a cookie + static getCookie(name) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; + } + + // Delete a cookie + static deleteCookie(name) { + document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 UTC;path=/;`; + } + + // Check if cookies are enabled + static areCookiesEnabled() { + try { + this.setCookie('test', 'test'); + const enabled = this.getCookie('test') === 'test'; + this.deleteCookie('test'); + return enabled; + } catch (e) { + return false; + } + } +} + +// Location-specific cookie functions +class LocationCookieManager { + static COOKIE_NAME = 'ninjacross_last_location'; + static COOKIE_EXPIRY_DAYS = 90; // 3 months + + // Save last selected location + static saveLastLocation(locationId, locationName) { + if (!locationId || !locationName) return; + + const locationData = { + id: locationId, + name: locationName, + timestamp: new Date().toISOString() + }; + + try { + CookieManager.setCookie( + this.COOKIE_NAME, + JSON.stringify(locationData), + this.COOKIE_EXPIRY_DAYS + ); + console.log('✅ Location saved to cookie:', locationName); + } catch (error) { + console.error('❌ Failed to save location to cookie:', error); + } + } + + // Get last selected location + static getLastLocation() { + try { + const cookieValue = CookieManager.getCookie(this.COOKIE_NAME); + if (!cookieValue) return null; + + const locationData = JSON.parse(cookieValue); + + // Check if cookie is not too old (optional: 30 days max) + const cookieDate = new Date(locationData.timestamp); + const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days in milliseconds + if (Date.now() - cookieDate.getTime() > maxAge) { + this.clearLastLocation(); + return null; + } + + return locationData; + } catch (error) { + console.error('❌ Failed to parse location cookie:', error); + this.clearLastLocation(); + return null; + } + } + + // Clear last location + static clearLastLocation() { + CookieManager.deleteCookie(this.COOKIE_NAME); + console.log('🗑️ Location cookie cleared'); + } + + // Check if location cookie exists + static hasLastLocation() { + return this.getLastLocation() !== null; + } +} + +// Export for use in other scripts +window.CookieManager = CookieManager; +window.LocationCookieManager = LocationCookieManager; diff --git a/public/js/dashboard.js b/public/js/dashboard.js index aa890d6..229b182 100644 --- a/public/js/dashboard.js +++ b/public/js/dashboard.js @@ -132,12 +132,13 @@ async function checkLinkStatusAndLoadTimes() { try { // Check if user has a linked player - const response = await fetch(`/api/v1/public/user-player/${currentUser.id}`); + const response = await fetch(`/api/v1/public/user-player/${currentUser.id}?t=${Date.now()}`); if (response.ok) { const result = await response.json(); // User is linked, load times await loadUserTimesSection(result.data); + } else { // User is not linked showTimesNotLinked(); @@ -362,7 +363,7 @@ async function loadUserTimesSection(playerData) { showTimesLoading(); try { - const response = await fetch(`/api/v1/public/user-times/${currentUser.id}`); + const response = await fetch(`/api/v1/public/user-times/${currentUser.id}?t=${Date.now()}`); const result = await response.json(); if (!response.ok) { @@ -608,14 +609,15 @@ async function loadPlayerAchievements() { document.getElementById('achievementCategories').style.display = 'none'; document.getElementById('achievementsNotAvailable').style.display = 'none'; - // Load player achievements - const response = await fetch(`/api/achievements/player/${currentPlayerId}`); + // Load player achievements (includes all achievements with player status) + const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`); if (!response.ok) { - throw new Error('Failed to load achievements'); + throw new Error('Failed to load player achievements'); } const result = await response.json(); - playerAchievements = result.data; + window.allAchievements = result.data; + playerAchievements = result.data.filter(achievement => achievement.is_completed); // Load achievement statistics await loadAchievementStats(); @@ -639,7 +641,7 @@ async function loadPlayerAchievements() { // Load achievement statistics async function loadAchievementStats() { try { - const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats`); + const response = await fetch(`/api/achievements/player/${currentPlayerId}/stats?t=${Date.now()}`); if (response.ok) { const result = await response.json(); window.achievementStats = result.data; @@ -665,7 +667,7 @@ function displayAchievementStats() { function displayAchievements() { const achievementsGrid = document.getElementById('achievementsGrid'); - if (playerAchievements.length === 0) { + if (!window.allAchievements || window.allAchievements.length === 0) { achievementsGrid.innerHTML = `
🏆
@@ -677,9 +679,9 @@ function displayAchievements() { } // Filter achievements by category - let filteredAchievements = playerAchievements; + let filteredAchievements = window.allAchievements; if (currentAchievementCategory !== 'all') { - filteredAchievements = playerAchievements.filter(achievement => + filteredAchievements = window.allAchievements.filter(achievement => achievement.category === currentAchievementCategory ); } @@ -690,6 +692,11 @@ function displayAchievements() { const progress = achievement.progress || 0; const earnedAt = achievement.earned_at; + // Debug logging + if (achievement.name === 'Tageskönig') { + console.log('Tageskönig Debug:', { isCompleted, progress, earnedAt }); + } + let progressText = ''; if (isCompleted) { progressText = earnedAt ? @@ -780,7 +787,7 @@ async function checkPlayerAchievements() { if (!currentPlayerId) return; try { - const response = await fetch(`/api/achievements/check/${currentPlayerId}`, { + const response = await fetch(`/api/achievements/check/${currentPlayerId}?t=${Date.now()}`, { method: 'POST' }); @@ -831,6 +838,109 @@ function initializeAchievements(playerId) { loadPlayerAchievements(); } +// Web Notification Functions +function showWebNotification(title, message, icon = '🏆') { + if ('Notification' in window && Notification.permission === 'granted') { + const notification = new Notification(title, { + body: message, + icon: '/pictures/favicon.ico', + badge: '/pictures/favicon.ico', + tag: 'ninjacross-achievement', + requireInteraction: true + }); + + // Auto-close after 10 seconds + setTimeout(() => { + notification.close(); + }, 10000); + + // Handle click + notification.onclick = function() { + window.focus(); + notification.close(); + }; + } +} + +// Check for best time achievements and show notifications +async function checkBestTimeNotifications() { + try { + const response = await fetch('/api/v1/public/best-times'); + const result = await response.json(); + + if (result.success && result.data) { + const { daily, weekly, monthly } = result.data; + + // Check if current player has best times + if (currentPlayerId) { + if (daily && daily.player_id === currentPlayerId) { + showWebNotification( + '🏆 Tageskönig!', + `Glückwunsch! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!`, + '👑' + ); + } + + if (weekly && weekly.player_id === currentPlayerId) { + showWebNotification( + '🏆 Wochenchampion!', + `Fantastisch! Du bist der Wochenchampion mit ${weekly.best_time}!`, + '🏆' + ); + } + + if (monthly && monthly.player_id === currentPlayerId) { + showWebNotification( + '🏆 Monatsmeister!', + `Unglaublich! Du bist der Monatsmeister mit ${monthly.best_time}!`, + '🥇' + ); + } + } + } + } catch (error) { + console.error('Error checking best time notifications:', error); + } +} + +// Check for new achievements and show notifications +async function checkAchievementNotifications() { + try { + if (!currentPlayerId) return; + + const response = await fetch(`/api/achievements/player/${currentPlayerId}?t=${Date.now()}`); + const result = await response.json(); + + if (result.success && result.data) { + const newAchievements = result.data.filter(achievement => { + // Check if achievement was earned in the last 5 minutes + const earnedAt = new Date(achievement.earned_at); + const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000); + return earnedAt > fiveMinutesAgo; + }); + + if (newAchievements.length > 0) { + newAchievements.forEach(achievement => { + showWebNotification( + `🏆 ${achievement.name}`, + achievement.description, + achievement.icon || '🏆' + ); + }); + } + } + } catch (error) { + console.error('Error checking achievement notifications:', error); + } +} + + +// Periodic check for notifications (every 30 seconds) +setInterval(() => { + checkBestTimeNotifications(); + checkAchievementNotifications(); +}, 30000); + document.addEventListener('DOMContentLoaded', function() { // Add cookie settings button functionality const cookieSettingsBtn = document.getElementById('cookie-settings-footer'); diff --git a/public/js/leaderboard.js b/public/js/leaderboard.js index caa31cb..0420d28 100644 --- a/public/js/leaderboard.js +++ b/public/js/leaderboard.js @@ -10,6 +10,58 @@ const socket = io(); // Global variable to store locations with coordinates let locationsData = []; +let lastSelectedLocation = null; + +// Cookie Functions (inline implementation) +function setCookie(name, value, days = 30) { + const expires = new Date(); + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)); + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/;SameSite=Lax`; +} + +function getCookie(name) { + const nameEQ = name + "="; + const ca = document.cookie.split(';'); + for (let i = 0; i < ca.length; i++) { + let c = ca[i]; + while (c.charAt(0) === ' ') c = c.substring(1, c.length); + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length); + } + return null; +} + +function loadLastSelectedLocation() { + try { + const cookieValue = getCookie('ninjacross_last_location'); + if (cookieValue) { + const lastLocation = JSON.parse(cookieValue); + lastSelectedLocation = lastLocation; + console.log('📍 Last selected location loaded:', lastLocation.name); + return lastLocation; + } + } catch (error) { + console.error('Error loading last location:', error); + } + return null; +} + +function saveLocationSelection(locationId, locationName) { + try { + // Remove emoji from location name for storage + const cleanName = locationName.replace(/^📍\s*/, ''); + const locationData = { + id: locationId, + name: cleanName, + timestamp: new Date().toISOString() + }; + + setCookie('ninjacross_last_location', JSON.stringify(locationData), 90); + lastSelectedLocation = { id: locationId, name: cleanName }; + console.log('💾 Location saved to cookie:', cleanName); + } catch (error) { + console.error('Error saving location:', error); + } +} // WebSocket Event Handlers socket.on('connect', () => { @@ -129,6 +181,23 @@ async function loadLocations() { locationSelect.appendChild(option); }); + // Load and set last selected location + const lastLocation = loadLastSelectedLocation(); + if (lastLocation) { + // Find the option that matches the last location name + const matchingOption = Array.from(locationSelect.options).find(option => + option.textContent === `📍 ${lastLocation.name}` || option.value === lastLocation.name + ); + if (matchingOption) { + locationSelect.value = matchingOption.value; + console.log('📍 Last selected location restored:', lastLocation.name); + // Update the current selection display + updateCurrentSelection(); + // Load data for the restored location + loadData(); + } + } + } catch (error) { console.error('Error loading locations:', error); } @@ -489,7 +558,15 @@ function updateLeaderboard(data) { // Event Listeners Setup function setupEventListeners() { // Location select event listener - document.getElementById('locationSelect').addEventListener('change', loadData); + document.getElementById('locationSelect').addEventListener('change', function() { + // Save location selection to cookie + const selectedOption = this.options[this.selectedIndex]; + if (selectedOption.value) { + saveLocationSelection(selectedOption.value, selectedOption.textContent); + } + // Load data + loadData(); + }); // Time tab event listeners document.querySelectorAll('.time-tab').forEach(tab => { diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..969fd6e --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,43 @@ +{ + "name": "Ninja Cross Parkour", + "short_name": "NinjaCross", + "description": "Dein persönliches Dashboard für die Ninja Cross Arena", + "start_url": "/index.html", + "display": "standalone", + "background_color": "#667eea", + "theme_color": "#764ba2", + "orientation": "portrait", + "icons": [ + { + "src": "/pictures/favicon.ico", + "sizes": "192x192", + "type": "image/x-icon", + "purpose": "any maskable" + }, + { + "src": "/pictures/favicon.ico", + "sizes": "512x512", + "type": "image/x-icon", + "purpose": "any maskable" + } + ], + "categories": ["sports", "fitness", "entertainment"], + "lang": "de", + "dir": "ltr", + "scope": "/", + "prefer_related_applications": false, + "shortcuts": [ + { + "name": "Dashboard", + "short_name": "Dashboard", + "description": "Öffne dein Dashboard", + "url": "/index.html", + "icons": [ + { + "src": "/pictures/favicon.ico", + "sizes": "192x192" + } + ] + } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..d326d8e --- /dev/null +++ b/public/sw.js @@ -0,0 +1,94 @@ +// Service Worker für iPhone Notifications +const CACHE_NAME = 'ninjacross-v1'; +const urlsToCache = [ + '/', + '/index.html', + '/css/leaderboard.css', + '/js/leaderboard.js', + '/pictures/favicon.ico' +]; + +// Install event +self.addEventListener('install', function(event) { + event.waitUntil( + caches.open(CACHE_NAME) + .then(function(cache) { + return cache.addAll(urlsToCache); + }) + ); +}); + +// Fetch event +self.addEventListener('fetch', function(event) { + event.respondWith( + caches.match(event.request) + .then(function(response) { + // Return cached version or fetch from network + return response || fetch(event.request); + } + ) + ); +}); + +// Push event (für iPhone Notifications) +self.addEventListener('push', function(event) { + console.log('Push received:', event); + + const options = { + body: 'Du hast eine neue Notification!', + icon: '/pictures/favicon.ico', + badge: '/pictures/favicon.ico', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: 1 + }, + actions: [ + { + action: 'explore', + title: 'Dashboard öffnen', + icon: '/pictures/favicon.ico' + }, + { + action: 'close', + title: 'Schließen', + icon: '/pictures/favicon.ico' + } + ] + }; + + if (event.data) { + const data = event.data.json(); + options.body = data.body || options.body; + options.title = data.title || 'Ninja Cross'; + } + + event.waitUntil( + self.registration.showNotification('Ninja Cross', options) + ); +}); + +// Notification click event +self.addEventListener('notificationclick', function(event) { + console.log('Notification clicked:', event); + + event.notification.close(); + + if (event.action === 'explore') { + event.waitUntil( + clients.openWindow('/dashboard.html') + ); + } +}); + +// Background sync (für offline functionality) +self.addEventListener('sync', function(event) { + if (event.tag === 'background-sync') { + event.waitUntil(doBackgroundSync()); + } +}); + +async function doBackgroundSync() { + // Sync data when back online + console.log('Background sync triggered'); +} diff --git a/routes/api.js b/routes/api.js index d7c300e..7eee924 100644 --- a/routes/api.js +++ b/routes/api.js @@ -65,6 +65,11 @@ function convertTimeToSeconds(timeStr) { function convertIntervalToSeconds(interval) { if (!interval) return 0; + // Handle PostgreSQL interval object format + if (typeof interval === 'object' && interval.seconds !== undefined) { + return parseFloat(interval.seconds); + } + // PostgreSQL interval format: "HH:MM:SS" or "MM:SS" or just seconds if (typeof interval === 'string') { const parts = interval.split(':'); @@ -853,30 +858,27 @@ router.post('/v1/private/create-time', requireApiKey, async (req, res) => { const thresholdSeconds = convertIntervalToSeconds(location.time_threshold); if (recordedTimeSeconds < thresholdSeconds) { + // Convert threshold to readable format + const thresholdMinutes = Math.floor(thresholdSeconds / 60); + const thresholdSecs = Math.floor(thresholdSeconds % 60); + const thresholdMs = Math.floor((thresholdSeconds % 1) * 1000); + const thresholdDisplay = thresholdMinutes > 0 + ? `${thresholdMinutes}:${thresholdSecs.toString().padStart(2, '0')}.${thresholdMs.toString().padStart(3, '0')}` + : `${thresholdSecs}.${thresholdMs.toString().padStart(3, '0')}`; + return res.status(400).json({ success: false, - message: `Zeit ${recorded_time} liegt unter dem Schwellenwert von ${location.time_threshold} für diesen Standort`, + message: `Zeit ${recorded_time} liegt unter dem Schwellenwert von ${thresholdDisplay} für diesen Standort`, data: { recorded_time: recorded_time, threshold: location.time_threshold, + threshold_display: thresholdDisplay, location_name: location_name } }); } } - // Prüfen ob Zeit bereits existiert (optional - kann entfernt werden) - const existingTime = await pool.query( - 'SELECT id FROM times WHERE player_id = $1 AND location_id = $2 AND recorded_time = $3', - [player_id, location_id, recorded_time] - ); - - if (existingTime.rows.length > 0) { - return res.status(409).json({ - success: false, - message: 'Zeit existiert bereits in der Datenbank' - }); - } // Zeit in Datenbank einfügen const result = await pool.query( @@ -2134,6 +2136,38 @@ router.post('/v1/admin/runs', requireAdminAuth, async (req, res) => { try { const { player_id, location_id, time_seconds } = req.body; + // Prüfen ob Location existiert und Threshold abrufen + const locationResult = await pool.query( + 'SELECT id, name, time_threshold FROM locations WHERE id = $1', + [location_id] + ); + + if (locationResult.rows.length === 0) { + return res.status(404).json({ + success: false, + message: 'Standort nicht gefunden' + }); + } + + const location = locationResult.rows[0]; + + // Prüfen ob die Zeit über dem Schwellenwert liegt + if (location.time_threshold) { + const thresholdSeconds = convertIntervalToSeconds(location.time_threshold); + + if (time_seconds < thresholdSeconds) { + return res.status(400).json({ + success: false, + message: `Zeit ${time_seconds}s liegt unter dem Schwellenwert von ${location.time_threshold} für diesen Standort`, + data: { + recorded_time: `${time_seconds}s`, + threshold: location.time_threshold, + location_name: location.name + } + }); + } + } + // Zeit in INTERVAL konvertieren const timeInterval = `${time_seconds} seconds`; @@ -2300,6 +2334,198 @@ router.put('/v1/admin/adminusers/:id', requireAdminAuth, async (req, res) => { } }); +// ==================== BEST TIMES ENDPOINTS ==================== + +/** + * @swagger + * /api/v1/public/best-times: + * get: + * summary: Beste Zeiten abrufen + * description: Ruft die besten Zeiten des Tages, der Woche und des Monats ab + * tags: [Times] + * responses: + * 200: + * description: Beste Zeiten erfolgreich abgerufen + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * data: + * type: object + * properties: + * daily: + * type: object + * properties: + * player_id: + * type: string + * format: uuid + * player_name: + * type: string + * best_time: + * type: string + * format: time + * weekly: + * type: object + * properties: + * player_id: + * type: string + * format: uuid + * player_name: + * type: string + * best_time: + * type: string + * format: time + * monthly: + * type: object + * properties: + * player_id: + * type: string + * format: uuid + * player_name: + * type: string + * best_time: + * type: string + * format: time + * 500: + * description: Server-Fehler + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// Get best times (daily, weekly, monthly) +router.get('/v1/public/best-times', async (req, res) => { + try { + const currentDate = new Date(); + const today = currentDate.toISOString().split('T')[0]; + const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay())); + const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + + // Get daily best + const dailyResult = await pool.query(` + WITH daily_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + CONCAT(p.firstname, ' ', p.lastname) as player_name + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1 + GROUP BY t.player_id, p.firstname, p.lastname + ) + SELECT + player_id, + player_name, + best_time + FROM daily_best + WHERE best_time = (SELECT MIN(best_time) FROM daily_best) + LIMIT 1 + `, [today]); + + // Get weekly best + const weeklyResult = await pool.query(` + WITH weekly_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + CONCAT(p.firstname, ' ', p.lastname) as player_name + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + GROUP BY t.player_id, p.firstname, p.lastname + ) + SELECT + player_id, + player_name, + best_time + FROM weekly_best + WHERE best_time = (SELECT MIN(best_time) FROM weekly_best) + LIMIT 1 + `, [weekStart.toISOString().split('T')[0], today]); + + // Get monthly best + const monthlyResult = await pool.query(` + WITH monthly_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + CONCAT(p.firstname, ' ', p.lastname) as player_name + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + GROUP BY t.player_id, p.firstname, p.lastname + ) + SELECT + player_id, + player_name, + best_time + FROM monthly_best + WHERE best_time = (SELECT MIN(best_time) FROM monthly_best) + LIMIT 1 + `, [monthStart.toISOString().split('T')[0], today]); + + res.json({ + success: true, + data: { + daily: dailyResult.rows[0] || null, + weekly: weeklyResult.rows[0] || null, + monthly: monthlyResult.rows[0] || null + } + }); + } catch (error) { + console.error('Error fetching best times:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Abrufen der Bestzeiten', + error: error.message + }); + } +}); + +// ==================== PUSH NOTIFICATION ENDPOINTS ==================== + +// Subscribe to push notifications +router.post('/v1/public/subscribe', async (req, res) => { + try { + const { endpoint, keys } = req.body; + + // Store subscription in database + await pool.query(` + INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth, created_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (player_id) + DO UPDATE SET + endpoint = EXCLUDED.endpoint, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + updated_at = NOW() + `, [ + req.session.userId || 'anonymous', + endpoint, + keys.p256dh, + keys.auth + ]); + + res.json({ + success: true, + message: 'Push subscription erfolgreich gespeichert' + }); + } catch (error) { + console.error('Error storing push subscription:', error); + res.status(500).json({ + success: false, + message: 'Fehler beim Speichern der Push Subscription', + error: error.message + }); + } +}); + // ==================== ACHIEVEMENT ENDPOINTS ==================== /** @@ -2396,6 +2622,13 @@ router.get('/achievements', async (req, res) => { // Get player achievements router.get('/achievements/player/:playerId', async (req, res) => { try { + // Set no-cache headers + res.set({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + const { playerId } = req.params; const result = await pool.query(` @@ -2406,8 +2639,8 @@ router.get('/achievements/player/:playerId', async (req, res) => { a.category, a.icon, a.points, - pa.progress, - pa.is_completed, + COALESCE(pa.progress, 0) as progress, + COALESCE(pa.is_completed, false) as is_completed, pa.earned_at FROM achievements a LEFT JOIN player_achievements pa ON a.id = pa.achievement_id AND pa.player_id = $1 @@ -2434,11 +2667,25 @@ router.get('/achievements/player/:playerId', async (req, res) => { // Get player achievement statistics router.get('/achievements/player/:playerId/stats', async (req, res) => { try { + // Set no-cache headers + res.set({ + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' + }); + const { playerId } = req.params; - const result = await pool.query(` + // Get total available achievements + const totalResult = await pool.query(` + SELECT COUNT(*) as total_achievements + FROM achievements + WHERE is_active = true + `); + + // Get player's earned achievements + const playerResult = await pool.query(` SELECT - COUNT(pa.id) as total_achievements, COUNT(CASE WHEN pa.is_completed = true THEN 1 END) as completed_achievements, SUM(CASE WHEN pa.is_completed = true THEN a.points ELSE 0 END) as total_points, COUNT(CASE WHEN pa.is_completed = true AND DATE(pa.earned_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE THEN 1 END) as achievements_today @@ -2447,17 +2694,21 @@ router.get('/achievements/player/:playerId/stats', async (req, res) => { WHERE a.is_active = true `, [playerId]); - const stats = result.rows[0]; + const totalStats = totalResult.rows[0]; + const playerStats = playerResult.rows[0]; + + const totalAchievements = parseInt(totalStats.total_achievements); + const completedAchievements = parseInt(playerStats.completed_achievements) || 0; + const completionPercentage = totalAchievements > 0 ? Math.round((completedAchievements / totalAchievements) * 100) : 0; res.json({ success: true, data: { - total_achievements: parseInt(stats.total_achievements), - completed_achievements: parseInt(stats.completed_achievements), - total_points: parseInt(stats.total_points) || 0, - achievements_today: parseInt(stats.achievements_today), - completion_percentage: stats.total_achievements > 0 ? - Math.round((stats.completed_achievements / stats.total_achievements) * 100) : 0 + total_achievements: totalAchievements, + completed_achievements: completedAchievements, + total_points: parseInt(playerStats.total_points) || 0, + achievements_today: parseInt(playerStats.achievements_today) || 0, + completion_percentage: completionPercentage } }); } catch (error) { diff --git a/scripts/best_time_notifications.js b/scripts/best_time_notifications.js new file mode 100644 index 0000000..daeb7a1 --- /dev/null +++ b/scripts/best_time_notifications.js @@ -0,0 +1,141 @@ +const { Pool } = require('pg'); +const cron = require('node-cron'); + +// Database connection +const pool = new Pool({ + user: 'postgres', + host: 'localhost', + database: 'ninjacross', + password: 'postgres', + port: 5432, +}); + +async function checkAndNotifyBestTimes() { + try { + console.log('🔔 Checking best times for notifications...'); + + const currentDate = new Date(); + const today = currentDate.toISOString().split('T')[0]; + const weekStart = new Date(currentDate.setDate(currentDate.getDate() - currentDate.getDay())); + const monthStart = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + + // Check daily best times + const dailyBestQuery = ` + WITH daily_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + p.name as player_name, + p.email + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = $1 + GROUP BY t.player_id, p.name, p.email + ), + global_daily_best AS ( + SELECT MIN(best_time) as global_best + FROM daily_best + ) + SELECT + db.player_id, + db.player_name, + db.email, + db.best_time, + gdb.global_best + FROM daily_best db + CROSS JOIN global_daily_best gdb + WHERE db.best_time = gdb.global_best + `; + + const dailyResult = await pool.query(dailyBestQuery, [today]); + + for (const row of dailyResult.rows) { + console.log(`🏆 Daily best time: ${row.player_name} with ${row.best_time}`); + // Here we would send the notification + // For now, just log it + } + + // Check weekly best times + const weeklyBestQuery = ` + WITH weekly_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + p.name as player_name, + p.email + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + GROUP BY t.player_id, p.name, p.email + ), + global_weekly_best AS ( + SELECT MIN(best_time) as global_best + FROM weekly_best + ) + SELECT + wb.player_id, + wb.player_name, + wb.email, + wb.best_time, + gwb.global_best + FROM weekly_best wb + CROSS JOIN global_weekly_best gwb + WHERE wb.best_time = gwb.global_best + `; + + const weeklyResult = await pool.query(weeklyBestQuery, [weekStart.toISOString().split('T')[0], today]); + + for (const row of weeklyResult.rows) { + console.log(`🏆 Weekly best time: ${row.player_name} with ${row.best_time}`); + } + + // Check monthly best times + const monthlyBestQuery = ` + WITH monthly_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + p.name as player_name, + p.email + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') >= $1 + AND DATE(t.created_at AT TIME ZONE 'Europe/Berlin') <= $2 + GROUP BY t.player_id, p.name, p.email + ), + global_monthly_best AS ( + SELECT MIN(best_time) as global_best + FROM monthly_best + ) + SELECT + mb.player_id, + mb.player_name, + mb.email, + mb.best_time, + gmb.global_best + FROM monthly_best mb + CROSS JOIN global_monthly_best gmb + WHERE mb.best_time = gmb.global_best + `; + + const monthlyResult = await pool.query(monthlyBestQuery, [monthStart.toISOString().split('T')[0], today]); + + for (const row of monthlyResult.rows) { + console.log(`🏆 Monthly best time: ${row.player_name} with ${row.best_time}`); + } + + console.log('✅ Best time notifications check completed'); + + } catch (error) { + console.error('❌ Error checking best times:', error); + } +} + +// Schedule to run every day at 19:00 (7 PM) +cron.schedule('0 19 * * *', () => { + console.log('🕐 Running best time notifications check...'); + checkAndNotifyBestTimes(); +}); + +console.log('📅 Best time notifications scheduler started - runs daily at 19:00'); diff --git a/scripts/iphone_notifications.js b/scripts/iphone_notifications.js new file mode 100644 index 0000000..45a90b4 --- /dev/null +++ b/scripts/iphone_notifications.js @@ -0,0 +1,135 @@ +const { Pool } = require('pg'); +const webpush = require('web-push'); + +// Database connection +const pool = new Pool({ + user: 'postgres', + host: 'localhost', + database: 'ninjacross', + password: 'postgres', + port: 5432, +}); + +// VAPID Keys (generate with: webpush.generateVAPIDKeys()) +const vapidKeys = { + publicKey: 'BEl62iUYgUivxIkv69yViEuiBIa40HI6F2B5L4h7Q8Y', + privateKey: 'your-private-key-here' +}; + +webpush.setVapidDetails( + 'mailto:admin@ninjacross.es', + vapidKeys.publicKey, + vapidKeys.privateKey +); + +// Store subscription endpoint for each player +async function storePlayerSubscription(playerId, subscription) { + try { + await pool.query(` + INSERT INTO player_subscriptions (player_id, endpoint, p256dh, auth) + VALUES ($1, $2, $3, $4) + ON CONFLICT (player_id) + DO UPDATE SET + endpoint = EXCLUDED.endpoint, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + updated_at = NOW() + `, [ + playerId, + subscription.endpoint, + subscription.keys.p256dh, + subscription.keys.auth + ]); + console.log(`✅ Subscription stored for player ${playerId}`); + } catch (error) { + console.error('Error storing subscription:', error); + } +} + +// Send push notification to player +async function sendPushNotification(playerId, title, message, icon = '🏆') { + try { + const result = await pool.query(` + SELECT endpoint, p256dh, auth + FROM player_subscriptions + WHERE player_id = $1 + `, [playerId]); + + if (result.rows.length === 0) { + console.log(`No subscription found for player ${playerId}`); + return; + } + + const subscription = { + endpoint: result.rows[0].endpoint, + keys: { + p256dh: result.rows[0].p256dh, + auth: result.rows[0].auth + } + }; + + const payload = JSON.stringify({ + title: title, + body: message, + icon: '/pictures/favicon.ico', + badge: '/pictures/favicon.ico', + data: { + url: '/dashboard.html' + } + }); + + await webpush.sendNotification(subscription, payload); + console.log(`✅ Push notification sent to player ${playerId}`); + + } catch (error) { + console.error('Error sending push notification:', error); + } +} + +// Send best time notifications +async function sendBestTimeNotifications() { + try { + console.log('🔔 Sending best time notifications...'); + + // Get daily best + const dailyResult = await pool.query(` + WITH daily_best AS ( + SELECT + t.player_id, + MIN(t.recorded_time) as best_time, + CONCAT(p.firstname, ' ', p.lastname) as player_name + FROM times t + JOIN players p ON t.player_id = p.id + WHERE DATE(t.created_at AT TIME ZONE 'Europe/Berlin') = CURRENT_DATE + GROUP BY t.player_id, p.firstname, p.lastname + ) + SELECT + player_id, + player_name, + best_time + FROM daily_best + WHERE best_time = (SELECT MIN(best_time) FROM daily_best) + LIMIT 1 + `); + + if (dailyResult.rows.length > 0) { + const daily = dailyResult.rows[0]; + await sendPushNotification( + daily.player_id, + '🏆 Tageskönig!', + `Glückwunsch ${daily.player_name}! Du hast die beste Zeit des Tages mit ${daily.best_time} erreicht!` + ); + } + + console.log('✅ Best time notifications sent'); + + } catch (error) { + console.error('❌ Error sending best time notifications:', error); + } +} + +module.exports = { + storePlayerSubscription, + sendPushNotification, + sendBestTimeNotifications +};