Notifications, div fixes, kekse für last location

This commit is contained in:
2025-09-06 12:37:10 +02:00
parent 61d5ef2e6f
commit 8342d95a13
13 changed files with 1325 additions and 39 deletions

264
package-lock.json generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -1374,3 +1374,9 @@ body {
max-width: none;
}
}
.last-location-info small {
color: #b3e5fc;
font-size: 0.85rem;
}

View File

@@ -5,10 +5,68 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPEEDRUN ARENA - Admin Dashboard</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<link rel="manifest" href="/manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<meta name="apple-mobile-web-app-title" content="Ninja Cross">
<link rel="apple-touch-icon" href="/pictures/favicon.ico">
<script src="https://unpkg.com/@supabase/supabase-js@2"></script>
<!-- QR Code Scanner Library -->
<script src="https://unpkg.com/jsqr@1.4.0/dist/jsQR.js"></script>
<link rel="stylesheet" href="/css/dashboard.css">
<!-- Notification Permission Script -->
<script>
// Register Service Worker for iPhone Notifications
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(function(registration) {
console.log('✅ Service Worker registered:', registration);
})
.catch(function(error) {
console.log('❌ Service Worker registration failed:', error);
});
}
// Request notification permission on page load
if ('Notification' in window) {
if (Notification.permission === 'default') {
Notification.requestPermission().then(function(permission) {
if (permission === 'granted') {
console.log('✅ Notification permission granted');
// Subscribe to push notifications
subscribeToPush();
} else {
console.log('❌ Notification permission denied');
}
});
}
}
// Subscribe to push notifications
async function subscribeToPush() {
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: 'BEl62iUYgUivxIkv69yViEuiBIa40HI6F2B5L4h7Q8Y'
});
// Send subscription to server
await fetch('/api/v1/public/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(subscription)
});
console.log('✅ Push subscription successful');
} catch (error) {
console.error('❌ Push subscription failed:', error);
}
}
</script>
</head>
<body>
<div class="main-container">
@@ -34,6 +92,7 @@
<div class="welcome-card">
<h2>Dein Dashboard 🥷</h2>
<p>Willkommen in Deinem Dashboard-Panel! Deine übersichtliche Übersicht aller deiner Läufe.</p>
</div>
<div class="dashboard-grid">
@@ -252,6 +311,6 @@
</footer>
<script src="/js/cookie-consent.js"></script>
<script src="/js/dashboard.js"></script>
<script src="/js/dashboard.js?v=1.1"></script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<title>Timer Leaderboard</title>
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico">
<script src="/js/page-tracking.js"></script>
<script src="/js/cookie-utils.js"></script>
<link rel="stylesheet" href="/css/leaderboard.css">
</head>
<body>

105
public/js/cookie-utils.js Normal file
View File

@@ -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;

View File

@@ -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 = `
<div class="no-achievements">
<div class="no-achievements-icon">🏆</div>
@@ -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');

View File

@@ -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 => {

43
public/manifest.json Normal file
View File

@@ -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"
}
]
}
]
}

94
public/sw.js Normal file
View File

@@ -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');
}

View File

@@ -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) {

View File

@@ -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');

View File

@@ -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
};