13 Commits
Author SHA1 Message Date
kyle 8a894e8365 Merge branch 'deep-ketch' into development
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
2026-08-23 20:31:49 -07:00
kyle e3fea18ab2 badges improvements 2026-08-23 20:31:44 -07:00
kyle 58702da156 style: trim narration comments from recent changes 2026-08-23 20:06:05 -07:00
kyle c342b91029 feat(header): shrink long titles to fit instead of wrapping
The header keeps its size; a title that would overflow scales down to a
floor of 55% and ellipsizes only past that. Re-measures on title change,
resize, and web-font load.
2026-08-23 20:06:05 -07:00
kyle 792425a83e fix(i18n): hydrate in the SSR locale instead of regressing to en
The client's message cache held only en at hydration, so a non-en user
rendered English until the locale's catalog chunk loaded — and stayed
English if that import failed or lost a race. __root beforeLoad now
dehydrates the active locale's compiled catalog with the page and the
provider primes its cache from that payload, so first paint is already in
the user's locale with no dependency on a client-side chunk load.
ensureMessages dedupes concurrent loads and drops rejected ones so a
retry can succeed, and the provider's fallback loader retries instead of
silently stranding the UI in en.
2026-08-23 19:47:31 -07:00
kyle ac2e61731c Merge branch 'theme-discovery-presets' into development 2026-08-23 19:26:37 -07:00
kyle 11bb70316a fix(pwa): simplify the install prompt copy 2026-08-23 19:03:57 -07:00
kyle 9fc79dfc07 fix(audit): readable names, single writer, denial rows, redacted args
The activity log recorded Start's hashed function id (a sha256 URL
segment) as the name; the middleware now reads compile-time
serverFnMeta.name with the path segment as fallback. toServerResult
resolves { success:false } instead of throwing, so the middleware logged
failures as successes while toServerResult wrote a duplicate row with its
own dead name parser — the middleware is now the single writer and reads
the envelope's success flag. Admin denials, which threw before the logging
middleware ran, get their own audit row. Arguments are redacted
(phone/otp/token keys) and truncated at 2KB. The admin activities search
binds its filter parameters (likePattern escaping) instead of
interpolating raw input. Adds vitest with node-env tests for the
middleware, redaction, and filter utils, and extends logging coverage to
mutating fns that lacked it.
2026-08-23 18:27:53 -07:00
kyle 60e91d1371 fix(pocketbase): self-heal admin auth instead of stranding requests
beforeSend now skips its gate for the auth endpoints (no self-await
deadlock), swallows a rejected authPromise and re-authenticates when the
store is invalid, re-stamps Authorization after the gate so requests built
mid-auth carry the fresh token, and guards startTokenRefresh against
stacking intervals. A failed boot auth logs a diagnostic instead of
crashing the process; recovery happens on the next request.
2026-08-23 18:27:45 -07:00
kyle a45c2aeafa fix(pwa): reword the install prompt and keep it off the bottom nav
The banner sat fixed at the viewport bottom at z-index 1000, covering the
authed shell's mobile nav; it now offsets above the nav's geometry when a
signed-in mobile user has one. New casual copy replaces the Twilio line,
the dismiss button gains a localized aria-label, and de/es/ja catalogs
carry the new strings.
2026-08-23 18:27:40 -07:00
kyle f63dfde85d fix(ui): defer the visual-viewport read out of render
Reading window during render made the client's first render disagree with
the SSR markup; start at zero and fill in after mount, matching Mantine's
useViewportSize contract.
2026-08-23 18:27:35 -07:00
kyle 5ac39ac7a2 Merge branch 'development' into player-picker-single-sele
# Conflicts:
#	src/locales/de/messages.po
#	src/locales/en/messages.po
#	src/locales/es/messages.po
#	src/locales/ja/messages.po
2026-08-23 17:59:55 -07:00
kyle 733615b9e0 redesign h2h player picker: single slot + Me quick-select 2026-08-23 17:59:24 -07:00
34 changed files with 1387 additions and 440 deletions
+52 -1
View File
@@ -62,6 +62,7 @@
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1", "workbox-build": "^7.4.1",
}, },
}, },
@@ -499,6 +500,8 @@
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="], "@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="],
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
@@ -631,6 +634,10 @@
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="], "@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
@@ -665,6 +672,20 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.2", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
"@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
@@ -687,6 +708,8 @@
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -749,6 +772,8 @@
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="], "caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
@@ -855,6 +880,8 @@
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
@@ -867,12 +894,14 @@
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="], "eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="], "facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="],
@@ -1189,6 +1218,8 @@
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], "ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
@@ -1397,6 +1428,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="], "smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="],
@@ -1413,6 +1446,10 @@
"srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="], "srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
"stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], "stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -1457,10 +1494,16 @@
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="], "tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="], "tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
@@ -1541,6 +1584,8 @@
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
@@ -1561,6 +1606,8 @@
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="], "which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="], "workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="],
"workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="], "workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="],
@@ -1817,6 +1864,8 @@
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], "@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
@@ -1907,6 +1956,8 @@
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="], "workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
+3 -1
View File
@@ -12,7 +12,8 @@
"extract": "lingui extract --clean", "extract": "lingui extract --clean",
"i18n:check": "lingui extract --clean && lingui compile --strict", "i18n:check": "lingui extract --clean && lingui compile --strict",
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs", "build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
"start": "bun run server.ts" "start": "bun run server.ts",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
@@ -73,6 +74,7 @@
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4", "vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.1.11",
"workbox-build": "^7.4.1" "workbox-build": "^7.4.1"
} }
} }
@@ -0,0 +1,36 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
// Dedupe before adding the unique index: keep the best row per
// (player, badge) — earned first, then highest progress, then newest.
app.db().newQuery(`
DELETE FROM badge_progress
WHERE id NOT IN (
SELECT id FROM (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY player, badge
ORDER BY earned DESC, progress DESC, updated DESC
) AS rn
FROM badge_progress
)
WHERE rn = 1
)
`).execute()
const collection = app.findCollectionByNameOrId("pbc_1063824264")
unmarshal({
"indexes": [
"CREATE UNIQUE INDEX `idx_badge_progress_player_badge` ON `badge_progress` (`player`, `badge`)"
]
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1063824264")
unmarshal({
"indexes": []
}, collection)
return app.save(collection)
})
+15 -2
View File
@@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config"; import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n"; import {
DEFAULT_LOCALE,
ensureMessages,
getCachedMessages,
i18nMessagesQueryKey,
resolveLocale,
} from "@/lib/i18n";
import { getRootMeta } from "@/lib/i18n/meta"; import { getRootMeta } from "@/lib/i18n/meta";
import FullScreenLoader from "@/components/full-screen-loader"; import FullScreenLoader from "@/components/full-screen-loader";
import { CHROME_COLORS } from "@/lib/mantine/theme-colors"; import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
@@ -132,7 +138,14 @@ export const Route = createRootRouteWithContext<{
context.queryClient, context.queryClient,
playerQueries.auth() playerQueries.auth()
); );
await ensureMessages(resolveLocale(auth?.metadata?.locale)); const locale = resolveLocale(auth?.metadata?.locale);
await ensureMessages(locale);
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
context.queryClient.setQueryData(
i18nMessagesQueryKey(locale),
getCachedMessages(locale)
);
}
return { auth }; return { auth };
} catch (error: any) { } catch (error: any) {
if (isRedirect(error) || error instanceof Response) throw error; if (isRedirect(error) || error instanceof Response) throw error;
+14 -3
View File
@@ -2,9 +2,15 @@ import { useEffect, useState } from 'react'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core' import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react' import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro' import { Trans, useLingui } from '@lingui/react/macro'
import { useAuth } from '@/contexts/auth-context'
import { useIsMobile } from '@/hooks/use-is-mobile'
const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))'
export function IOSInstallPrompt() { export function IOSInstallPrompt() {
const { t } = useLingui() const { t } = useLingui()
const { user } = useAuth()
const isMobile = useIsMobile()
const [show, setShow] = useState(false) const [show, setShow] = useState(false)
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null) const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
@@ -36,22 +42,27 @@ export function IOSInstallPrompt() {
? t`Tap Share → Add to Home Screen` ? t`Tap Share → Add to Home Screen`
: t`Tap Menu (⋮) → Add to Home screen` : t`Tap Menu (⋮) → Add to Home screen`
const aboveBottomNav = Boolean(user) && isMobile
const bottomStyle = aboveBottomNav
? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' }
: { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }
return ( return (
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}> <Box style={{ position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '8px', ...bottomStyle }}>
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}> <Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
<Group justify="space-between" wrap="nowrap"> <Group justify="space-between" wrap="nowrap">
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}> <Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
<DownloadIcon size={20} style={{ flexShrink: 0 }} /> <DownloadIcon size={20} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1, minWidth: 0 }}> <Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}> <Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans> <Trans>Add FLXN to your home screen for a smoother experience</Trans>
</Text> </Text>
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}> <Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
{instructions} {instructions}
</Text> </Text>
</Box> </Box>
</Group> </Group>
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}> <ActionIcon variant="subtle" color="white" onClick={handleDismiss} aria-label={t`Dismiss`}>
<XIcon size={18} /> <XIcon size={18} />
</ActionIcon> </ActionIcon>
</Group> </Group>
+51 -8
View File
@@ -1,5 +1,5 @@
import { List } from "@mantine/core"; import { List, Text } from "@mantine/core";
import { useLingui } from "@lingui/react/macro"; import { Plural, Trans, useLingui } from "@lingui/react/macro";
import ListLink from "@/components/list-link"; import ListLink from "@/components/list-link";
import { import {
DatabaseIcon, DatabaseIcon,
@@ -10,19 +10,38 @@ import {
ListIcon, ListIcon,
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import ListButton from "@/components/list-button"; import ListButton from "@/components/list-button";
import { migrateBadgeProgress } from "@/features/badges/server"; import { getBadgeMigrationStatus, migrateBadgeProgress } from "@/features/badges/server";
import { useState } from "react"; import { useServerQuery } from "@/lib/tanstack-query/hooks";
import toast from "@/lib/sonner";
const AdminPage = () => { const AdminPage = () => {
const { t } = useLingui(); const { t } = useLingui();
const [isMigrating, setIsMigrating] = useState(false);
const { data: migrationStatus, refetch: refetchMigrationStatus } = useServerQuery({
queryKey: ["admin", "badge-migration-status"],
queryFn: () => getBadgeMigrationStatus(),
options: {
refetchInterval: (query) =>
query.state.data?.state === "running" ? 2000 : false,
},
});
const isMigrating = migrationStatus?.state === "running";
const handleMigrateBadges = async () => { const handleMigrateBadges = async () => {
if (isMigrating) return; if (isMigrating) return;
setIsMigrating(true); const result = await migrateBadgeProgress();
await migrateBadgeProgress(); if (result.success) {
setIsMigrating(false); toast.success(
result.data.started
? t`Badge recalculation started in the background`
: t`Badge recalculation is already running`
);
} else {
toast.error(t`Failed to start badge recalculation`);
}
await refetchMigrationStatus();
}; };
return ( return (
@@ -43,6 +62,30 @@ const AdminPage = () => {
onClick={handleMigrateBadges} onClick={handleMigrateBadges}
loading={isMigrating} loading={isMigrating}
/> />
{migrationStatus?.state === "running" && (
<Text size="sm" c="dimmed" px="md" py="xs">
<Trans>
Recalculating badges in the background ({migrationStatus.playersProcessed}/{migrationStatus.totalPlayers})
</Trans>
</Text>
)}
{migrationStatus?.state === "completed" && (
<Text size="sm" c="dimmed" px="md" py="xs">
<Trans>
Last recalculation:{" "}
<Plural
value={migrationStatus.result.totalBadgesEarned}
one="# badge earned"
other="# badges earned"
/>
</Trans>
</Text>
)}
{migrationStatus?.state === "failed" && (
<Text size="sm" c="red" px="md" py="xs">
<Trans>Badge recalculation failed. Check the server logs.</Trans>
</Text>
)}
<ListLink <ListLink
label={t`Activities`} label={t`Activities`}
Icon={ListIcon} Icon={ListIcon}
+55
View File
@@ -0,0 +1,55 @@
import { pbAdmin } from "@/lib/pocketbase/client";
import { emitServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger";
export type BadgeMigrationResult = {
playersProcessed: number;
totalBadgesEarned: number;
created: number;
updated: number;
deleted: number;
averageBadgesPerPlayer: string;
};
export type BadgeMigrationStatus =
| { state: "idle" }
| { state: "running"; startedAt: string; playersProcessed: number; totalPlayers: number }
| { state: "completed"; startedAt: string; finishedAt: string; result: BadgeMigrationResult }
| { state: "failed"; startedAt: string; finishedAt: string; error: string };
// In-process job state — valid only because the app runs a single replica.
let status: BadgeMigrationStatus = { state: "idle" };
export const getBadgeMigrationJobStatus = (): BadgeMigrationStatus => status;
export const startBadgeMigration = (): { started: boolean; status: BadgeMigrationStatus } => {
if (status.state === "running") {
return { started: false, status };
}
const startedAt = new Date().toISOString();
status = { state: "running", startedAt, playersProcessed: 0, totalPlayers: 0 };
void pbAdmin
.migrateBadgeProgress((playersProcessed, totalPlayers) => {
if (status.state === "running") {
status = { ...status, playersProcessed, totalPlayers };
}
})
.then((result) => {
status = { state: "completed", startedAt, finishedAt: new Date().toISOString(), result };
logger.info("Badge migration completed", result);
emitServerEvent({ type: "badge" });
})
.catch((error) => {
status = {
state: "failed",
startedAt,
finishedAt: new Date().toISOString(),
error: error instanceof Error ? error.message : String(error),
};
logger.error("Badge migration failed", error);
});
return { started: true, status };
};
+10 -6
View File
@@ -1,9 +1,11 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens"; import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { emitServerEvent } from "@/lib/events/emitter"; import { emitServerEvent } from "@/lib/events/emitter";
import { getBadgeMigrationJobStatus, startBadgeMigration } from "./migration.server";
export const getPlayerBadges = createServerFn() export const getPlayerBadges = createServerFn()
.validator(z.string()) .validator(z.string())
@@ -13,13 +15,15 @@ export const getPlayerBadges = createServerFn()
); );
export const migrateBadgeProgress = createServerFn() export const migrateBadgeProgress = createServerFn()
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async () =>
toServerResult(async () => startBadgeMigration())
);
export const getBadgeMigrationStatus = createServerFn()
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async () => .handler(async () =>
toServerResult(async () => { toServerResult(async () => getBadgeMigrationJobStatus())
const result = await pbAdmin.migrateBadgeProgress();
emitServerEvent({ type: "badge" });
return result;
})
); );
export const getAllBadges = createServerFn() export const getAllBadges = createServerFn()
@@ -37,7 +41,7 @@ export const awardManualBadge = createServerFn()
playerId: z.string(), playerId: z.string(),
badgeId: z.string(), badgeId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId); const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
+56 -2
View File
@@ -1,15 +1,57 @@
import { useLayoutEffect, useRef } from "react";
import { Title, AppShell, Flex } from "@mantine/core"; import { Title, AppShell, Flex } from "@mantine/core";
import { useLingui } from "@lingui/react"; import { useLingui } from "@lingui/react";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button"; import BackButton from "./back-button";
const MIN_TITLE_SCALE = 0.55;
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => { const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
const { i18n } = useLingui(); const { i18n } = useLingui();
const titleRef = useRef<HTMLHeadingElement>(null);
const resolvedTitle = const resolvedTitle =
typeof title === "string" || title === undefined typeof title === "string" || title === undefined
? title ? title
: i18n._({ ...title, values: titleValues }); : i18n._({ ...title, values: titleValues });
useLayoutEffect(() => {
const el = titleRef.current;
if (!el) return;
let cancelled = false;
const textWidth = () => {
const range = document.createRange();
range.selectNodeContents(el);
return range.getBoundingClientRect().width;
};
const fit = () => {
el.style.fontSize = "";
const available = el.clientWidth - 1;
if (available <= 0) return;
let needed = textWidth();
if (needed <= available) return;
const natural = parseFloat(getComputedStyle(el).fontSize);
let scale = Math.max(available / needed, MIN_TITLE_SCALE);
el.style.fontSize = `${natural * scale}px`;
for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) {
scale = Math.max(scale * 0.98, MIN_TITLE_SCALE);
el.style.fontSize = `${natural * scale}px`;
}
};
fit();
document.fonts?.ready.then(() => {
if (!cancelled) fit();
});
const observer = new ResizeObserver(fit);
observer.observe(el);
return () => {
cancelled = true;
observer.disconnect();
};
}, [resolvedTitle]);
return ( return (
<AppShell.Header <AppShell.Header
id='app-header' id='app-header'
@@ -20,8 +62,20 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
}} }}
> >
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' px='md' mt={8}> <Flex justify='center' px={48} mt={8} w='100%'>
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}> <Title
ref={titleRef}
order={1}
lts='0.08em'
style={{
userSelect: 'none',
width: '100%',
textAlign: 'center',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{resolvedTitle?.toLocaleUpperCase()} {resolvedTitle?.toLocaleUpperCase()}
</Title> </Title>
</Flex> </Flex>
@@ -5,11 +5,10 @@ const eventListerOptions = {
}; };
const useVisualViewportSize = () => { const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({ const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0, width: 0,
height: windowExists ? window.visualViewport?.height || 0 : 0, height: 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0, top: 0,
}); });
const rafRef = useRef<number | null>(null); const rafRef = useRef<number | null>(null);
@@ -1,8 +1,9 @@
import { Stack, Text, TextInput, Box, Paper, Group, Divider, Center, ActionIcon, Badge } from "@mantine/core"; import { Stack, Text, TextInput, Box, Paper, Group, Divider, ActionIcon, Button } from "@mantine/core";
import { useState, useMemo } from "react"; import { useState, useMemo } from "react";
import { MagnifyingGlassIcon, XIcon, ArrowRightIcon } from "@phosphor-icons/react"; import { MagnifyingGlassIcon, XIcon, ArrowRightIcon, PlusIcon } from "@phosphor-icons/react";
import { Trans, useLingui } from "@lingui/react/macro"; import { Trans, useLingui } from "@lingui/react/macro";
import { useAllPlayerStats } from "../queries"; import { useAllPlayerStats } from "../queries";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import PlayerHeadToHeadSheet from "./player-head-to-head-sheet"; import PlayerHeadToHeadSheet from "./player-head-to-head-sheet";
@@ -10,16 +11,24 @@ import PlayerAvatar from "@/components/player-avatar";
const LeagueHeadToHead = () => { const LeagueHeadToHead = () => {
const { t } = useLingui(); const { t } = useLingui();
const { user } = useAuth();
const [player1Id, setPlayer1Id] = useState<string | null>(null); const [player1Id, setPlayer1Id] = useState<string | null>(null);
const [player2Id, setPlayer2Id] = useState<string | null>(null); const [player2Id, setPlayer2Id] = useState<string | null>(null);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const { data: allPlayerStats } = useAllPlayerStats(); const { data: allPlayerStats } = useAllPlayerStats();
const h2hSheet = useSheet(); const h2hSheet = useSheet();
const myName = useMemo(() => {
if (!user) return "";
const fromStats = allPlayerStats?.find((p) => p.player_id === user.id)?.player_name;
return fromStats || [user.first_name, user.last_name].filter(Boolean).join(" ");
}, [user, allPlayerStats]);
const player1Name = useMemo(() => { const player1Name = useMemo(() => {
if (!player1Id || !allPlayerStats) return ""; if (!player1Id) return "";
return allPlayerStats.find((p) => p.player_id === player1Id)?.player_name || ""; if (user && player1Id === user.id) return myName;
}, [player1Id, allPlayerStats]); return allPlayerStats?.find((p) => p.player_id === player1Id)?.player_name || "";
}, [player1Id, allPlayerStats, user, myName]);
const player2Name = useMemo(() => { const player2Name = useMemo(() => {
if (!player2Id || !allPlayerStats) return ""; if (!player2Id || !allPlayerStats) return "";
@@ -30,21 +39,17 @@ const LeagueHeadToHead = () => {
if (!allPlayerStats) return []; if (!allPlayerStats) return [];
return allPlayerStats return allPlayerStats
.filter((stat) => { .filter((stat) => stat.player_id !== player1Id)
if (player1Id && stat.player_id === player1Id) return false;
if (player2Id && stat.player_id === player2Id) return false;
return true;
})
.filter((stat) => .filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase()) stat.player_name.toLowerCase().includes(search.toLowerCase())
) )
.sort((a, b) => b.matches - a.matches); .sort((a, b) => b.matches - a.matches);
}, [allPlayerStats, player1Id, player2Id, search]); }, [allPlayerStats, player1Id, search]);
const handlePlayerClick = (playerId: string) => { const handlePlayerClick = (playerId: string) => {
if (!player1Id) { if (!player1Id) {
setPlayer1Id(playerId); setPlayer1Id(playerId);
} else if (!player2Id) { } else {
setPlayer2Id(playerId); setPlayer2Id(playerId);
h2hSheet.open(); h2hSheet.open();
} }
@@ -52,160 +57,73 @@ const LeagueHeadToHead = () => {
const handleClearPlayer1 = () => { const handleClearPlayer1 = () => {
setPlayer1Id(null); setPlayer1Id(null);
if (player2Id) {
setPlayer1Id(player2Id);
setPlayer2Id(null);
}
};
const handleClearPlayer2 = () => {
setPlayer2Id(null); setPlayer2Id(null);
}; };
const activeStep = !player1Id ? 1 : !player2Id ? 2 : 0;
return ( return (
<> <>
<Stack gap="md"> <Stack gap="md">
<Paper px="md" pt="md" pb="sm" withBorder shadow="sm"> <Box px="md" pt="md">
<Stack gap="xs"> <Paper
<Group gap="xs" wrap="nowrap"> p="sm"
<Paper withBorder
p="sm" shadow="sm"
withBorder style={{
style={{ minHeight: 64,
flex: 1, display: "flex",
minHeight: 70, alignItems: "center",
display: "flex", borderWidth: 2,
alignItems: "center", borderColor: !player1Id ? "var(--mantine-primary-color-filled)" : undefined,
justifyContent: "center", transition: "border-color 150ms ease",
position: "relative", }}
borderWidth: 2, >
borderColor: activeStep === 1 ? "var(--mantine-primary-color-filled)" : undefined, {player1Id ? (
backgroundColor: player1Id && activeStep !== 1 ? "var(--mantine-color-default-hover)" : undefined, <Group justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
cursor: player1Id && activeStep === 0 ? "pointer" : undefined, <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
transition: "all 150ms ease", <PlayerAvatar name={player1Name} size={36} disableFullscreen />
}} <Box style={{ minWidth: 0 }}>
onClick={player1Id && activeStep === 0 ? handleClearPlayer1 : undefined} <Text size="sm" fw={600} truncate>
> {player1Name}
{player1Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<PlayerAvatar name={player1Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player1Name}
</Text>
</Stack>
<ActionIcon
variant="light"
color="gray"
size="xs"
radius="xl"
onClick={(e) => {
e.stopPropagation();
handleClearPlayer1();
}}
style={{ position: "absolute", top: 4, right: 4 }}
>
<XIcon size={10} />
</ActionIcon>
</>
) : (
<Stack gap={4} align="center">
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
<Trans>Player 1</Trans>
</Text> </Text>
</Stack> <Text size="xs" c="dimmed">
)} <Trans>Tap an opponent below to compare</Trans>
</Paper>
<Center>
<Text size="xl" fw={700} c="dimmed">
<Trans>VS</Trans>
</Text>
</Center>
<Paper
p="sm"
withBorder
style={{
flex: 1,
minHeight: 70,
display: "flex",
alignItems: "center",
justifyContent: "center",
position: "relative",
borderWidth: 2,
borderColor: activeStep === 2 ? "var(--mantine-primary-color-filled)" : undefined,
backgroundColor: player2Id && activeStep !== 2 ? "var(--mantine-color-default-hover)" : undefined,
cursor: player2Id && activeStep === 0 ? "pointer" : undefined,
transition: "all 150ms ease",
}}
onClick={player2Id && activeStep === 0 ? handleClearPlayer2 : undefined}
>
{player2Id ? (
<>
<Stack gap={4} align="center" style={{ flex: 1 }}>
<PlayerAvatar name={player2Name} size={36} disableFullscreen />
<Text size="xs" fw={600} ta="center" lineClamp={1}>
{player2Name}
</Text>
</Stack>
<ActionIcon
variant="light"
color="gray"
size="xs"
radius="xl"
onClick={(e) => {
e.stopPropagation();
handleClearPlayer2();
}}
style={{ position: "absolute", top: 4, right: 4 }}
>
<XIcon size={10} />
</ActionIcon>
</>
) : (
<Stack gap={4} align="center">
<PlayerAvatar size={36} disableFullscreen />
<Text size="xs" c="dimmed" fw={500}>
<Trans>Player 2</Trans>
</Text> </Text>
</Stack> </Box>
)} </Group>
</Paper> <ActionIcon
</Group> variant="light"
color="gray"
{activeStep > 0 ? ( size="sm"
<Badge radius="xl"
variant="light" onClick={handleClearPlayer1}
size="sm" aria-label={t`Clear selected player`}
radius="sm"
fullWidth
styles={{ label: { textTransform: "none" } }}
>
{activeStep === 1 && <Trans>Select first player</Trans>}
{activeStep === 2 && <Trans>Select second player</Trans>}
</Badge>
) : (
<Group justify="center">
<Text
size="xs"
c="dimmed"
style={{ cursor: "pointer" }}
onClick={() => {
setPlayer1Id(null);
setPlayer2Id(null);
}}
td="underline"
> >
<Trans>Clear both players</Trans> <XIcon size={12} />
</Text> </ActionIcon>
</Group>
) : (
<Group justify="space-between" wrap="nowrap" style={{ flex: 1 }}>
<Group gap="sm" wrap="nowrap">
<PlayerAvatar size={36} disableFullscreen />
<Text size="sm" c="dimmed" fw={500}>
<Trans>Select a player</Trans>
</Text>
</Group>
{user && (
<Button
variant="light"
size="xs"
radius="xl"
leftSection={<PlusIcon size={12} />}
onClick={() => setPlayer1Id(user.id)}
>
<Trans>Me</Trans>
</Button>
)}
</Group> </Group>
)} )}
</Stack> </Paper>
</Paper> </Box>
<TextInput <TextInput
placeholder={t`Search players`} placeholder={t`Search players`}
@@ -230,6 +148,7 @@ const LeagueHeadToHead = () => {
p="md" p="md"
gap="sm" gap="sm"
wrap="nowrap" wrap="nowrap"
className="flxn-press"
style={{ style={{
cursor: "pointer", cursor: "pointer",
transition: "background-color 150ms ease", transition: "background-color 150ms ease",
@@ -250,7 +169,7 @@ const LeagueHeadToHead = () => {
</Text> </Text>
</Box> </Box>
<ActionIcon variant="subtle" color="gray" size="lg" radius="xl"> <ActionIcon variant="subtle" color="gray" size="lg" radius="xl">
<ArrowRightIcon size={18} /> {player1Id ? <ArrowRightIcon size={18} /> : <PlusIcon size={18} />}
</ActionIcon> </ActionIcon>
</Group> </Group>
{index < filteredPlayers.length - 1 && <Divider />} {index < filteredPlayers.length - 1 && <Divider />}
+1 -1
View File
@@ -84,7 +84,7 @@ export const updatePlayer = createServerFn()
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.validator(playerInputSchema) .validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
+1 -1
View File
@@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn()
tournamentId: z.string(), tournamentId: z.string(),
seed: z.number().optional() seed: z.number().optional()
})) }))
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId); const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
+18 -3
View File
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
: DEFAULT_LOCALE; : DEFAULT_LOCALE;
} }
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
export async function ensureMessages(locale: AppLocale): Promise<Messages> { export async function ensureMessages(locale: AppLocale): Promise<Messages> {
const cached = messageCache[locale]; const cached = messageCache[locale];
if (cached) return cached; if (cached) return cached;
@@ -39,11 +41,24 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`]; const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
if (!loader) return messageCache[DEFAULT_LOCALE]!; if (!loader) return messageCache[DEFAULT_LOCALE]!;
const { messages } = await loader(); const pending = (inflightLoads[locale] ??= loader()
messageCache[locale] = messages; .then(({ messages }) => {
return messages; messageCache[locale] = messages;
return messages;
})
.finally(() => {
delete inflightLoads[locale];
}));
return pending;
} }
export function primeMessages(locale: AppLocale, messages: Messages) {
messageCache[locale] ??= messages;
}
export const i18nMessagesQueryKey = (locale: AppLocale) =>
["i18n-messages", locale] as const;
export function getCachedMessages(locale: AppLocale): Messages { export function getCachedMessages(locale: AppLocale): Messages {
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!; return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
} }
+28 -10
View File
@@ -1,11 +1,14 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@lingui/react"; import { I18nProvider } from "@lingui/react";
import type { Messages } from "@lingui/core";
import { playerQueries } from "@/features/players/queries"; import { playerQueries } from "@/features/players/queries";
import { import {
createI18n, createI18n,
ensureMessages, ensureMessages,
hasCachedMessages, hasCachedMessages,
i18nMessagesQueryKey,
primeMessages,
resolveLocale, resolveLocale,
} from "@/lib/i18n"; } from "@/lib/i18n";
@@ -29,27 +32,42 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}); });
const queryClient = useQueryClient();
const locale = resolveLocale(data?.metadata?.locale); const locale = resolveLocale(data?.metadata?.locale);
const [catalogVersion, setCatalogVersion] = useState(0); const [catalogVersion, setCatalogVersion] = useState(0);
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
// preloads the catalog on authed routes; on public routes (login) it may
// not be cached yet — createI18n falls back to en until the effect below
// loads it and bumps catalogVersion.
const i18n = useMemo( const i18n = useMemo(
() => createI18n(locale), () => {
if (!hasCachedMessages(locale)) {
const seeded = queryClient.getQueryData<Messages>(
i18nMessagesQueryKey(locale)
);
if (seeded) primeMessages(locale, seeded);
}
return createI18n(locale);
},
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[locale, catalogVersion] [locale, catalogVersion, queryClient]
); );
useEffect(() => { useEffect(() => {
if (hasCachedMessages(locale)) return; if (hasCachedMessages(locale)) return;
let cancelled = false; let cancelled = false;
ensureMessages(locale).then(() => { let retryTimer: ReturnType<typeof setTimeout> | undefined;
if (!cancelled) setCatalogVersion((v) => v + 1); const load = () => {
}); ensureMessages(locale).then(
() => {
if (!cancelled) setCatalogVersion((v) => v + 1);
},
() => {
if (!cancelled) retryTimer = setTimeout(load, 3000);
}
);
};
load();
return () => { return () => {
cancelled = true; cancelled = true;
if (retryTimer) clearTimeout(retryTimer);
}; };
}, [locale]); }, [locale]);
+34 -10
View File
@@ -19,15 +19,30 @@ class PocketBaseAdminClient {
this.pb = new PocketBase(process.env.POCKETBASE_URL); this.pb = new PocketBase(process.env.POCKETBASE_URL);
this.pb.beforeSend = async (url, options) => { this.pb.beforeSend = async (url, options) => {
await this.authPromise; // Auth requests skip the gate: awaiting authPromise here would deadlock them.
if (!url.includes("/collections/_superusers/auth-")) {
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) {
try { try {
await this.refreshAuth(); await this.authPromise;
} catch (error) { } catch {}
console.error('Failed to refresh admin token, re-authenticating:', error);
await this.authenticate(); if (!this.pb.authStore.isValid) {
this.authPromise = this.authenticate();
await this.authPromise;
this.startTokenRefresh();
} else if (this.isTokenExpiringSoon()) {
try {
await this.refreshAuth();
} catch (error) {
console.error('Failed to refresh admin token, re-authenticating:', error);
this.authPromise = this.authenticate();
await this.authPromise;
}
} }
options.headers = {
...options.headers,
Authorization: this.pb.authStore.token,
};
} }
options.cache = "no-store"; options.cache = "no-store";
@@ -54,9 +69,16 @@ class PocketBaseAdminClient {
Object.assign(this, createPushService(this.pb)); Object.assign(this, createPushService(this.pb));
this.authPromise = this.authenticate(); this.authPromise = this.authenticate();
this.authPromise.then(() => { this.authPromise
this.startTokenRefresh(); .then(() => {
}); this.startTokenRefresh();
})
.catch(() => {
console.error(
"PocketBase admin authentication failed - is PocketBase running and are " +
"POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?"
);
});
} }
private async authenticate() { private async authenticate() {
@@ -98,6 +120,8 @@ class PocketBaseAdminClient {
} }
private startTokenRefresh() { private startTokenRefresh() {
if (this.refreshInterval) return;
this.refreshInterval = setInterval(async () => { this.refreshInterval = setInterval(async () => {
try { try {
await this.refreshAuth(); await this.refreshAuth();
+7 -5
View File
@@ -1,5 +1,7 @@
import PocketBase from "pocketbase"; import PocketBase from "pocketbase";
import { PlayerInfo } from "@/features/players/types"; import { PlayerInfo } from "@/features/players/types";
import { pbFilter } from "../util/filter";
import { likePattern } from "../util/like-pattern";
export interface Activity { export interface Activity {
id: string; id: string;
@@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) {
const filters: string[] = []; const filters: string[] = [];
if (name) { if (name) {
filters.push(`name ~ "${name}"`); filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
} }
if (player) { if (player) {
filters.push(`player = "${player}"`); filters.push(pbFilter(pb, "player = {:player}", { player }));
} }
if (success !== undefined) { if (success !== undefined) {
filters.push(`success = ${success}`); filters.push(pbFilter(pb, "success = {:success}", { success }));
} }
const filterString = filters.length > 0 ? filters.join(" && ") : ""; const filterString = filters.length > 0 ? filters.join(" && ") : "";
@@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> { async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
const result = await pb.collection("activities").getList<Activity>(1, limit, { const result = await pb.collection("activities").getList<Activity>(1, limit, {
filter: `player = "${userId}"`, filter: pbFilter(pb, "player = {:userId}", { userId }),
sort: "-created", sort: "-created",
}); });
return result.items; return result.items;
@@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) {
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> { async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
const result = await pb.collection("activities").getList<Activity>(1, limit, { const result = await pb.collection("activities").getList<Activity>(1, limit, {
filter: `name = "${functionName}"`, filter: pbFilter(pb, "name = {:functionName}", { functionName }),
sort: "-created", sort: "-created",
}); });
return result.items; return result.items;
+84 -39
View File
@@ -70,16 +70,6 @@ export function createBadgesService(pb: PocketBase) {
return true; return true;
}, },
async clearAllBadgeProgress(): Promise<number> {
const existingProgress = await pb.collection("badge_progress").getFullList({
filter: 'badge.type != "manual"',
});
for (const progress of existingProgress) {
await pb.collection("badge_progress").delete(progress.id);
}
return existingProgress.length;
},
async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> { async calculateBadgeProgress(playerId: string, badge: Badge): Promise<number> {
if (badge.type === "manual") { if (badge.type === "manual") {
return 0; return 0;
@@ -651,27 +641,36 @@ export function createBadgesService(pb: PocketBase) {
return transformBadgeProgress(created); return transformBadgeProgress(created);
}, },
async migrateBadgeProgress(): Promise<{ async migrateBadgeProgress(
success: boolean; onProgress?: (playersProcessed: number, totalPlayers: number) => void
): Promise<{
playersProcessed: number; playersProcessed: number;
progressRecordsCreated: number;
totalBadgesEarned: number; totalBadgesEarned: number;
created: number;
updated: number;
deleted: number;
averageBadgesPerPlayer: string; averageBadgesPerPlayer: string;
}> { }> {
await this.clearAllBadgeProgress();
const badges = await this.listBadges(); const badges = await this.listBadges();
const autoBadges = badges.filter((badge) => badge.type !== "manual");
const players = await pb.collection("players").getFullList();
const allPlayers = await pb.collection("players").getFullList(); // Phase 1: read-only calculation. Existing badge_progress rows stay
const uniquePlayers = new Set(allPlayers.map((p: any) => p.id)); // untouched until the full desired state is known.
const desired = new Map<string, {
badge: string;
player: string;
progress: number;
earned: boolean;
}>();
const failedKeys = new Set<string>();
let playersProcessed = 0;
let totalProgressRecords = 0; for (const player of players) {
let totalBadgesEarned = 0; for (const badge of autoBadges) {
const key = `${player.id}|${badge.id}`;
for (const playerId of uniquePlayers) {
for (const badge of badges) {
try { try {
const progress = await this.calculateBadgeProgress(playerId, badge); const progress = await this.calculateBadgeProgress(player.id, badge);
const target = this.getTargetProgress(badge); const target = this.getTargetProgress(badge);
const isPlacementBadge = badge.criteria.placement !== undefined; const isPlacementBadge = badge.criteria.placement !== undefined;
@@ -681,31 +680,77 @@ export function createBadgesService(pb: PocketBase) {
: progress === target; : progress === target;
if (progress > 0 || earned) { if (progress > 0 || earned) {
await this.createBadgeProgress({ desired.set(key, {
badge: badge.id, badge: badge.id,
player: playerId, player: player.id,
progress: progress, progress,
earned: earned, earned,
}); });
totalProgressRecords++;
if (earned) {
totalBadgesEarned++;
}
} }
} catch (error: any) { } catch (error: any) {
console.error(`Error processing badge "${badge.name}" for player ${playerId}:`, error.message); // Keep whatever row already exists when a calculation fails.
failedKeys.add(key);
console.error(`Error processing badge "${badge.name}" for player ${player.id}:`, error.message);
} }
} }
playersProcessed++;
onProgress?.(playersProcessed, players.length);
}
// Phase 2: apply as a diff — update changed rows, create missing ones,
// delete stale/duplicate ones. No window where all badges are gone.
const existing = await pb.collection("badge_progress").getFullList({
filter: 'badge.type != "manual"',
});
const existingByKey = new Map<string, { id: string; progress: number; earned: boolean }>();
const toDelete: string[] = [];
for (const row of existing) {
const key = `${row.player}|${row.badge}`;
if (existingByKey.has(key)) {
toDelete.push(row.id);
continue;
}
existingByKey.set(key, { id: row.id, progress: row.progress, earned: row.earned });
if (!desired.has(key) && !failedKeys.has(key)) {
toDelete.push(row.id);
}
}
let created = 0;
let updated = 0;
let totalBadgesEarned = 0;
for (const [key, want] of desired) {
if (want.earned) {
totalBadgesEarned++;
}
const row = existingByKey.get(key);
if (!row) {
await this.createBadgeProgress(want);
created++;
} else if (row.progress !== want.progress || row.earned !== want.earned) {
await this.updateBadgeProgress(row.id, {
progress: want.progress,
earned: want.earned,
});
updated++;
}
}
for (const id of toDelete) {
await this.deleteBadgeProgress(id);
} }
return { return {
success: true, playersProcessed: players.length,
playersProcessed: uniquePlayers.size, totalBadgesEarned,
progressRecordsCreated: totalProgressRecords, created,
totalBadgesEarned: totalBadgesEarned, updated,
averageBadgesPerPlayer: (totalBadgesEarned / uniquePlayers.size).toFixed(2), deleted: toDelete.length,
averageBadgesPerPlayer: players.length
? (totalBadgesEarned / players.length).toFixed(2)
: "0",
}; };
}, },
}; };
+118
View File
@@ -0,0 +1,118 @@
import PocketBase from "pocketbase";
import { describe, expect, it } from "vitest";
import { pbFilter } from "./filter";
// Real SDK, not a stub: the escaping under test lives in PocketBase's own
// replaceAll.
const pb = new PocketBase("http://pocketbase.test");
const quotedLiteral = (expression: string) => {
const open = expression.indexOf("'");
let literal = "";
for (let i = open + 1; i < expression.length; i++) {
if (expression[i] === "\\") {
literal += expression[i + 1];
i++;
continue;
}
if (expression[i] === "'") return literal;
literal += expression[i];
}
throw new Error(`unterminated literal in ${expression}`);
};
describe("pbFilter", () => {
it.each([
["a match-substitution pattern", "a$&b"],
["a preceding-input pattern", "a$`b"],
["a following-input pattern", "a$'b"],
["an escaped-dollar pattern", "a$$b"],
["a bare dollar", "a$b"],
["nothing but a dollar", "$"],
["a dollar beside a metacharacter", "50$%"],
])("substitutes %s as a literal", (_label, term) => {
const expression = pbFilter(pb, "first_name ~ {:query}", { query: term });
expect(quotedLiteral(expression)).toBe(term);
expect(expression).not.toContain("{:query}");
});
it("keeps both dollars of an escaped-dollar pattern", () => {
expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'");
});
it("splices no part of the surrounding expression into the literal", () => {
const expression = pbFilter(pb, "first_name ~ {:query}", {
query: "a$`b",
});
expect(expression).toBe("first_name ~ 'a$`b'");
expect(quotedLiteral(expression)).not.toContain("first_name");
});
it.each([
["a single quote", "o'brien"],
["a trailing backslash", "ada\\"],
["a percent sign", "50%"],
])("escapes %s exactly as the SDK does", (_label, term) => {
expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe(
pb.filter("f ~ {:q}", { q: term })
);
});
it.each([
["a number", 42],
["a boolean", true],
["null", null],
["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))],
])("renders %s identically to pb.filter", (_label, value) => {
expect(pbFilter(pb, "f = {:v}", { v: value })).toBe(
pb.filter("f = {:v}", { v: value })
);
});
it("leaves a placeholder with no matching parameter verbatim", () => {
expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}");
expect(pbFilter(pb, "a = {:missing}", {})).toBe(
pb.filter("a = {:missing}", {})
);
});
it.each([
["a hyphen", "user-id"],
["a dot", "meta.user"],
["a digit and underscore", "auth_id2"],
])("resolves a key containing %s the way the SDK does", (_label, key) => {
const raw = `a = {:${key}}`;
expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'");
expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" }));
});
it("does not absorb another parameter's value into a literal", () => {
expect(
pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })
).toBe("a = '%{:y}%' && b = 'SECRET'");
});
it("substitutes every occurrence of a repeated placeholder", () => {
expect(
pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" })
).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')");
});
});
// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade
// fixes the expansion upstream, these fail rather than the doubling silently
// becoming a double-escape.
describe("the pocketbase SDK substitution this works around", () => {
it("still mis-expands a dollar pattern in a parameter value", () => {
expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}");
});
it("still splices a later parameter into an earlier literal", () => {
expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe(
"a = '%'SECRET'%' && b = 'SECRET'"
);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type PocketBase from "pocketbase";
export type FilterParam = string | number | boolean | Date | null;
const PLACEHOLDER = /\{:([^}]+)\}/g;
// pb.filter $-expands string replacements; doubling `$` keeps the value literal.
const quote = (pb: PocketBase, value: FilterParam) =>
pb.filter("{:v}", {
v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value,
});
export const pbFilter = (
pb: PocketBase,
raw: string,
params: Record<string, FilterParam>
) =>
raw.replace(PLACEHOLDER, (token, key: string) =>
Object.hasOwn(params, key) ? quote(pb, params[key]) : token
);
@@ -0,0 +1,69 @@
import PocketBase from "pocketbase";
import { describe, expect, it } from "vitest";
import { literalTerminates } from "@/test/pb-filter";
import { pbFilter } from "./filter";
import { likePattern } from "./like-pattern";
const compose = (term: string) =>
pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", {
query: likePattern(term),
});
describe("likePattern", () => {
it("wraps a plain term so ~ matches a substring", () => {
expect(likePattern("ada")).toBe("%ada%");
});
it.each([
["a percent sign", "50%", "%50\\%%"],
["an underscore", "_", "%\\_%"],
["a backslash", "ada\\", "%ada\\\\%"],
])("escapes %s so it matches literally", (_label, term, expected) => {
expect(likePattern(term)).toBe(expected);
});
it("always ends the operand with an unescaped wildcard", () => {
// An odd run of backslashes before the final % would mean the % is itself
// escaped, which is the shape that swallows the closing quote.
for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) {
const pattern = likePattern(term);
const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? "";
expect(pattern.endsWith("%")).toBe(true);
expect(trailingSlashes.length % 2).toBe(0);
}
});
});
describe("likePattern composed through the real SDK", () => {
it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])(
"keeps the filter expression parseable for %j",
(term) => {
expect(literalTerminates(compose(term))).toBe(true);
}
);
it("detects the unterminated literal a raw term produces", () => {
const raw = new PocketBase("http://pocketbase.test").filter(
"first_name ~ {:query}",
{ query: "ada\\" }
);
expect(raw).toBe("first_name ~ 'ada\\'");
expect(literalTerminates(raw)).toBe(false);
});
it("leaves a term without metacharacters exactly as before", () => {
expect(compose("ada")).toBe("first_name ~ '%ada%'");
});
it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])(
"carries %j through both escaping layers intact",
(term) => {
const composed = compose(term);
expect(composed).toContain(likePattern(term));
expect(literalTerminates(composed)).toBe(true);
}
);
});
+3
View File
@@ -0,0 +1,3 @@
// Escapes LIKE metacharacters and wraps in `%` for a literal substring match.
export const likePattern = (term: string) =>
`%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { redactValue, SENSITIVE_KEY } from "./redact";
// Shared scrubber for logs + audit rows - a regression leaks PII from both.
describe("redactValue", () => {
it("redacts sensitive keys and preserves benign ones", () => {
expect(
redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" })
).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" });
});
it("redacts recursively through nested objects and arrays", () => {
expect(
redactValue({
user: { first_name: "Ada", password: "hunter2" },
items: [{ authToken: "x" }, { label: "ok" }],
})
).toEqual({
user: { first_name: "Ada", password: "[redacted]" },
items: [{ authToken: "[redacted]" }, { label: "ok" }],
});
});
it("passes primitives through untouched (a bare string is not assumed secret)", () => {
expect(redactValue("hello")).toBe("hello");
expect(redactValue(42)).toBe(42);
expect(redactValue(null)).toBeNull();
});
it("covers the documented sensitive keys", () => {
for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) {
expect(SENSITIVE_KEY.test(key)).toBe(true);
}
expect(SENSITIVE_KEY.test("first_name")).toBe(false);
});
});
+18
View File
@@ -0,0 +1,18 @@
export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i;
const REDACTED = "[redacted]";
export const redactValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(redactValue);
if (value && typeof value === "object") {
if (value instanceof Error) {
return { name: value.name, message: value.message, stack: value.stack };
}
const out: Record<string, unknown> = {};
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v);
}
return out;
}
return value;
};
@@ -1,6 +1,5 @@
import { logger } from "../../logger"; import { logger } from "../../logger";
import { ErrorType, ServerError, ServerResult } from "../types"; import { ErrorType, ServerError, ServerResult } from "../types";
import { getRequest } from "@tanstack/react-start/server";
import { isRedirect } from "@tanstack/react-router"; import { isRedirect } from "@tanstack/react-router";
export const createServerError = ( export const createServerError = (
@@ -20,54 +19,15 @@ export const createServerError = (
export const toServerResult = async <T>( export const toServerResult = async <T>(
serverFn: () => Promise<T> serverFn: () => Promise<T>
): Promise<ServerResult<T>> => { ): Promise<ServerResult<T>> => {
const startTime = Date.now();
try { try {
const data = await serverFn(); const data = await serverFn();
return { success: true, data }; return { success: true, data };
} catch (error) { } catch (error) {
if (isRedirect(error) || error instanceof Response) throw error; if (isRedirect(error) || error instanceof Response) throw error;
const duration = Date.now() - startTime;
logger.error('Server Fn Error', error); logger.error('Server Fn Error', error);
const mappedError = mapKnownError(error); return { success: false, error: mapKnownError(error) };
let fnName = 'unknown';
try {
const request = getRequest();
const url = new URL(request.url);
const functionId = url.searchParams.get('_serverFnId') || url.pathname;
if (functionId.includes('--')) {
const match = functionId.match(/--([^_]+)_/);
fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown';
} else {
fnName = serverFn.name || 'unknown';
}
} catch {
fnName = serverFn.name || 'unknown';
}
import("../../pocketbase/client")
.then(async ({ pbAdmin }) => {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: fnName,
duration,
success: false,
error: mappedError.message,
arguments: {
errorType: mappedError.code,
statusCode: mappedError.statusCode,
userMessage: mappedError.userMessage,
},
});
})
.catch(() => {});
return { success: false, error: mappedError };
} }
}; };
+62 -33
View File
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Aktives Gerät: {0}" msgstr "Aktives Gerät: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21 #: src/app/routes/_authed/admin/activities.tsx:21
#: src/features/admin/components/admin-page.tsx:47 #: src/features/admin/components/admin-page.tsx:90
msgid "Activities" msgid "Activities"
msgstr "Aktivitäten" msgstr "Aktivitäten"
@@ -392,6 +392,10 @@ msgstr "Aktivitäten"
msgid "Activity Details" msgid "Activity Details"
msgstr "Aktivitätsdetails" msgstr "Aktivitätsdetails"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Spieler hinzufügen" msgstr "Spieler hinzufügen"
@@ -520,7 +524,7 @@ msgstr "Ø Sieg-Marge"
msgid "Award Badge" msgid "Award Badge"
msgstr "Abzeichen verleihen" msgstr "Abzeichen verleihen"
#: src/features/admin/components/admin-page.tsx:36 #: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges" msgid "Award Badges"
msgstr "Abzeichen verleihen" msgstr "Abzeichen verleihen"
@@ -554,6 +558,18 @@ msgstr "Zurück"
msgid "Badge awarded to {playerName}" msgid "Badge awarded to {playerName}"
msgstr "Abzeichen an {playerName} verliehen" msgstr "Abzeichen an {playerName} verliehen"
#: src/features/admin/components/admin-page.tsx:86
msgid "Badge recalculation failed. Check the server logs."
msgstr "Abzeichen-Neuberechnung fehlgeschlagen. Prüfe die Server-Logs."
#: src/features/admin/components/admin-page.tsx:39
msgid "Badge recalculation is already running"
msgstr "Abzeichen-Neuberechnung läuft bereits"
#: src/features/admin/components/admin-page.tsx:38
msgid "Badge recalculation started in the background"
msgstr "Abzeichen-Neuberechnung im Hintergrund gestartet"
#: src/features/admin/components/award-badges.tsx:70 #: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type" msgid "Badge Type"
msgstr "Abzeichentyp" msgstr "Abzeichentyp"
@@ -581,7 +597,7 @@ msgid "Bracket"
msgstr "Turnierbaum" msgstr "Turnierbaum"
#: src/app/routes/_authed/admin/preview.tsx:13 #: src/app/routes/_authed/admin/preview.tsx:13
#: src/features/admin/components/admin-page.tsx:59 #: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview" msgid "Bracket Preview"
msgstr "Turnierbaum-Vorschau" msgstr "Turnierbaum-Vorschau"
@@ -651,9 +667,9 @@ msgstr "Wähle einen Spieler"
msgid "Claymation" msgid "Claymation"
msgstr "Knete" msgstr "Knete"
#: src/features/players/components/league-head-to-head.tsx:203 #: src/features/players/components/league-head-to-head.tsx:99
msgid "Clear both players" msgid "Clear selected player"
msgstr "Beide Spieler entfernen" msgstr "Ausgewählten Spieler entfernen"
#: src/components/sheet/modal.tsx:35 #: src/components/sheet/modal.tsx:35
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169 #: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
@@ -841,6 +857,10 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Spotify trennen" msgstr "Spotify trennen"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Schließen"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "Sieht das richtig aus?" msgstr "Sieht das richtig aus?"
@@ -1060,6 +1080,10 @@ msgstr "Aktives Gerät konnte nicht festgelegt werden"
msgid "Failed to set volume" msgid "Failed to set volume"
msgstr "Lautstärke konnte nicht festgelegt werden" msgstr "Lautstärke konnte nicht festgelegt werden"
#: src/features/admin/components/admin-page.tsx:42
msgid "Failed to start badge recalculation"
msgstr "Abzeichen-Neuberechnung konnte nicht gestartet werden"
#: src/features/teams/components/team-form/index.tsx:105 #: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team" msgid "Failed to update team"
msgstr "Team konnte nicht aktualisiert werden" msgstr "Team konnte nicht aktualisiert werden"
@@ -1200,7 +1224,7 @@ msgstr "Hast du schon mal mitgespielt?"
#: src/features/bracket/components/match-dock.tsx:215 #: src/features/bracket/components/match-dock.tsx:215
#: src/features/matches/components/match-card.tsx:84 #: src/features/matches/components/match-card.tsx:84
#: src/features/matches/components/match-card.tsx:228 #: src/features/matches/components/match-card.tsx:228
#: src/features/players/components/league-head-to-head.tsx:264 #: src/features/players/components/league-head-to-head.tsx:183
#: src/features/players/components/profile/header.tsx:90 #: src/features/players/components/profile/header.tsx:90
msgid "Head to Head" msgid "Head to Head"
msgstr "Direktvergleich" msgstr "Direktvergleich"
@@ -1322,6 +1346,11 @@ msgstr "Der Nachname muss 2-20 Zeichen lang sein und darf nur Buchstaben enthalt
msgid "Last name must be 3-20 characters long and contain only letters" msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "Der Nachname muss 3-20 Zeichen lang sein und darf nur Buchstaben enthalten" msgstr "Der Nachname muss 3-20 Zeichen lang sein und darf nur Buchstaben enthalten"
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
#: src/features/admin/components/admin-page.tsx:74
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
msgstr "Letzte Neuberechnung: {0, plural, one {# Abzeichen verdient} other {# Abzeichen verdient}}"
#: src/features/settings/components/color-scheme-picker.tsx:43 #: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light" msgid "Light"
msgstr "Hell" msgstr "Hell"
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Team-Songs/Logos verwalten" msgstr "Team-Songs/Logos verwalten"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17 #: src/app/routes/_authed/admin/tournaments/index.tsx:17
#: src/features/admin/components/admin-page.tsx:31 #: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments" msgid "Manage Tournaments"
msgstr "Turniere verwalten" msgstr "Turniere verwalten"
@@ -1485,7 +1514,11 @@ msgstr "Matchup"
msgid "Matchup details" msgid "Matchup details"
msgstr "Matchup-Details" msgstr "Matchup-Details"
#: src/features/admin/components/admin-page.tsx:41 #: src/features/players/components/league-head-to-head.tsx:120
msgid "Me"
msgstr "Ich"
#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress" msgid "Migrate Badge Progress"
msgstr "Abzeichen-Fortschritt migrieren" msgstr "Abzeichen-Fortschritt migrieren"
@@ -1600,7 +1633,7 @@ msgstr "Keine Matches geplant"
msgid "No player activity found" msgid "No player activity found"
msgstr "Keine Spieleraktivität gefunden" msgstr "Keine Spieleraktivität gefunden"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
msgid "No players available" msgid "No players available"
msgstr "Keine Spieler verfügbar" msgstr "Keine Spieler verfügbar"
@@ -1609,7 +1642,7 @@ msgstr "Keine Spieler verfügbar"
msgid "No players enrolled yet" msgid "No players enrolled yet"
msgstr "Noch keine Spieler angemeldet" msgstr "Noch keine Spieler angemeldet"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
#: src/features/players/components/player-stats-table.tsx:476 #: src/features/players/components/player-stats-table.tsx:476
msgid "No players found matching \"{search}\"" msgid "No players found matching \"{search}\""
msgstr "Keine Spieler gefunden, die zu \"{search}\" passen" msgstr "Keine Spieler gefunden, die zu \"{search}\" passen"
@@ -1725,7 +1758,7 @@ msgstr "Sobald du angemeldet bist, kannst du andere Free Agents und ihre Telefon
msgid "One time code" msgid "One time code"
msgstr "Einmalcode" msgstr "Einmalcode"
#: src/features/admin/components/admin-page.tsx:52 #: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase" msgid "Open Pocketbase"
msgstr "Pocketbase öffnen" msgstr "Pocketbase öffnen"
@@ -1819,12 +1852,10 @@ msgstr "Wiedergabesteuerung fehlgeschlagen"
msgid "Player" msgid "Player"
msgstr "Spieler" msgstr "Spieler"
#: src/features/players/components/league-head-to-head.tsx:117
#: src/features/teams/components/team-form/players-picker.tsx:45 #: src/features/teams/components/team-form/players-picker.tsx:45
msgid "Player 1" msgid "Player 1"
msgstr "Spieler 1" msgstr "Spieler 1"
#: src/features/players/components/league-head-to-head.tsx:173
#: src/features/teams/components/team-form/players-picker.tsx:53 #: src/features/teams/components/team-form/players-picker.tsx:53
msgid "Player 2" msgid "Player 2"
msgstr "Spieler 2" msgstr "Spieler 2"
@@ -1862,10 +1893,6 @@ msgstr "Spieler sind erforderlich"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Wird auf {0} abgespielt" msgstr "Wird auf {0} abgespielt"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Installier FLXN • Das spart mir Twilio-Credits, weil du nicht ausgeloggt wirst!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "K.-o.-Runde befüllen" msgstr "K.-o.-Runde befüllen"
@@ -1925,6 +1952,12 @@ msgstr "Neu würfeln"
msgid "Reactions" msgid "Reactions"
msgstr "Reaktionen" msgstr "Reaktionen"
#. placeholder {0}: migrationStatus.playersProcessed
#. placeholder {1}: migrationStatus.totalPlayers
#: src/features/admin/components/admin-page.tsx:67
msgid "Recalculating badges in the background ({0}/{1})"
msgstr "Abzeichen werden im Hintergrund neu berechnet ({0}/{1})"
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34 #: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished" msgid "Recently finished"
msgstr "Kürzlich beendet" msgstr "Kürzlich beendet"
@@ -2032,7 +2065,7 @@ msgstr "Nach Songs suchen..."
msgid "Search for teams to enroll..." msgid "Search for teams to enroll..."
msgstr "Teams zum Anmelden suchen..." msgstr "Teams zum Anmelden suchen..."
#: src/features/players/components/league-head-to-head.tsx:211 #: src/features/players/components/league-head-to-head.tsx:129
#: src/features/players/components/player-stats-table.tsx:329 #: src/features/players/components/player-stats-table.tsx:329
msgid "Search players" msgid "Search players"
msgstr "Spieler suchen" msgstr "Spieler suchen"
@@ -2070,6 +2103,10 @@ msgstr "Abzeichen auswählen"
msgid "Select a device..." msgid "Select a device..."
msgstr "Gerät auswählen..." msgstr "Gerät auswählen..."
#: src/features/players/components/league-head-to-head.tsx:109
msgid "Select a player"
msgstr "Wähle einen Spieler"
#: src/features/teams/components/team-form/song-picker.tsx:131 #: src/features/teams/components/team-form/song-picker.tsx:131
msgid "Select a song to see details" msgid "Select a song to see details"
msgstr "Song auswählen, um Details zu sehen" msgstr "Song auswählen, um Details zu sehen"
@@ -2100,10 +2137,6 @@ msgstr "Enddatum auswählen"
msgid "Select Enrollment Due Date" msgid "Select Enrollment Due Date"
msgstr "Anmeldeschluss auswählen" msgstr "Anmeldeschluss auswählen"
#: src/features/players/components/league-head-to-head.tsx:188
msgid "Select first player"
msgstr "Ersten Spieler auswählen"
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64 #: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
msgid "Select one of your existing teams" msgid "Select one of your existing teams"
msgstr "Wähle eines deiner bestehenden Teams" msgstr "Wähle eines deiner bestehenden Teams"
@@ -2112,10 +2145,6 @@ msgstr "Wähle eines deiner bestehenden Teams"
msgid "Select Player" msgid "Select Player"
msgstr "Spieler auswählen" msgstr "Spieler auswählen"
#: src/features/players/components/league-head-to-head.tsx:189
msgid "Select second player"
msgstr "Zweiten Spieler auswählen"
#: src/features/teams/components/team-form/song-picker.tsx:68 #: src/features/teams/components/team-form/song-picker.tsx:68
msgid "Select Song" msgid "Select Song"
msgstr "Song auswählen" msgstr "Song auswählen"
@@ -2318,11 +2347,15 @@ msgstr "System"
msgid "T" msgid "T"
msgstr "T" msgstr "T"
#: src/components/ios-install-prompt.tsx:37 #: src/features/players/components/league-head-to-head.tsx:89
msgid "Tap an opponent below to compare"
msgstr "Tippe unten auf einen Gegner zum Vergleichen"
#: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen" msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen" msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen"
@@ -2624,10 +2657,6 @@ msgstr "volumePercent muss eine Zahl sein"
msgid "vs" msgid "vs"
msgstr "vs" msgstr "vs"
#: src/features/players/components/league-head-to-head.tsx:125
msgid "VS"
msgstr "VS"
#: src/features/players/components/player-stats-table.tsx:131 #: src/features/players/components/player-stats-table.tsx:131
#: src/features/tournaments/components/tournament-stats.tsx:112 #: src/features/tournaments/components/tournament-stats.tsx:112
msgid "W" msgid "W"
+62 -33
View File
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Active device: {0}" msgstr "Active device: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21 #: src/app/routes/_authed/admin/activities.tsx:21
#: src/features/admin/components/admin-page.tsx:47 #: src/features/admin/components/admin-page.tsx:90
msgid "Activities" msgid "Activities"
msgstr "Activities" msgstr "Activities"
@@ -392,6 +392,10 @@ msgstr "Activities"
msgid "Activity Details" msgid "Activity Details"
msgstr "Activity Details" msgstr "Activity Details"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Add FLXN to your home screen for a smoother experience"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Add Player" msgstr "Add Player"
@@ -520,7 +524,7 @@ msgstr "Avg Win Margin"
msgid "Award Badge" msgid "Award Badge"
msgstr "Award Badge" msgstr "Award Badge"
#: src/features/admin/components/admin-page.tsx:36 #: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges" msgid "Award Badges"
msgstr "Award Badges" msgstr "Award Badges"
@@ -554,6 +558,18 @@ msgstr "Back"
msgid "Badge awarded to {playerName}" msgid "Badge awarded to {playerName}"
msgstr "Badge awarded to {playerName}" msgstr "Badge awarded to {playerName}"
#: src/features/admin/components/admin-page.tsx:86
msgid "Badge recalculation failed. Check the server logs."
msgstr "Badge recalculation failed. Check the server logs."
#: src/features/admin/components/admin-page.tsx:39
msgid "Badge recalculation is already running"
msgstr "Badge recalculation is already running"
#: src/features/admin/components/admin-page.tsx:38
msgid "Badge recalculation started in the background"
msgstr "Badge recalculation started in the background"
#: src/features/admin/components/award-badges.tsx:70 #: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type" msgid "Badge Type"
msgstr "Badge Type" msgstr "Badge Type"
@@ -581,7 +597,7 @@ msgid "Bracket"
msgstr "Bracket" msgstr "Bracket"
#: src/app/routes/_authed/admin/preview.tsx:13 #: src/app/routes/_authed/admin/preview.tsx:13
#: src/features/admin/components/admin-page.tsx:59 #: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview" msgid "Bracket Preview"
msgstr "Bracket Preview" msgstr "Bracket Preview"
@@ -651,9 +667,9 @@ msgstr "Choose a player"
msgid "Claymation" msgid "Claymation"
msgstr "Claymation" msgstr "Claymation"
#: src/features/players/components/league-head-to-head.tsx:203 #: src/features/players/components/league-head-to-head.tsx:99
msgid "Clear both players" msgid "Clear selected player"
msgstr "Clear both players" msgstr "Clear selected player"
#: src/components/sheet/modal.tsx:35 #: src/components/sheet/modal.tsx:35
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169 #: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
@@ -841,6 +857,10 @@ msgstr "deviceId is required for transfer action"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Disconnect Spotify" msgstr "Disconnect Spotify"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Dismiss"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "Does this look right?" msgstr "Does this look right?"
@@ -1060,6 +1080,10 @@ msgstr "Failed to set active device"
msgid "Failed to set volume" msgid "Failed to set volume"
msgstr "Failed to set volume" msgstr "Failed to set volume"
#: src/features/admin/components/admin-page.tsx:42
msgid "Failed to start badge recalculation"
msgstr "Failed to start badge recalculation"
#: src/features/teams/components/team-form/index.tsx:105 #: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team" msgid "Failed to update team"
msgstr "Failed to update team" msgstr "Failed to update team"
@@ -1200,7 +1224,7 @@ msgstr "Have you played before?"
#: src/features/bracket/components/match-dock.tsx:215 #: src/features/bracket/components/match-dock.tsx:215
#: src/features/matches/components/match-card.tsx:84 #: src/features/matches/components/match-card.tsx:84
#: src/features/matches/components/match-card.tsx:228 #: src/features/matches/components/match-card.tsx:228
#: src/features/players/components/league-head-to-head.tsx:264 #: src/features/players/components/league-head-to-head.tsx:183
#: src/features/players/components/profile/header.tsx:90 #: src/features/players/components/profile/header.tsx:90
msgid "Head to Head" msgid "Head to Head"
msgstr "Head to Head" msgstr "Head to Head"
@@ -1322,6 +1346,11 @@ msgstr "Last name must be 2-20 characters long and contain only letters"
msgid "Last name must be 3-20 characters long and contain only letters" msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "Last name must be 3-20 characters long and contain only letters" msgstr "Last name must be 3-20 characters long and contain only letters"
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
#: src/features/admin/components/admin-page.tsx:74
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
msgstr "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
#: src/features/settings/components/color-scheme-picker.tsx:43 #: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light" msgid "Light"
msgstr "Light" msgstr "Light"
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Manage Team Songs/Logos" msgstr "Manage Team Songs/Logos"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17 #: src/app/routes/_authed/admin/tournaments/index.tsx:17
#: src/features/admin/components/admin-page.tsx:31 #: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments" msgid "Manage Tournaments"
msgstr "Manage Tournaments" msgstr "Manage Tournaments"
@@ -1485,7 +1514,11 @@ msgstr "Matchup"
msgid "Matchup details" msgid "Matchup details"
msgstr "Matchup details" msgstr "Matchup details"
#: src/features/admin/components/admin-page.tsx:41 #: src/features/players/components/league-head-to-head.tsx:120
msgid "Me"
msgstr "Me"
#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress" msgid "Migrate Badge Progress"
msgstr "Migrate Badge Progress" msgstr "Migrate Badge Progress"
@@ -1600,7 +1633,7 @@ msgstr "No matches scheduled"
msgid "No player activity found" msgid "No player activity found"
msgstr "No player activity found" msgstr "No player activity found"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
msgid "No players available" msgid "No players available"
msgstr "No players available" msgstr "No players available"
@@ -1609,7 +1642,7 @@ msgstr "No players available"
msgid "No players enrolled yet" msgid "No players enrolled yet"
msgstr "No players enrolled yet" msgstr "No players enrolled yet"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
#: src/features/players/components/player-stats-table.tsx:476 #: src/features/players/components/player-stats-table.tsx:476
msgid "No players found matching \"{search}\"" msgid "No players found matching \"{search}\""
msgstr "No players found matching \"{search}\"" msgstr "No players found matching \"{search}\""
@@ -1725,7 +1758,7 @@ msgstr "Once enrolled, you can view other free agents and their phone number in
msgid "One time code" msgid "One time code"
msgstr "One time code" msgstr "One time code"
#: src/features/admin/components/admin-page.tsx:52 #: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase" msgid "Open Pocketbase"
msgstr "Open Pocketbase" msgstr "Open Pocketbase"
@@ -1819,12 +1852,10 @@ msgstr "Playback control failed"
msgid "Player" msgid "Player"
msgstr "Player" msgstr "Player"
#: src/features/players/components/league-head-to-head.tsx:117
#: src/features/teams/components/team-form/players-picker.tsx:45 #: src/features/teams/components/team-form/players-picker.tsx:45
msgid "Player 1" msgid "Player 1"
msgstr "Player 1" msgstr "Player 1"
#: src/features/players/components/league-head-to-head.tsx:173
#: src/features/teams/components/team-form/players-picker.tsx:53 #: src/features/teams/components/team-form/players-picker.tsx:53
msgid "Player 2" msgid "Player 2"
msgstr "Player 2" msgstr "Player 2"
@@ -1862,10 +1893,6 @@ msgstr "Players are required"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Playing on {0}" msgstr "Playing on {0}"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "Populate Knockout Bracket" msgstr "Populate Knockout Bracket"
@@ -1925,6 +1952,12 @@ msgstr "Re-roll"
msgid "Reactions" msgid "Reactions"
msgstr "Reactions" msgstr "Reactions"
#. placeholder {0}: migrationStatus.playersProcessed
#. placeholder {1}: migrationStatus.totalPlayers
#: src/features/admin/components/admin-page.tsx:67
msgid "Recalculating badges in the background ({0}/{1})"
msgstr "Recalculating badges in the background ({0}/{1})"
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34 #: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished" msgid "Recently finished"
msgstr "Recently finished" msgstr "Recently finished"
@@ -2032,7 +2065,7 @@ msgstr "Search for songs..."
msgid "Search for teams to enroll..." msgid "Search for teams to enroll..."
msgstr "Search for teams to enroll..." msgstr "Search for teams to enroll..."
#: src/features/players/components/league-head-to-head.tsx:211 #: src/features/players/components/league-head-to-head.tsx:129
#: src/features/players/components/player-stats-table.tsx:329 #: src/features/players/components/player-stats-table.tsx:329
msgid "Search players" msgid "Search players"
msgstr "Search players" msgstr "Search players"
@@ -2070,6 +2103,10 @@ msgstr "Select a badge"
msgid "Select a device..." msgid "Select a device..."
msgstr "Select a device..." msgstr "Select a device..."
#: src/features/players/components/league-head-to-head.tsx:109
msgid "Select a player"
msgstr "Select a player"
#: src/features/teams/components/team-form/song-picker.tsx:131 #: src/features/teams/components/team-form/song-picker.tsx:131
msgid "Select a song to see details" msgid "Select a song to see details"
msgstr "Select a song to see details" msgstr "Select a song to see details"
@@ -2100,10 +2137,6 @@ msgstr "Select End Date"
msgid "Select Enrollment Due Date" msgid "Select Enrollment Due Date"
msgstr "Select Enrollment Due Date" msgstr "Select Enrollment Due Date"
#: src/features/players/components/league-head-to-head.tsx:188
msgid "Select first player"
msgstr "Select first player"
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64 #: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
msgid "Select one of your existing teams" msgid "Select one of your existing teams"
msgstr "Select one of your existing teams" msgstr "Select one of your existing teams"
@@ -2112,10 +2145,6 @@ msgstr "Select one of your existing teams"
msgid "Select Player" msgid "Select Player"
msgstr "Select Player" msgstr "Select Player"
#: src/features/players/components/league-head-to-head.tsx:189
msgid "Select second player"
msgstr "Select second player"
#: src/features/teams/components/team-form/song-picker.tsx:68 #: src/features/teams/components/team-form/song-picker.tsx:68
msgid "Select Song" msgid "Select Song"
msgstr "Select Song" msgstr "Select Song"
@@ -2318,11 +2347,15 @@ msgstr "System"
msgid "T" msgid "T"
msgstr "T" msgstr "T"
#: src/components/ios-install-prompt.tsx:37 #: src/features/players/components/league-head-to-head.tsx:89
msgid "Tap an opponent below to compare"
msgstr "Tap an opponent below to compare"
#: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Tap Menu (⋮) → Add to Home screen" msgstr "Tap Menu (⋮) → Add to Home screen"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Tap Share → Add to Home Screen" msgstr "Tap Share → Add to Home Screen"
@@ -2624,10 +2657,6 @@ msgstr "volumePercent must be a number"
msgid "vs" msgid "vs"
msgstr "vs" msgstr "vs"
#: src/features/players/components/league-head-to-head.tsx:125
msgid "VS"
msgstr "VS"
#: src/features/players/components/player-stats-table.tsx:131 #: src/features/players/components/player-stats-table.tsx:131
#: src/features/tournaments/components/tournament-stats.tsx:112 #: src/features/tournaments/components/tournament-stats.tsx:112
msgid "W" msgid "W"
+62 -33
View File
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "Dispositivo activo: {0}" msgstr "Dispositivo activo: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21 #: src/app/routes/_authed/admin/activities.tsx:21
#: src/features/admin/components/admin-page.tsx:47 #: src/features/admin/components/admin-page.tsx:90
msgid "Activities" msgid "Activities"
msgstr "Actividades" msgstr "Actividades"
@@ -392,6 +392,10 @@ msgstr "Actividades"
msgid "Activity Details" msgid "Activity Details"
msgstr "Detalles de la actividad" msgstr "Detalles de la actividad"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "Agregar Jugador" msgstr "Agregar Jugador"
@@ -520,7 +524,7 @@ msgstr "Prom. Margen de Victoria"
msgid "Award Badge" msgid "Award Badge"
msgstr "Otorgar insignia" msgstr "Otorgar insignia"
#: src/features/admin/components/admin-page.tsx:36 #: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges" msgid "Award Badges"
msgstr "Otorgar insignias" msgstr "Otorgar insignias"
@@ -554,6 +558,18 @@ msgstr "Atrás"
msgid "Badge awarded to {playerName}" msgid "Badge awarded to {playerName}"
msgstr "Insignia otorgada a {playerName}" msgstr "Insignia otorgada a {playerName}"
#: src/features/admin/components/admin-page.tsx:86
msgid "Badge recalculation failed. Check the server logs."
msgstr "Falló el recálculo de insignias. Revisa los registros del servidor."
#: src/features/admin/components/admin-page.tsx:39
msgid "Badge recalculation is already running"
msgstr "El recálculo de insignias ya está en curso"
#: src/features/admin/components/admin-page.tsx:38
msgid "Badge recalculation started in the background"
msgstr "Recálculo de insignias iniciado en segundo plano"
#: src/features/admin/components/award-badges.tsx:70 #: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type" msgid "Badge Type"
msgstr "Tipo de insignia" msgstr "Tipo de insignia"
@@ -581,7 +597,7 @@ msgid "Bracket"
msgstr "Bracket" msgstr "Bracket"
#: src/app/routes/_authed/admin/preview.tsx:13 #: src/app/routes/_authed/admin/preview.tsx:13
#: src/features/admin/components/admin-page.tsx:59 #: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview" msgid "Bracket Preview"
msgstr "Vista previa del bracket" msgstr "Vista previa del bracket"
@@ -651,9 +667,9 @@ msgstr "Elige un jugador"
msgid "Claymation" msgid "Claymation"
msgstr "Plastilina" msgstr "Plastilina"
#: src/features/players/components/league-head-to-head.tsx:203 #: src/features/players/components/league-head-to-head.tsx:99
msgid "Clear both players" msgid "Clear selected player"
msgstr "Quitar ambos jugadores" msgstr "Quitar jugador seleccionado"
#: src/components/sheet/modal.tsx:35 #: src/components/sheet/modal.tsx:35
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169 #: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
@@ -841,6 +857,10 @@ msgstr "Se requiere deviceId para la acción de transferencia"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Desconectar Spotify" msgstr "Desconectar Spotify"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "Cerrar"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "¿Se ve bien así?" msgstr "¿Se ve bien así?"
@@ -1060,6 +1080,10 @@ msgstr "No se pudo establecer el dispositivo activo"
msgid "Failed to set volume" msgid "Failed to set volume"
msgstr "No se pudo ajustar el volumen" msgstr "No se pudo ajustar el volumen"
#: src/features/admin/components/admin-page.tsx:42
msgid "Failed to start badge recalculation"
msgstr "No se pudo iniciar el recálculo de insignias"
#: src/features/teams/components/team-form/index.tsx:105 #: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team" msgid "Failed to update team"
msgstr "No se pudo actualizar el equipo" msgstr "No se pudo actualizar el equipo"
@@ -1200,7 +1224,7 @@ msgstr "¿Ya has jugado antes?"
#: src/features/bracket/components/match-dock.tsx:215 #: src/features/bracket/components/match-dock.tsx:215
#: src/features/matches/components/match-card.tsx:84 #: src/features/matches/components/match-card.tsx:84
#: src/features/matches/components/match-card.tsx:228 #: src/features/matches/components/match-card.tsx:228
#: src/features/players/components/league-head-to-head.tsx:264 #: src/features/players/components/league-head-to-head.tsx:183
#: src/features/players/components/profile/header.tsx:90 #: src/features/players/components/profile/header.tsx:90
msgid "Head to Head" msgid "Head to Head"
msgstr "Cara a cara" msgstr "Cara a cara"
@@ -1322,6 +1346,11 @@ msgstr "El apellido debe tener entre 2 y 20 caracteres y contener solo letras"
msgid "Last name must be 3-20 characters long and contain only letters" msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "El apellido debe tener entre 3 y 20 caracteres y contener solo letras" msgstr "El apellido debe tener entre 3 y 20 caracteres y contener solo letras"
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
#: src/features/admin/components/admin-page.tsx:74
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
msgstr "Último recálculo: {0, plural, one {# insignia obtenida} other {# insignias obtenidas}}"
#: src/features/settings/components/color-scheme-picker.tsx:43 #: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light" msgid "Light"
msgstr "Claro" msgstr "Claro"
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
msgstr "Administrar canciones/logos del equipo" msgstr "Administrar canciones/logos del equipo"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17 #: src/app/routes/_authed/admin/tournaments/index.tsx:17
#: src/features/admin/components/admin-page.tsx:31 #: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments" msgid "Manage Tournaments"
msgstr "Administrar torneos" msgstr "Administrar torneos"
@@ -1485,7 +1514,11 @@ msgstr "Enfrentamiento"
msgid "Matchup details" msgid "Matchup details"
msgstr "Detalles del enfrentamiento" msgstr "Detalles del enfrentamiento"
#: src/features/admin/components/admin-page.tsx:41 #: src/features/players/components/league-head-to-head.tsx:120
msgid "Me"
msgstr "Yo"
#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress" msgid "Migrate Badge Progress"
msgstr "Migrar progreso de insignias" msgstr "Migrar progreso de insignias"
@@ -1600,7 +1633,7 @@ msgstr "No hay partidos programados"
msgid "No player activity found" msgid "No player activity found"
msgstr "No se encontró actividad de jugadores" msgstr "No se encontró actividad de jugadores"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
msgid "No players available" msgid "No players available"
msgstr "No hay jugadores disponibles" msgstr "No hay jugadores disponibles"
@@ -1609,7 +1642,7 @@ msgstr "No hay jugadores disponibles"
msgid "No players enrolled yet" msgid "No players enrolled yet"
msgstr "Aún no hay jugadores inscritos" msgstr "Aún no hay jugadores inscritos"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
#: src/features/players/components/player-stats-table.tsx:476 #: src/features/players/components/player-stats-table.tsx:476
msgid "No players found matching \"{search}\"" msgid "No players found matching \"{search}\""
msgstr "No se encontraron jugadores que coincidan con \"{search}\"" msgstr "No se encontraron jugadores que coincidan con \"{search}\""
@@ -1725,7 +1758,7 @@ msgstr "Una vez inscrito, puedes ver a otros agentes libres y su número de tel
msgid "One time code" msgid "One time code"
msgstr "Código de un solo uso" msgstr "Código de un solo uso"
#: src/features/admin/components/admin-page.tsx:52 #: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase" msgid "Open Pocketbase"
msgstr "Abrir Pocketbase" msgstr "Abrir Pocketbase"
@@ -1819,12 +1852,10 @@ msgstr "Falló el control de reproducción"
msgid "Player" msgid "Player"
msgstr "Jugador" msgstr "Jugador"
#: src/features/players/components/league-head-to-head.tsx:117
#: src/features/teams/components/team-form/players-picker.tsx:45 #: src/features/teams/components/team-form/players-picker.tsx:45
msgid "Player 1" msgid "Player 1"
msgstr "Jugador 1" msgstr "Jugador 1"
#: src/features/players/components/league-head-to-head.tsx:173
#: src/features/teams/components/team-form/players-picker.tsx:53 #: src/features/teams/components/team-form/players-picker.tsx:53
msgid "Player 2" msgid "Player 2"
msgstr "Jugador 2" msgstr "Jugador 2"
@@ -1862,10 +1893,6 @@ msgstr "Los jugadores son obligatorios"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "Reproduciendo en {0}" msgstr "Reproduciendo en {0}"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "Por favor instala FLXN • Así me ahorras créditos de Twilio porque no se cerrará tu sesión"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "Llenar el bracket eliminatorio" msgstr "Llenar el bracket eliminatorio"
@@ -1925,6 +1952,12 @@ msgstr "Volver a sortear"
msgid "Reactions" msgid "Reactions"
msgstr "Reacciones" msgstr "Reacciones"
#. placeholder {0}: migrationStatus.playersProcessed
#. placeholder {1}: migrationStatus.totalPlayers
#: src/features/admin/components/admin-page.tsx:67
msgid "Recalculating badges in the background ({0}/{1})"
msgstr "Recalculando insignias en segundo plano ({0}/{1})"
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34 #: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished" msgid "Recently finished"
msgstr "Recién terminados" msgstr "Recién terminados"
@@ -2032,7 +2065,7 @@ msgstr "Buscar canciones..."
msgid "Search for teams to enroll..." msgid "Search for teams to enroll..."
msgstr "Buscar equipos para inscribir..." msgstr "Buscar equipos para inscribir..."
#: src/features/players/components/league-head-to-head.tsx:211 #: src/features/players/components/league-head-to-head.tsx:129
#: src/features/players/components/player-stats-table.tsx:329 #: src/features/players/components/player-stats-table.tsx:329
msgid "Search players" msgid "Search players"
msgstr "Buscar jugadores" msgstr "Buscar jugadores"
@@ -2070,6 +2103,10 @@ msgstr "Selecciona una insignia"
msgid "Select a device..." msgid "Select a device..."
msgstr "Selecciona un dispositivo..." msgstr "Selecciona un dispositivo..."
#: src/features/players/components/league-head-to-head.tsx:109
msgid "Select a player"
msgstr "Selecciona un jugador"
#: src/features/teams/components/team-form/song-picker.tsx:131 #: src/features/teams/components/team-form/song-picker.tsx:131
msgid "Select a song to see details" msgid "Select a song to see details"
msgstr "Selecciona una canción para ver detalles" msgstr "Selecciona una canción para ver detalles"
@@ -2100,10 +2137,6 @@ msgstr "Selecciona fecha de fin"
msgid "Select Enrollment Due Date" msgid "Select Enrollment Due Date"
msgstr "Selecciona fecha límite de inscripción" msgstr "Selecciona fecha límite de inscripción"
#: src/features/players/components/league-head-to-head.tsx:188
msgid "Select first player"
msgstr "Selecciona el primer jugador"
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64 #: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
msgid "Select one of your existing teams" msgid "Select one of your existing teams"
msgstr "Selecciona uno de tus equipos existentes" msgstr "Selecciona uno de tus equipos existentes"
@@ -2112,10 +2145,6 @@ msgstr "Selecciona uno de tus equipos existentes"
msgid "Select Player" msgid "Select Player"
msgstr "Selecciona jugador" msgstr "Selecciona jugador"
#: src/features/players/components/league-head-to-head.tsx:189
msgid "Select second player"
msgstr "Selecciona el segundo jugador"
#: src/features/teams/components/team-form/song-picker.tsx:68 #: src/features/teams/components/team-form/song-picker.tsx:68
msgid "Select Song" msgid "Select Song"
msgstr "Selecciona canción" msgstr "Selecciona canción"
@@ -2318,11 +2347,15 @@ msgstr "Sistema"
msgid "T" msgid "T"
msgstr "T" msgstr "T"
#: src/components/ios-install-prompt.tsx:37 #: src/features/players/components/league-head-to-head.tsx:89
msgid "Tap an opponent below to compare"
msgstr "Toca un oponente abajo para comparar"
#: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio" msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "Toca Compartir → Agregar a pantalla de inicio" msgstr "Toca Compartir → Agregar a pantalla de inicio"
@@ -2624,10 +2657,6 @@ msgstr "volumePercent debe ser un número"
msgid "vs" msgid "vs"
msgstr "vs" msgstr "vs"
#: src/features/players/components/league-head-to-head.tsx:125
msgid "VS"
msgstr "VS"
#: src/features/players/components/player-stats-table.tsx:131 #: src/features/players/components/player-stats-table.tsx:131
#: src/features/tournaments/components/tournament-stats.tsx:112 #: src/features/tournaments/components/tournament-stats.tsx:112
msgid "W" msgid "W"
+62 -33
View File
@@ -384,7 +384,7 @@ msgid "Active device: {0}"
msgstr "アクティブなデバイス: {0}" msgstr "アクティブなデバイス: {0}"
#: src/app/routes/_authed/admin/activities.tsx:21 #: src/app/routes/_authed/admin/activities.tsx:21
#: src/features/admin/components/admin-page.tsx:47 #: src/features/admin/components/admin-page.tsx:90
msgid "Activities" msgid "Activities"
msgstr "アクティビティ" msgstr "アクティビティ"
@@ -392,6 +392,10 @@ msgstr "アクティビティ"
msgid "Activity Details" msgid "Activity Details"
msgstr "アクティビティの詳細" msgstr "アクティビティの詳細"
#: src/components/ios-install-prompt.tsx:58
msgid "Add FLXN to your home screen for a smoother experience"
msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます"
#: src/features/tournaments/components/edit-enrolled-players.tsx:117 #: src/features/tournaments/components/edit-enrolled-players.tsx:117
msgid "Add Player" msgid "Add Player"
msgstr "プレイヤーを追加" msgstr "プレイヤーを追加"
@@ -520,7 +524,7 @@ msgstr "平均勝差"
msgid "Award Badge" msgid "Award Badge"
msgstr "バッジを付与" msgstr "バッジを付与"
#: src/features/admin/components/admin-page.tsx:36 #: src/features/admin/components/admin-page.tsx:55
msgid "Award Badges" msgid "Award Badges"
msgstr "バッジを付与" msgstr "バッジを付与"
@@ -554,6 +558,18 @@ msgstr "戻る"
msgid "Badge awarded to {playerName}" msgid "Badge awarded to {playerName}"
msgstr "{playerName}にバッジを付与しました" msgstr "{playerName}にバッジを付与しました"
#: src/features/admin/components/admin-page.tsx:86
msgid "Badge recalculation failed. Check the server logs."
msgstr "バッジの再計算に失敗しました。サーバーログを確認してください。"
#: src/features/admin/components/admin-page.tsx:39
msgid "Badge recalculation is already running"
msgstr "バッジの再計算はすでに実行中です"
#: src/features/admin/components/admin-page.tsx:38
msgid "Badge recalculation started in the background"
msgstr "バックグラウンドでバッジの再計算を開始しました"
#: src/features/admin/components/award-badges.tsx:70 #: src/features/admin/components/award-badges.tsx:70
msgid "Badge Type" msgid "Badge Type"
msgstr "バッジの種類" msgstr "バッジの種類"
@@ -581,7 +597,7 @@ msgid "Bracket"
msgstr "ブラケット" msgstr "ブラケット"
#: src/app/routes/_authed/admin/preview.tsx:13 #: src/app/routes/_authed/admin/preview.tsx:13
#: src/features/admin/components/admin-page.tsx:59 #: src/features/admin/components/admin-page.tsx:102
msgid "Bracket Preview" msgid "Bracket Preview"
msgstr "ブラケットプレビュー" msgstr "ブラケットプレビュー"
@@ -651,9 +667,9 @@ msgstr "プレイヤーを選択"
msgid "Claymation" msgid "Claymation"
msgstr "クレイ" msgstr "クレイ"
#: src/features/players/components/league-head-to-head.tsx:203 #: src/features/players/components/league-head-to-head.tsx:99
msgid "Clear both players" msgid "Clear selected player"
msgstr "両方のプレイヤーをクリア" msgstr "選択したプレイヤーを解除"
#: src/components/sheet/modal.tsx:35 #: src/components/sheet/modal.tsx:35
#: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169 #: src/features/tournaments/components/started-tournament/sections/nav-grid.tsx:169
@@ -841,6 +857,10 @@ msgstr "転送操作にはdeviceIdが必要です"
msgid "Disconnect Spotify" msgid "Disconnect Spotify"
msgstr "Spotifyの連携を解除" msgstr "Spotifyの連携を解除"
#: src/components/ios-install-prompt.tsx:65
msgid "Dismiss"
msgstr "閉じる"
#: src/features/bracket/components/match-report.tsx:159 #: src/features/bracket/components/match-report.tsx:159
msgid "Does this look right?" msgid "Does this look right?"
msgstr "これで合っていますか?" msgstr "これで合っていますか?"
@@ -1060,6 +1080,10 @@ msgstr "アクティブなデバイスの設定に失敗しました"
msgid "Failed to set volume" msgid "Failed to set volume"
msgstr "音量の設定に失敗しました" msgstr "音量の設定に失敗しました"
#: src/features/admin/components/admin-page.tsx:42
msgid "Failed to start badge recalculation"
msgstr "バッジの再計算を開始できませんでした"
#: src/features/teams/components/team-form/index.tsx:105 #: src/features/teams/components/team-form/index.tsx:105
msgid "Failed to update team" msgid "Failed to update team"
msgstr "チームの更新に失敗しました" msgstr "チームの更新に失敗しました"
@@ -1200,7 +1224,7 @@ msgstr "参加したことはありますか?"
#: src/features/bracket/components/match-dock.tsx:215 #: src/features/bracket/components/match-dock.tsx:215
#: src/features/matches/components/match-card.tsx:84 #: src/features/matches/components/match-card.tsx:84
#: src/features/matches/components/match-card.tsx:228 #: src/features/matches/components/match-card.tsx:228
#: src/features/players/components/league-head-to-head.tsx:264 #: src/features/players/components/league-head-to-head.tsx:183
#: src/features/players/components/profile/header.tsx:90 #: src/features/players/components/profile/header.tsx:90
msgid "Head to Head" msgid "Head to Head"
msgstr "対戦成績" msgstr "対戦成績"
@@ -1322,6 +1346,11 @@ msgstr "姓は2〜20文字の英字のみで入力してください"
msgid "Last name must be 3-20 characters long and contain only letters" msgid "Last name must be 3-20 characters long and contain only letters"
msgstr "姓は3〜20文字の英字のみで入力してください" msgstr "姓は3〜20文字の英字のみで入力してください"
#. placeholder {0}: migrationStatus.result.totalBadgesEarned
#: src/features/admin/components/admin-page.tsx:74
msgid "Last recalculation: {0, plural, one {# badge earned} other {# badges earned}}"
msgstr "前回の再計算:{0, plural, one {# 個のバッジを獲得} other {# 個のバッジを獲得}}"
#: src/features/settings/components/color-scheme-picker.tsx:43 #: src/features/settings/components/color-scheme-picker.tsx:43
msgid "Light" msgid "Light"
msgstr "ライト" msgstr "ライト"
@@ -1410,7 +1439,7 @@ msgid "Manage Team Songs/Logos"
msgstr "チームの曲・ロゴを管理" msgstr "チームの曲・ロゴを管理"
#: src/app/routes/_authed/admin/tournaments/index.tsx:17 #: src/app/routes/_authed/admin/tournaments/index.tsx:17
#: src/features/admin/components/admin-page.tsx:31 #: src/features/admin/components/admin-page.tsx:50
msgid "Manage Tournaments" msgid "Manage Tournaments"
msgstr "トーナメント管理" msgstr "トーナメント管理"
@@ -1485,7 +1514,11 @@ msgstr "対戦カード"
msgid "Matchup details" msgid "Matchup details"
msgstr "対戦カードの詳細" msgstr "対戦カードの詳細"
#: src/features/admin/components/admin-page.tsx:41 #: src/features/players/components/league-head-to-head.tsx:120
msgid "Me"
msgstr "自分"
#: src/features/admin/components/admin-page.tsx:60
msgid "Migrate Badge Progress" msgid "Migrate Badge Progress"
msgstr "バッジ進捗を移行" msgstr "バッジ進捗を移行"
@@ -1600,7 +1633,7 @@ msgstr "予定されている試合はありません"
msgid "No player activity found" msgid "No player activity found"
msgstr "プレイヤーのアクティビティが見つかりません" msgstr "プレイヤーのアクティビティが見つかりません"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
msgid "No players available" msgid "No players available"
msgstr "利用可能なプレイヤーがいません" msgstr "利用可能なプレイヤーがいません"
@@ -1609,7 +1642,7 @@ msgstr "利用可能なプレイヤーがいません"
msgid "No players enrolled yet" msgid "No players enrolled yet"
msgstr "まだプレイヤーが登録されていません" msgstr "まだプレイヤーが登録されていません"
#: src/features/players/components/league-head-to-head.tsx:223 #: src/features/players/components/league-head-to-head.tsx:141
#: src/features/players/components/player-stats-table.tsx:476 #: src/features/players/components/player-stats-table.tsx:476
msgid "No players found matching \"{search}\"" msgid "No players found matching \"{search}\""
msgstr "「{search}」に一致するプレイヤーが見つかりません" msgstr "「{search}」に一致するプレイヤーが見つかりません"
@@ -1725,7 +1758,7 @@ msgstr "登録が完了すると、他のフリーエージェントとその電
msgid "One time code" msgid "One time code"
msgstr "ワンタイムコード" msgstr "ワンタイムコード"
#: src/features/admin/components/admin-page.tsx:52 #: src/features/admin/components/admin-page.tsx:95
msgid "Open Pocketbase" msgid "Open Pocketbase"
msgstr "Pocketbaseを開く" msgstr "Pocketbaseを開く"
@@ -1819,12 +1852,10 @@ msgstr "再生コントロールに失敗しました"
msgid "Player" msgid "Player"
msgstr "プレイヤー" msgstr "プレイヤー"
#: src/features/players/components/league-head-to-head.tsx:117
#: src/features/teams/components/team-form/players-picker.tsx:45 #: src/features/teams/components/team-form/players-picker.tsx:45
msgid "Player 1" msgid "Player 1"
msgstr "プレイヤー1" msgstr "プレイヤー1"
#: src/features/players/components/league-head-to-head.tsx:173
#: src/features/teams/components/team-form/players-picker.tsx:53 #: src/features/teams/components/team-form/players-picker.tsx:53
msgid "Player 2" msgid "Player 2"
msgstr "プレイヤー2" msgstr "プレイヤー2"
@@ -1862,10 +1893,6 @@ msgstr "プレイヤーの選択が必要です"
msgid "Playing on {0}" msgid "Playing on {0}"
msgstr "{0}で再生中" msgstr "{0}で再生中"
#: src/components/ios-install-prompt.tsx:47
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
msgstr "FLXNをインストールしてください • ログアウトされなくなるので、Twilioのクレジット節約になります!"
#: src/features/tournaments/components/group-stage-view.tsx:314 #: src/features/tournaments/components/group-stage-view.tsx:314
msgid "Populate Knockout Bracket" msgid "Populate Knockout Bracket"
msgstr "ノックアウトブラケットを作成" msgstr "ノックアウトブラケットを作成"
@@ -1925,6 +1952,12 @@ msgstr "再抽選"
msgid "Reactions" msgid "Reactions"
msgstr "リアクション" msgstr "リアクション"
#. placeholder {0}: migrationStatus.playersProcessed
#. placeholder {1}: migrationStatus.totalPlayers
#: src/features/admin/components/admin-page.tsx:67
msgid "Recalculating badges in the background ({0}/{1})"
msgstr "バックグラウンドでバッジを再計算中({0}/{1})"
#: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34 #: src/features/tournaments/components/started-tournament/sections/recent-results.tsx:34
msgid "Recently finished" msgid "Recently finished"
msgstr "最近終了した試合" msgstr "最近終了した試合"
@@ -2032,7 +2065,7 @@ msgstr "曲を検索..."
msgid "Search for teams to enroll..." msgid "Search for teams to enroll..."
msgstr "登録するチームを検索..." msgstr "登録するチームを検索..."
#: src/features/players/components/league-head-to-head.tsx:211 #: src/features/players/components/league-head-to-head.tsx:129
#: src/features/players/components/player-stats-table.tsx:329 #: src/features/players/components/player-stats-table.tsx:329
msgid "Search players" msgid "Search players"
msgstr "プレイヤーを検索" msgstr "プレイヤーを検索"
@@ -2070,6 +2103,10 @@ msgstr "バッジを選択"
msgid "Select a device..." msgid "Select a device..."
msgstr "デバイスを選択..." msgstr "デバイスを選択..."
#: src/features/players/components/league-head-to-head.tsx:109
msgid "Select a player"
msgstr "プレイヤーを選択"
#: src/features/teams/components/team-form/song-picker.tsx:131 #: src/features/teams/components/team-form/song-picker.tsx:131
msgid "Select a song to see details" msgid "Select a song to see details"
msgstr "詳細を見るには曲を選択してください" msgstr "詳細を見るには曲を選択してください"
@@ -2100,10 +2137,6 @@ msgstr "終了日を選択"
msgid "Select Enrollment Due Date" msgid "Select Enrollment Due Date"
msgstr "登録締切日を選択" msgstr "登録締切日を選択"
#: src/features/players/components/league-head-to-head.tsx:188
msgid "Select first player"
msgstr "1人目のプレイヤーを選択"
#: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64 #: src/features/tournaments/components/upcoming-tournament/enroll-team/team-selection-view.tsx:64
msgid "Select one of your existing teams" msgid "Select one of your existing teams"
msgstr "既存のチームから選択してください" msgstr "既存のチームから選択してください"
@@ -2112,10 +2145,6 @@ msgstr "既存のチームから選択してください"
msgid "Select Player" msgid "Select Player"
msgstr "プレイヤーを選択" msgstr "プレイヤーを選択"
#: src/features/players/components/league-head-to-head.tsx:189
msgid "Select second player"
msgstr "2人目のプレイヤーを選択"
#: src/features/teams/components/team-form/song-picker.tsx:68 #: src/features/teams/components/team-form/song-picker.tsx:68
msgid "Select Song" msgid "Select Song"
msgstr "曲を選択" msgstr "曲を選択"
@@ -2318,11 +2347,15 @@ msgstr "システム"
msgid "T" msgid "T"
msgstr "T" msgstr "T"
#: src/components/ios-install-prompt.tsx:37 #: src/features/players/components/league-head-to-head.tsx:89
msgid "Tap an opponent below to compare"
msgstr "下の対戦相手をタップして比較"
#: src/components/ios-install-prompt.tsx:43
msgid "Tap Menu (⋮) → Add to Home screen" msgid "Tap Menu (⋮) → Add to Home screen"
msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」" msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」"
#: src/components/ios-install-prompt.tsx:36 #: src/components/ios-install-prompt.tsx:42
msgid "Tap Share → Add to Home Screen" msgid "Tap Share → Add to Home Screen"
msgstr "共有をタップ →「ホーム画面に追加」" msgstr "共有をタップ →「ホーム画面に追加」"
@@ -2624,10 +2657,6 @@ msgstr "volumePercentは数値である必要があります"
msgid "vs" msgid "vs"
msgstr "vs" msgstr "vs"
#: src/features/players/components/league-head-to-head.tsx:125
msgid "VS"
msgstr "VS"
#: src/features/players/components/player-stats-table.tsx:131 #: src/features/players/components/player-stats-table.tsx:131
#: src/features/tournaments/components/tournament-stats.tsx:112 #: src/features/tournaments/components/tournament-stats.tsx:112
msgid "W" msgid "W"
+14
View File
@@ -0,0 +1,14 @@
// Mirrors PocketBase's own scan of a quoted literal: a backslash consumes the
// character after it, so an unterminated literal is exactly what a trailing
// backslash produces.
export const literalTerminates = (expression: string) => {
const open = expression.indexOf("'");
for (let i = open + 1; i < expression.length; i++) {
if (expression[i] === "\\") {
i++;
continue;
}
if (expression[i] === "'") return true;
}
return false;
};
+226
View File
@@ -0,0 +1,226 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const h = vi.hoisted(() => ({
request: undefined as { url: string; headers: Headers } | undefined,
createActivity: vi.fn(),
}));
vi.mock("@tanstack/react-start/server", () => ({
getRequest: () => h.request,
}));
vi.mock("@/lib/pocketbase/client", () => ({
pbAdmin: { authPromise: Promise.resolve(), createActivity: h.createActivity },
}));
vi.mock("@/lib/logger", () => ({
Logger: class {
error() {}
info() {}
},
}));
import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities";
type ServerHandler = (opts: {
next: () => Promise<unknown>;
data: unknown;
context: unknown;
serverFnMeta?: { id: string; name?: string; filename?: string };
}) => Promise<unknown>;
const runMiddleware = (opts: Parameters<ServerHandler>[0]) =>
(serverFnLoggingMiddleware as any).options.server(opts) as ReturnType<ServerHandler>;
const setRequest = (url: string, userAgent = "vitest") => {
h.request = { url, headers: new Headers({ "user-agent": userAgent }) };
};
const successEnvelope = { result: { success: true, data: {} } };
beforeEach(() => {
h.createActivity.mockReset();
});
describe("serverFnLoggingMiddleware", () => {
it("records the source name from the compile-time meta, not the hashed url segment", async () => {
const hashedId = "a".repeat(64);
setRequest(`http://localhost:3000/_serverFn/${hashedId}`);
await runMiddleware({
next: async () => successEnvelope,
data: undefined,
context: { metadata: { player_id: "p1" } },
serverFnMeta: {
id: hashedId,
name: "updatePlayer",
filename: "src/features/players/server.ts",
},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
name: "updatePlayer",
player: "p1",
success: true,
user_agent: "vitest",
});
});
it("falls back to the last path segment when the meta carries no name", async () => {
setRequest("http://localhost:3000/_serverFn/deadbeef");
await runMiddleware({
next: async () => successEnvelope,
data: undefined,
context: {},
serverFnMeta: { id: "deadbeef" },
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0].name).toBe("deadbeef");
});
it("falls back to the last path segment when there is no meta at all", async () => {
setRequest("http://localhost:3000/_serverFn/legacySegment");
await runMiddleware({
next: async () => successEnvelope,
data: undefined,
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0].name).toBe("legacySegment");
});
it("records no player when the session has no player_id", async () => {
setRequest("http://localhost:3000/_serverFn/doThing");
await runMiddleware({
next: async () => successEnvelope,
data: undefined,
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0].player).toBeUndefined();
});
it("falls back to 'unknown' when the path has no trailing segment", async () => {
setRequest("http://localhost:3000/");
await runMiddleware({
next: async () => successEnvelope,
data: undefined,
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0].name).toBe("unknown");
});
it("records success:false by reading the returned envelope, not by throwing", async () => {
setRequest("http://localhost:3000/_serverFn/doThing");
await runMiddleware({
next: async () => ({
result: {
success: false,
error: { code: "NOT_FOUND", userMessage: "nope" },
},
}),
data: undefined,
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
const row = h.createActivity.mock.calls[0][0];
expect(row.success).toBe(false);
expect(row.error).toContain("NOT_FOUND");
});
it("records success:false and rethrows when the handler throws", async () => {
setRequest("http://localhost:3000/_serverFn/doThing");
await expect(
runMiddleware({
next: async () => {
throw new Error("boom");
},
data: undefined,
context: {},
})
).rejects.toThrow("boom");
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
success: false,
error: "boom",
});
});
it("redacts sensitive argument keys before they reach the audit row", async () => {
setRequest("http://localhost:3000/_serverFn/login");
await runMiddleware({
next: async () => successEnvelope,
data: { phone: "+17135550142", first_name: "Ada" },
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0].arguments).toEqual({
phone: "[redacted]",
first_name: "Ada",
});
});
it("truncates oversized arguments", async () => {
setRequest("http://localhost:3000/_serverFn/doThing");
await runMiddleware({
next: async () => successEnvelope,
data: { blob: "x".repeat(5000) },
context: {},
});
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
const args = h.createActivity.mock.calls[0][0].arguments;
expect(args.truncated).toBeDefined();
expect(args.truncated.length).toBeLessThanOrEqual(2048);
});
});
describe("recordDeniedServerFn", () => {
const deniedRequest = (url: string) =>
({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request;
it("names the denial row from the meta so it matches the success rows", async () => {
recordDeniedServerFn(
deniedRequest(`http://localhost:3000/_serverFn/${"b".repeat(64)}`),
{ player_id: "p1" },
{ name: "createTournament" }
);
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
name: "createTournament",
player: "p1",
success: false,
error: "FORBIDDEN: Access denied",
});
});
it("still records a name when the meta is absent", async () => {
recordDeniedServerFn(
deniedRequest("http://localhost:3000/_serverFn/rawSegment")
);
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
name: "rawSegment",
success: false,
});
});
});
+72 -13
View File
@@ -1,6 +1,7 @@
import { createMiddleware } from "@tanstack/react-start"; import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { Logger } from "@/lib/logger"; import { Logger } from "@/lib/logger";
import { redactValue } from "@/lib/redact";
import type { ActivityInput } from "@/lib/pocketbase/services/activities"; import type { ActivityInput } from "@/lib/pocketbase/services/activities";
const logger = new Logger("Activities"); const logger = new Logger("Activities");
@@ -16,16 +17,61 @@ const recordActivity = (activity: ActivityInput) => {
}); });
}; };
const MAX_ARGUMENTS_CHARS = 2048;
const redactArguments = (data: unknown): unknown => {
if (data === undefined) return undefined;
const redacted = redactValue(data);
const serialized = JSON.stringify(redacted) ?? "";
if (serialized.length > MAX_ARGUMENTS_CHARS) {
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
}
return redacted;
};
const auditErrorMessage = (
error?: { code?: string; userMessage?: string }
): string => {
if (!error) return "error";
return [error.code, error.userMessage].filter(Boolean).join(": ") || "error";
};
type Actor = { player_id?: string };
type ServerFnMeta = { name?: string };
// The URL segment is a hash of the fn id; serverFnMeta carries the readable name.
const serverFnName = (request: Request, meta?: ServerFnMeta): string => {
if (meta?.name) return meta.name;
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
return pathParts[pathParts.length - 1] || "unknown";
};
export const recordDeniedServerFn = (
request: Request,
actor?: Actor,
meta?: ServerFnMeta
) => {
recordActivity({
name: serverFnName(request, meta),
player: actor?.player_id,
duration: 0,
success: false,
error: "FORBIDDEN: Access denied",
user_agent: request.headers.get("user-agent") || undefined,
});
};
export const serverFnLoggingMiddleware = createMiddleware({ export const serverFnLoggingMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, data, context }) => { }).server(async ({ next, data, context, serverFnMeta }) => {
const request = getRequest(); const request = getRequest();
const url = new URL(request.url); const name = serverFnName(request, serverFnMeta);
const pathParts = url.pathname.split('/').filter(Boolean); const playerId = (context as any)?.metadata?.player_id as string | undefined;
const serverFnName = pathParts[pathParts.length - 1] || 'unknown'; const userAgent = request.headers.get("user-agent") || undefined;
const userId = (context as any)?.metadata?.player_id || 'unknown'; const safeArgs = redactArguments(data);
const userAgent = request.headers.get('user-agent') || undefined;
const startTime = Date.now(); const startTime = Date.now();
@@ -33,12 +79,25 @@ export const serverFnLoggingMiddleware = createMiddleware({
const result = await next(); const result = await next();
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const envelope = (result as { result?: unknown })?.result;
const failed =
!!envelope &&
typeof envelope === "object" &&
"success" in envelope &&
(envelope as { success: boolean }).success === false;
recordActivity({ recordActivity({
name: serverFnName, name,
player: userId !== 'unknown' ? userId : undefined, player: playerId,
duration, duration,
success: true, success: !failed,
arguments: data, error: failed
? auditErrorMessage(
(envelope as { error?: { code?: string; userMessage?: string } })
.error
)
: undefined,
arguments: safeArgs,
user_agent: userAgent, user_agent: userAgent,
}); });
@@ -48,12 +107,12 @@ export const serverFnLoggingMiddleware = createMiddleware({
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
recordActivity({ recordActivity({
name: serverFnName, name,
player: userId !== 'unknown' ? userId : undefined, player: playerId,
duration, duration,
success: false, success: false,
error: errorMessage, error: errorMessage,
arguments: data, arguments: safeArgs,
user_agent: userAgent, user_agent: userAgent,
}); });
+7 -2
View File
@@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
export const superTokensAdminFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next }) => { }).server(async ({ next, serverFnMeta }) => {
const request = getRequest(); const request = getRequest();
try { try {
@@ -62,7 +62,12 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
return next({ context }); return next({ context });
} }
logger.error("Unauthorized user in admin function.", context); logger.error("Unauthorized user in admin function.", {
userAuthId: context.userAuthId,
roles: context.roles,
});
const { recordDeniedServerFn } = await import("./activities");
recordDeniedServerFn(request, context.metadata, serverFnMeta);
throw new Error("Unauthorized"); throw new Error("Unauthorized");
} catch (error: any) { } catch (error: any) {
if (error.message === "SESSION_REFRESH_REQUIRED") { if (error.message === "SESSION_REFRESH_REQUIRED") {
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vitest/config'
import tsConfigPaths from 'vite-tsconfig-paths'
// Excludes the TanStack Start plugin: its SSR/router codegen isn't needed for unit tests.
export default defineConfig({
plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })],
test: {
environment: 'node',
include: ['src/**/*.test.{ts,tsx}'],
restoreMocks: true,
},
})