diff --git a/bun.lock b/bun.lock
index 50968ac..a5f208e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,6 +41,7 @@
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"web-push": "^3.6.7",
+ "web-vitals": "^6.2.0",
"zod": "^4.0.15",
},
"devDependencies": {
@@ -1590,6 +1591,8 @@
"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-vitals": ["web-vitals@6.2.0", "", {}, "sha512-i2jhLkq6eB38jgJX5OIjEkyzQKgsG7FR56v7GphJNjzKAySgbRbwAfsFC0dDfHROWmCycf912SJpU2QoYIcMVg=="],
+
"webidl-conversions": ["webidl-conversions@4.0.2", "", {}, "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
diff --git a/package.json b/package.json
index c14bbab..c7cb483 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"web-push": "^3.6.7",
+ "web-vitals": "^6.2.0",
"zod": "^4.0.15"
},
"devDependencies": {
diff --git a/pb_migrations/1787961600_activities_indexes.js b/pb_migrations/1787961600_activities_indexes.js
new file mode 100644
index 0000000..bcefa6b
--- /dev/null
+++ b/pb_migrations/1787961600_activities_indexes.js
@@ -0,0 +1,23 @@
+///
+migrate((app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1262591861")
+
+ unmarshal({
+ "indexes": [
+ "CREATE INDEX `idx_activities_created` ON `activities` (`created`)",
+ "CREATE INDEX `idx_activities_name_created` ON `activities` (`name`, `created`)",
+ "CREATE INDEX `idx_activities_player_created` ON `activities` (`player`, `created`)",
+ "CREATE INDEX `idx_activities_success_created` ON `activities` (`success`, `created`)"
+ ]
+ }, collection)
+
+ return app.save(collection)
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_1262591861")
+
+ unmarshal({
+ "indexes": []
+ }, collection)
+
+ return app.save(collection)
+})
diff --git a/pb_migrations/1787961700_created_client_events.js b/pb_migrations/1787961700_created_client_events.js
new file mode 100644
index 0000000..972f8d0
--- /dev/null
+++ b/pb_migrations/1787961700_created_client_events.js
@@ -0,0 +1,167 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ce_name",
+ "max": 0,
+ "min": 0,
+ "name": "name",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation_ce_player",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "player",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ce_session_id",
+ "max": 0,
+ "min": 0,
+ "name": "session_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ce_path",
+ "max": 0,
+ "min": 0,
+ "name": "path",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ce_route_id",
+ "max": 0,
+ "min": 0,
+ "name": "route_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number_ce_value",
+ "max": null,
+ "min": null,
+ "name": "value",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "json_ce_props",
+ "maxSize": 0,
+ "name": "props",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ce_user_agent",
+ "max": 0,
+ "min": 0,
+ "name": "user_agent",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_client_events",
+ "indexes": [
+ "CREATE INDEX `idx_client_events_created` ON `client_events` (`created`)",
+ "CREATE INDEX `idx_client_events_name_created` ON `client_events` (`name`, `created`)",
+ "CREATE INDEX `idx_client_events_player_created` ON `client_events` (`player`, `created`)",
+ "CREATE INDEX `idx_client_events_session_id` ON `client_events` (`session_id`)"
+ ],
+ "listRule": null,
+ "name": "client_events",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_client_events");
+
+ return app.delete(collection);
+})
diff --git a/pb_migrations/1787961800_created_client_errors.js b/pb_migrations/1787961800_created_client_errors.js
new file mode 100644
index 0000000..2600f24
--- /dev/null
+++ b/pb_migrations/1787961800_created_client_errors.js
@@ -0,0 +1,207 @@
+///
+migrate((app) => {
+ const collection = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_message",
+ "max": 0,
+ "min": 0,
+ "name": "message",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_stack",
+ "max": 0,
+ "min": 0,
+ "name": "stack",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "select_cerr_source",
+ "maxSelect": 1,
+ "name": "source",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "select",
+ "values": [
+ "window",
+ "unhandledrejection",
+ "error-boundary",
+ "sw"
+ ]
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_path",
+ "max": 0,
+ "min": 0,
+ "name": "path",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_route_id",
+ "max": 0,
+ "min": 0,
+ "name": "route_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_group_hash",
+ "max": 0,
+ "min": 0,
+ "name": "group_hash",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "bool_cerr_resolved",
+ "name": "resolved",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "bool"
+ },
+ {
+ "cascadeDelete": false,
+ "collectionId": "pbc_3072146508",
+ "hidden": false,
+ "id": "relation_cerr_player",
+ "maxSelect": 1,
+ "minSelect": 0,
+ "name": "player",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "relation"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_session_id",
+ "max": 0,
+ "min": 0,
+ "name": "session_id",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_cerr_user_agent",
+ "max": 0,
+ "min": 0,
+ "name": "user_agent",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "json_cerr_props",
+ "maxSize": 0,
+ "name": "props",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_client_errors",
+ "indexes": [
+ "CREATE INDEX `idx_client_errors_group_created` ON `client_errors` (`group_hash`, `created`)",
+ "CREATE INDEX `idx_client_errors_created` ON `client_errors` (`created`)",
+ "CREATE INDEX `idx_client_errors_resolved_created` ON `client_errors` (`resolved`, `created`)"
+ ],
+ "listRule": null,
+ "name": "client_errors",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(collection);
+}, (app) => {
+ const collection = app.findCollectionByNameOrId("pbc_client_errors");
+
+ return app.delete(collection);
+})
diff --git a/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js
new file mode 100644
index 0000000..2e699f7
--- /dev/null
+++ b/pb_migrations/1787961900_created_telemetry_rollups_and_alerts.js
@@ -0,0 +1,240 @@
+///
+migrate((app) => {
+ const rollups = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_tr_date",
+ "max": 0,
+ "min": 0,
+ "name": "date",
+ "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_tr_metric",
+ "max": 0,
+ "min": 0,
+ "name": "metric",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_tr_dim",
+ "max": 0,
+ "min": 0,
+ "name": "dim",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number_tr_value",
+ "max": null,
+ "min": null,
+ "name": "value",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "json_tr_meta",
+ "maxSize": 0,
+ "name": "meta",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_telemetry_rollups",
+ "indexes": [
+ "CREATE UNIQUE INDEX `idx_rollups_date_metric_dim` ON `telemetry_rollups` (`date`, `metric`, `dim`)",
+ "CREATE INDEX `idx_rollups_metric_date` ON `telemetry_rollups` (`metric`, `date`)"
+ ],
+ "listRule": null,
+ "name": "telemetry_rollups",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ app.save(rollups);
+
+ const alerts = new Collection({
+ "createRule": null,
+ "deleteRule": null,
+ "fields": [
+ {
+ "autogeneratePattern": "[a-z0-9]{15}",
+ "hidden": false,
+ "id": "text3208210256",
+ "max": 15,
+ "min": 15,
+ "name": "id",
+ "pattern": "^[a-z0-9]+$",
+ "presentable": false,
+ "primaryKey": true,
+ "required": true,
+ "system": true,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ta_kind",
+ "max": 0,
+ "min": 0,
+ "name": "kind",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": true,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ta_dim",
+ "max": 0,
+ "min": 0,
+ "name": "dim",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "autogeneratePattern": "",
+ "hidden": false,
+ "id": "text_ta_message",
+ "max": 0,
+ "min": 0,
+ "name": "message",
+ "pattern": "",
+ "presentable": false,
+ "primaryKey": false,
+ "required": false,
+ "system": false,
+ "type": "text"
+ },
+ {
+ "hidden": false,
+ "id": "number_ta_value",
+ "max": null,
+ "min": null,
+ "name": "value",
+ "onlyInt": false,
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "number"
+ },
+ {
+ "hidden": false,
+ "id": "json_ta_meta",
+ "maxSize": 0,
+ "name": "meta",
+ "presentable": false,
+ "required": false,
+ "system": false,
+ "type": "json"
+ },
+ {
+ "hidden": false,
+ "id": "autodate2990389176",
+ "name": "created",
+ "onCreate": true,
+ "onUpdate": false,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ },
+ {
+ "hidden": false,
+ "id": "autodate3332085495",
+ "name": "updated",
+ "onCreate": true,
+ "onUpdate": true,
+ "presentable": false,
+ "system": false,
+ "type": "autodate"
+ }
+ ],
+ "id": "pbc_telemetry_alerts",
+ "indexes": [
+ "CREATE INDEX `idx_alerts_kind_dim_created` ON `telemetry_alerts` (`kind`, `dim`, `created`)"
+ ],
+ "listRule": null,
+ "name": "telemetry_alerts",
+ "system": false,
+ "type": "base",
+ "updateRule": null,
+ "viewRule": null
+ });
+
+ return app.save(alerts);
+}, (app) => {
+ app.delete(app.findCollectionByNameOrId("pbc_telemetry_alerts"));
+ return app.delete(app.findCollectionByNameOrId("pbc_telemetry_rollups"));
+})
diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts
index cdc5fb1..0b8d5e6 100644
--- a/src/app/routeTree.gen.ts
+++ b/src/app/routeTree.gen.ts
@@ -22,6 +22,7 @@ import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
+import { Route as ApiTelemetryBeaconRouteImport } from './routes/api/telemetry/beacon'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
@@ -117,6 +118,11 @@ const ApiTournamentsUploadLogoRoute =
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
+const ApiTelemetryBeaconRoute = ApiTelemetryBeaconRouteImport.update({
+ id: '/api/telemetry/beacon',
+ path: '/api/telemetry/beacon',
+ getParentRoute: () => rootRouteImport,
+} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
@@ -303,6 +309,7 @@ export interface FileRoutesByFullPath {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
+ '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments/': typeof AuthedTournamentsIndexRoute
@@ -345,6 +352,7 @@ export interface FileRoutesByTo {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
+ '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
@@ -390,6 +398,7 @@ export interface FileRoutesById {
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
+ '/api/telemetry/beacon': typeof ApiTelemetryBeaconRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
@@ -435,6 +444,7 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
+ | '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin/'
| '/tournaments/'
@@ -477,6 +487,7 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
+ | '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/admin'
| '/tournaments'
@@ -521,6 +532,7 @@ export interface FileRouteTypes {
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
+ | '/api/telemetry/beacon'
| '/api/tournaments/upload-logo'
| '/_authed/admin/'
| '/_authed/tournaments/'
@@ -555,6 +567,7 @@ export interface RootRouteChildren {
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
+ ApiTelemetryBeaconRoute: typeof ApiTelemetryBeaconRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
@@ -652,6 +665,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
+ '/api/telemetry/beacon': {
+ id: '/api/telemetry/beacon'
+ path: '/api/telemetry/beacon'
+ fullPath: '/api/telemetry/beacon'
+ preLoaderRoute: typeof ApiTelemetryBeaconRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
@@ -944,6 +964,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
+ ApiTelemetryBeaconRoute: ApiTelemetryBeaconRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
}
@@ -952,10 +973,11 @@ export const routeTree = rootRouteImport
._addFileTypes()
import type { getRouter } from './router.tsx'
-import type { createStart } from '@tanstack/react-start'
+import type { startInstance } from './start.ts'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited>
+ config: Awaited>
}
}
diff --git a/src/app/routes/__root.tsx b/src/app/routes/__root.tsx
index ba1d90b..a81eb22 100644
--- a/src/app/routes/__root.tsx
+++ b/src/app/routes/__root.tsx
@@ -5,11 +5,13 @@ import {
Scripts,
createRootRouteWithContext,
isRedirect,
+ useRouter,
} from "@tanstack/react-router";
import * as React from "react";
import { DefaultCatchBoundary } from "@/components/DefaultCatchBoundary";
import { type QueryClient } from "@tanstack/react-query";
import { ensureSuperTokensFrontend } from "@/lib/supertokens/client";
+import { initTelemetry, track } from "@/lib/telemetry/client";
import { AuthContextType } from "@/contexts/auth-context";
import Providers from "@/features/core/components/providers";
import { SessionMonitor } from "@/components/session-monitor";
@@ -175,12 +177,15 @@ export const Route = createRootRouteWithContext<{
});
function RootComponent() {
+ const router = useRouter();
+
React.useEffect(() => {
ensureSuperTokensFrontend();
+ initTelemetry(router);
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
- navigator.serviceWorker.register('/sw.js').catch(() => {});
+ navigator.serviceWorker.register('/sw.js').catch(() => track('sw_register_failed'));
}
- }, []);
+ }, [router]);
return (
@@ -193,7 +198,6 @@ function RootComponent() {
);
}
-// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
return (
{
- if (!context.auth?.roles?.includes("Admin")) {
+ if (!isAdminRole(context.auth?.roles)) {
throw redirect({ to: "/" });
}
diff --git a/src/app/routes/_authed/admin/activities.tsx b/src/app/routes/_authed/admin/activities.tsx
index 0cc963a..a62fc78 100644
--- a/src/app/routes/_authed/admin/activities.tsx
+++ b/src/app/routes/_authed/admin/activities.tsx
@@ -2,7 +2,8 @@ import { createFileRoute } from "@tanstack/react-router";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { ActivitiesTable, activityQueries } from "@/features/activities";
import { PlayersActivityTable, playerQueries } from "@/features/players";
-import { Box, Divider, Group, Skeleton, Stack, Tabs } from "@mantine/core";
+import { ListSkeleton } from "@/components/skeletons";
+import { Tabs } from "@mantine/core";
import { Suspense, useState } from "react";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
@@ -25,30 +26,6 @@ export const Route = createFileRoute("/_authed/admin/activities")({
}),
});
-function ActivityRowsSkeleton({ withSearch = false }: { withSearch?: boolean }) {
- return (
-
- {withSearch && (
-
-
-
- )}
- {Array.from({ length: 8 }).map((_, index) => (
-
- ))}
-
- );
-}
-
function Stats() {
const [activeTab, setActiveTab] = useState("server-functions");
@@ -60,13 +37,13 @@ function Stats() {
- }>
+ }>
- }>
+ }>
diff --git a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
index f49fb71..3df4c0f 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/assign-partners.tsx
@@ -1,6 +1,7 @@
import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router";
import { tournamentQueries, useFreeAgents, useTournament } from "@/features/tournaments/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
+import { StatTileSkeleton } from "@/components/skeletons";
import { Stack, Text, Button, Alert, LoadingOverlay, Group, Skeleton } from "@mantine/core";
import { useState } from "react";
import useGenerateRandomTeams from "@/features/tournaments/hooks/use-generate-random-teams";
@@ -37,10 +38,7 @@ function AssignPartnersPending() {
return (
-
-
-
-
+
diff --git a/src/app/routes/_authed/admin/tournaments/$id/index.tsx b/src/app/routes/_authed/admin/tournaments/$id/index.tsx
index ba24ea8..ae2db70 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/index.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/index.tsx
@@ -2,7 +2,7 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
-import { Divider, Group, Skeleton, Stack } from "@mantine/core";
+import { ListSkeleton } from "@/components/skeletons";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
@@ -31,18 +31,14 @@ export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
function ManageTournamentPending() {
return (
-
- {Array.from({ length: 5 }).map((_, index) => (
-
- ))}
-
+
);
}
diff --git a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
index fa96f99..c717617 100644
--- a/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
+++ b/src/app/routes/_authed/admin/tournaments/$id/teams.tsx
@@ -2,7 +2,8 @@ import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
-import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
+import { ListSkeleton } from "@/components/skeletons";
+import { Box, Skeleton, Stack } from "@mantine/core";
import { msg } from "@lingui/core/macro";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
@@ -38,21 +39,7 @@ function ManageTeamsPending() {
-
- {Array.from({ length: 8 }).map((_, index) => (
-
- ))}
-
+
);
}
diff --git a/src/app/routes/_authed/admin/tournaments/index.tsx b/src/app/routes/_authed/admin/tournaments/index.tsx
index 7210102..205cb95 100644
--- a/src/app/routes/_authed/admin/tournaments/index.tsx
+++ b/src/app/routes/_authed/admin/tournaments/index.tsx
@@ -1,7 +1,7 @@
import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
-import { Divider, Group, Skeleton, Stack } from "@mantine/core";
+import { ListSkeleton } from "@/components/skeletons";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { msg } from "@lingui/core/macro";
@@ -22,25 +22,13 @@ export const Route = createFileRoute("/_authed/admin/tournaments/")({
component: RouteComponent,
});
-function TournamentListSkeleton() {
- return (
-
- {Array.from({ length: 6 }).map((_, index) => (
-
- ))}
-
- );
-}
-
function RouteComponent() {
return (
- }>
+
+ }
+ >
);
diff --git a/src/app/routes/_authed/admin/tournaments/run.$id.tsx b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
index ad68e03..216ce86 100644
--- a/src/app/routes/_authed/admin/tournaments/run.$id.tsx
+++ b/src/app/routes/_authed/admin/tournaments/run.$id.tsx
@@ -8,12 +8,10 @@ import SeedTournament from "@/features/tournaments/components/seed-tournament";
import SetupGroupStage from "@/features/tournaments/components/setup-group-stage";
import GroupStageView from "@/features/tournaments/components/group-stage-view";
import { Container, Stack, Divider, Title, Box, Card, Group, Skeleton, SimpleGrid } from "@mantine/core";
-import { useMemo } from "react";
-import { BracketData } from "@/features/bracket/types";
-import { Match } from "@/features/matches/types";
import BracketView from "@/features/bracket/components/bracket-view";
import { SpotifyControlsBar } from "@/features/spotify/components";
-import { useAuth } from "@/contexts/auth-context";
+import { useIsAdmin } from "@/features/core/hooks/use-is-admin";
+import { useTournamentRunState } from "@/features/tournaments/hooks/use-tournament-run-state";
import { Trans } from "@lingui/react/macro";
import { msg } from "@lingui/core/macro";
@@ -83,67 +81,10 @@ function RunTournamentPending() {
function RouteComponent() {
const { id } = Route.useParams();
const { data: tournament } = useTournament(id);
- const { roles } = useAuth();
- const isAdmin = roles?.includes('Admin') || false;
+ const isAdmin = useIsAdmin();
- const hasGroupStage = useMemo(() => {
- return tournament.matches?.some((match) => match.round === -1) || false;
- }, [tournament.matches]);
-
- const hasKnockout = useMemo(() => {
- return tournament.matches?.some((match) => match.round !== -1) || false;
- }, [tournament.matches]);
-
- const knockoutBracketPopulated = useMemo(() => {
- return tournament.matches?.some((match) =>
- match.round === 0 && match.lid >= 0 && (match.home || match.away)
- ) || false;
- }, [tournament.matches]);
-
- const nextUpMatchId = useMemo(() => {
- const ready = (tournament.matches ?? [])
- .filter(
- (match) =>
- match.status === "ready" && match.home && match.away && !match.bye
- )
- .sort((a, b) => a.order - b.order);
- return ready[0]?.id;
- }, [tournament.matches]);
-
- const bracket: BracketData = useMemo(() => {
- if (!tournament.matches || tournament.matches.length === 0) {
- return { winners: [], losers: [] };
- }
-
- const winnersMap = new Map();
- const losersMap = new Map();
-
- tournament.matches
- .filter((match) => match.round !== -1)
- .sort((a, b) => a.lid - b.lid)
- .forEach((match) => {
- if (!match.is_losers_bracket) {
- if (!winnersMap.has(match.round)) {
- winnersMap.set(match.round, []);
- }
- winnersMap.get(match.round)!.push(match);
- } else {
- if (!losersMap.has(match.round)) {
- losersMap.set(match.round, []);
- }
- losersMap.get(match.round)!.push(match);
- }
- });
-
- const winners = Array.from(winnersMap.entries())
- .sort(([a], [b]) => a - b)
- .map(([, matches]) => matches);
-
- const losers = Array.from(losersMap.entries())
- .sort(([a], [b]) => a - b)
- .map(([, matches]) => matches);
- return { winners, losers };
- }, [tournament.matches]);
+ const { hasGroupStage, hasKnockout, knockoutBracketPopulated, nextUpMatchId, bracket } =
+ useTournamentRunState(tournament.matches);
return (
diff --git a/src/app/routes/_authed/tournaments/$tournamentId.tsx b/src/app/routes/_authed/tournaments/$tournamentId.tsx
index 9ed811c..bf42b78 100644
--- a/src/app/routes/_authed/tournaments/$tournamentId.tsx
+++ b/src/app/routes/_authed/tournaments/$tournamentId.tsx
@@ -1,6 +1,7 @@
import { createFileRoute } from '@tanstack/react-router'
import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile';
+import { isAdminRole } from '@/features/core/utils/roles';
import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react';
@@ -20,7 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
header: {
collapsed: true,
withBackButton: true,
- settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
+ settingsLink: isAdminRole(context.auth.roles) ? `/admin/tournaments/${params.tournamentId}` : undefined
},
refresh: [tournamentQueries.details(params.tournamentId).queryKey],
withPadding: false
diff --git a/src/app/routes/api/auth.$.ts b/src/app/routes/api/auth.$.ts
index 3b617e2..0c560c7 100644
--- a/src/app/routes/api/auth.$.ts
+++ b/src/app/routes/api/auth.$.ts
@@ -8,8 +8,6 @@ ensureSuperTokensBackend();
// forwards all supertokens api methods to our API
const superTokensHandler = handleAuthAPIRequest();
const handleRequest = async ({ request }: {request: Request}) => {
- console.log("=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=");
- console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request);
};
export const Route = createFileRoute('/api/auth/$')({
diff --git a/src/app/routes/api/push/subscribe.ts b/src/app/routes/api/push/subscribe.ts
index 51b77b7..bbcc4b4 100644
--- a/src/app/routes/api/push/subscribe.ts
+++ b/src/app/routes/api/push/subscribe.ts
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/subscribe")({
server: {
- middleware: [superTokensRequestMiddleware],
+ middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
diff --git a/src/app/routes/api/push/test.ts b/src/app/routes/api/push/test.ts
index 3f8f587..7cf43c9 100644
--- a/src/app/routes/api/push/test.ts
+++ b/src/app/routes/api/push/test.ts
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { sendPushToPlayer } from "@/lib/push";
import { isPushConfigured } from "@/lib/config";
import { logger } from "@/lib/logger";
@@ -13,7 +14,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/test")({
server: {
- middleware: [superTokensRequestMiddleware],
+ middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: {
POST: async ({ context }) => {
const player = (context as any).player;
diff --git a/src/app/routes/api/push/unsubscribe.ts b/src/app/routes/api/push/unsubscribe.ts
index 8e3c687..92b3faf 100644
--- a/src/app/routes/api/push/unsubscribe.ts
+++ b/src/app/routes/api/push/unsubscribe.ts
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
@@ -11,7 +12,7 @@ const json = (body: unknown, status = 200) =>
export const Route = createFileRoute("/api/push/unsubscribe")({
server: {
- middleware: [superTokensRequestMiddleware],
+ middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const player = (context as any).player;
diff --git a/src/app/routes/api/teams/upload-logo.ts b/src/app/routes/api/teams/upload-logo.ts
index 1ea5723..d2a8f74 100644
--- a/src/app/routes/api/teams/upload-logo.ts
+++ b/src/app/routes/api/teams/upload-logo.ts
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
@@ -12,7 +13,7 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/teams/upload-logo")({
server: {
- middleware: [superTokensRequestMiddleware],
+ middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
diff --git a/src/app/routes/api/telemetry/beacon.ts b/src/app/routes/api/telemetry/beacon.ts
new file mode 100644
index 0000000..38981d8
--- /dev/null
+++ b/src/app/routes/api/telemetry/beacon.ts
@@ -0,0 +1,128 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { superTokensOptionalRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
+import { beaconPayloadSchema, MAX_BEACON_BYTES, type BeaconEvent } from "@/lib/telemetry/beacon-schema";
+import { errorGroupHash } from "@/lib/telemetry/error-hash";
+import { redactArguments } from "@/lib/telemetry/redact-args";
+import { Logger } from "@/lib/logger";
+import type { Player } from "@/features/players/types";
+
+const logger = new Logger("Telemetry");
+
+const BUCKET_CAPACITY = 120;
+const REFILL_PER_MS = BUCKET_CAPACITY / 60_000;
+const buckets = new Map();
+
+const takeTokens = (key: string, count: number): boolean => {
+ const now = Date.now();
+ const bucket = buckets.get(key) ?? { tokens: BUCKET_CAPACITY, updatedAt: now };
+ bucket.tokens = Math.min(BUCKET_CAPACITY, bucket.tokens + (now - bucket.updatedAt) * REFILL_PER_MS);
+ bucket.updatedAt = now;
+ if (bucket.tokens < count) {
+ buckets.set(key, bucket);
+ return false;
+ }
+ bucket.tokens -= count;
+ buckets.set(key, bucket);
+
+ if (buckets.size > 2000) {
+ for (const [k, v] of buckets) {
+ if (now - v.updatedAt > 5 * 60_000) buckets.delete(k);
+ }
+ }
+ return true;
+};
+
+const clientIp = (request: Request): string =>
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+ request.headers.get("x-real-ip") ||
+ "unknown";
+
+const writeEvents = async (
+ events: BeaconEvent[],
+ base: { sessionId: string; playerId?: string; userAgent?: string }
+) => {
+ const { pbAdmin } = await import("@/lib/pocketbase/client");
+ await pbAdmin.authPromise;
+
+ // Sequential on purpose: SQLite is single-writer; a burst of parallel
+ // creates just queues behind the lock.
+ for (const event of events) {
+ try {
+ if (event.kind === "error") {
+ await pbAdmin.createClientError({
+ message: event.message,
+ stack: event.stack,
+ source: event.source,
+ path: event.path,
+ route_id: event.routeId,
+ group_hash: errorGroupHash(event),
+ resolved: false,
+ player: base.playerId,
+ session_id: base.sessionId,
+ user_agent: base.userAgent,
+ props: redactArguments(event.props),
+ });
+ } else {
+ await pbAdmin.createClientEvent({
+ name: event.kind === "vital" ? `vital.${event.metric}` : event.name,
+ player: base.playerId,
+ session_id: base.sessionId,
+ path: event.path,
+ route_id: event.routeId,
+ value: event.value,
+ props:
+ event.kind === "vital"
+ ? event.rating
+ ? { rating: event.rating }
+ : undefined
+ : redactArguments(event.props),
+ user_agent: base.userAgent,
+ });
+ }
+ } catch (error) {
+ logger.error("Failed to persist beacon event", error);
+ }
+ }
+};
+
+export const Route = createFileRoute("/api/telemetry/beacon")({
+ server: {
+ middleware: [apiLoggingMiddleware, superTokensOptionalRequestMiddleware],
+ handlers: {
+ POST: async ({ request, context }) => {
+ const player = (context as unknown as { player?: Player | null }).player ?? undefined;
+
+ const raw = await request.text();
+ if (raw.length > MAX_BEACON_BYTES) {
+ return new Response(null, { status: 413 });
+ }
+
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ return new Response(null, { status: 400 });
+ }
+
+ const result = beaconPayloadSchema.safeParse(parsed);
+ if (!result.success) {
+ return new Response(null, { status: 400 });
+ }
+
+ const key = player ? `p:${player.id}` : `ip:${clientIp(request)}`;
+ if (!takeTokens(key, result.data.events.length)) {
+ return new Response(null, { status: 429 });
+ }
+
+ void writeEvents(result.data.events, {
+ sessionId: result.data.sessionId,
+ playerId: player?.id,
+ userAgent: request.headers.get("user-agent") ?? undefined,
+ }).catch((error) => logger.error("Beacon write batch failed", error));
+
+ return new Response(null, { status: 204 });
+ },
+ },
+ },
+});
diff --git a/src/app/routes/api/tournaments/upload-logo.ts b/src/app/routes/api/tournaments/upload-logo.ts
index 9b7f990..7121c65 100644
--- a/src/app/routes/api/tournaments/upload-logo.ts
+++ b/src/app/routes/api/tournaments/upload-logo.ts
@@ -1,6 +1,7 @@
import { createFileRoute } from "@tanstack/react-router";
import { msg } from "@lingui/core/macro";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
+import { apiLoggingMiddleware } from "@/lib/telemetry/api-logging.server";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
import { localizedFor } from "@/lib/i18n/server-messages";
@@ -12,7 +13,7 @@ const uploadSchema = z.object({
export const Route = createFileRoute("/api/tournaments/upload-logo")({
server: {
- middleware: [superTokensRequestMiddleware],
+ middleware: [apiLoggingMiddleware, superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
const i18n = localizedFor(context.metadata);
diff --git a/src/app/start.ts b/src/app/start.ts
new file mode 100644
index 0000000..7f79732
--- /dev/null
+++ b/src/app/start.ts
@@ -0,0 +1,8 @@
+import { createStart } from "@tanstack/react-start";
+import { serverFnLoggingMiddleware } from "@/utils/activities";
+
+// Global function middleware: every server fn (current and future) is audited
+// without opting in at the definition site.
+export const startInstance = createStart(() => ({
+ functionMiddleware: [serverFnLoggingMiddleware],
+}));
diff --git a/src/components/DefaultCatchBoundary.tsx b/src/components/DefaultCatchBoundary.tsx
index 81f4f8a..19cca0a 100644
--- a/src/components/DefaultCatchBoundary.tsx
+++ b/src/components/DefaultCatchBoundary.tsx
@@ -22,6 +22,7 @@ import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react'
import toast from '@/lib/sonner'
import { logger } from '@/lib/logger'
+import { captureBoundaryError } from '@/lib/telemetry/client'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
import Button from './button'
@@ -41,6 +42,7 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
useEffect(() => {
logger.error('DefaultCatchBoundary | ', error)
+ captureBoundaryError(error)
if (errorMessage.toLowerCase().includes('unauthenticated')) {
toast.error(t`You've been logged out`)
diff --git a/src/components/ios-install-prompt.tsx b/src/components/ios-install-prompt.tsx
index e6f17d5..b24ecf7 100644
--- a/src/components/ios-install-prompt.tsx
+++ b/src/components/ios-install-prompt.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { track } from '@/lib/telemetry/client'
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
import { Trans, useLingui } from '@lingui/react/macro'
@@ -26,13 +27,17 @@ export function IOSInstallPrompt() {
if ((isIOS || isAndroid) && !isInStandaloneMode && !hasBeenDismissed) {
setPlatform(isIOS ? 'ios' : 'android')
- const timer = setTimeout(() => setShow(true), 3000)
+ const timer = setTimeout(() => {
+ setShow(true)
+ track('pwa_ios_prompt_shown')
+ }, 3000)
return () => clearTimeout(timer)
}
}, [])
const handleDismiss = () => {
localStorage.setItem('pwa-install-prompt-dismissed', 'true')
+ track('pwa_ios_prompt_dismissed')
setShow(false)
}
diff --git a/src/components/skeletons/card-skeleton.tsx b/src/components/skeletons/card-skeleton.tsx
new file mode 100644
index 0000000..8b6ee7d
--- /dev/null
+++ b/src/components/skeletons/card-skeleton.tsx
@@ -0,0 +1,35 @@
+import { Box, Group, Paper, Skeleton } from "@mantine/core";
+import type { MantineRadius } from "@mantine/core";
+
+export interface CardSkeletonProps {
+ withAvatar?: boolean;
+ avatarSize?: number;
+ withBadge?: boolean;
+ radius?: MantineRadius;
+ opacity?: number;
+}
+
+export const CardSkeleton = ({
+ withAvatar = true,
+ avatarSize = 32,
+ withBadge = true,
+ radius = "md",
+ opacity = 1,
+}: CardSkeletonProps) => {
+ return (
+
+
+ {withAvatar && (
+
+ )}
+
+
+
+
+ {withBadge && }
+
+
+ );
+};
+
+export default CardSkeleton;
diff --git a/src/components/skeletons/index.ts b/src/components/skeletons/index.ts
new file mode 100644
index 0000000..eaec0ec
--- /dev/null
+++ b/src/components/skeletons/index.ts
@@ -0,0 +1,3 @@
+export * from "./list-skeleton";
+export * from "./card-skeleton";
+export * from "./stat-tile-skeleton";
diff --git a/src/components/skeletons/list-skeleton.tsx b/src/components/skeletons/list-skeleton.tsx
new file mode 100644
index 0000000..8344eda
--- /dev/null
+++ b/src/components/skeletons/list-skeleton.tsx
@@ -0,0 +1,76 @@
+import { Box, Divider, Group, Skeleton, Stack } from "@mantine/core";
+import type { MantineSpacing } from "@mantine/core";
+
+export interface ListSkeletonProps {
+ rows?: number;
+ withSearch?: boolean;
+ withAvatar?: boolean;
+ avatarSize?: number;
+ withSubtitle?: boolean;
+ withTrailing?: boolean | "double" | "text";
+ titleWidth?: string | number;
+ padding?: MantineSpacing;
+ fadeStep?: number;
+ fadeFloor?: number;
+}
+
+export const ListSkeleton = ({
+ rows = 6,
+ withSearch = false,
+ withAvatar = false,
+ avatarSize = 40,
+ withSubtitle = false,
+ withTrailing = false,
+ titleWidth = "45%",
+ padding = "md",
+ fadeStep = 0.1,
+ fadeFloor = 0.35,
+}: ListSkeletonProps) => {
+ return (
+
+ {withSearch && (
+
+
+
+ )}
+ {Array.from({ length: rows }).map((_, index) => (
+
+
+ {withAvatar && (
+
+ )}
+ {withSubtitle ? (
+
+
+
+
+ ) : (
+
+ )}
+ {withTrailing === "double" ? (
+
+
+
+
+ ) : withTrailing === "text" ? (
+
+ ) : withTrailing ? (
+
+ ) : null}
+
+
+
+ ))}
+
+ );
+};
+
+export default ListSkeleton;
diff --git a/src/components/skeletons/stat-tile-skeleton.tsx b/src/components/skeletons/stat-tile-skeleton.tsx
new file mode 100644
index 0000000..23068ff
--- /dev/null
+++ b/src/components/skeletons/stat-tile-skeleton.tsx
@@ -0,0 +1,42 @@
+import { Card, Group, Skeleton, Stack } from "@mantine/core";
+
+export interface StatTileSkeletonProps {
+ withCard?: boolean;
+ align?: "row" | "column";
+ numberWidth?: number;
+ numberHeight?: number;
+ labelWidth?: number;
+ labelHeight?: number;
+}
+
+export const StatTileSkeleton = ({
+ withCard = false,
+ align = "column",
+ numberWidth = align === "row" ? 36 : 48,
+ numberHeight = 28,
+ labelWidth = align === "row" ? 110 : 80,
+ labelHeight = align === "row" ? 14 : 12,
+}: StatTileSkeletonProps) => {
+ const content =
+ align === "row" ? (
+
+
+
+
+ ) : (
+
+
+
+
+ );
+
+ if (!withCard) return content;
+
+ return (
+
+ {content}
+
+ );
+};
+
+export default StatTileSkeleton;
diff --git a/src/contexts/spotify-context.tsx b/src/contexts/spotify-context.tsx
index c2ff000..e013aa3 100644
--- a/src/contexts/spotify-context.tsx
+++ b/src/contexts/spotify-context.tsx
@@ -1,7 +1,7 @@
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { useLingui } from '@lingui/react/macro';
import { SpotifyAuth } from '@/lib/spotify/auth';
-import { useAuth } from './auth-context';
+import { useIsAdmin } from '@/features/core/hooks/use-is-admin';
import { useConfig } from '@/hooks/use-config';
import type {
SpotifyContextType,
@@ -40,8 +40,7 @@ const deepEqual = (a: unknown, b: unknown): boolean => {
};
export const SpotifyProvider: React.FC = ({ children }) => {
- const { roles } = useAuth();
- const isAdmin = roles?.includes('Admin') || false;
+ const isAdmin = useIsAdmin();
const config = useConfig();
const { t } = useLingui();
diff --git a/src/features/admin/components/award-badges.tsx b/src/features/admin/components/award-badges.tsx
index abab1d5..9b60a00 100644
--- a/src/features/admin/components/award-badges.tsx
+++ b/src/features/admin/components/award-badges.tsx
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Box, Card, Text, Select, Button, Group, Stack, Badge, Divider } from "@mantine/core";
import { Trans, useLingui } from "@lingui/react/macro";
-import { awardManualBadge } from "@/features/badges/server";
+import useAwardManualBadge from "@/features/badges/hooks/use-award-manual-badge";
import { useAllBadges } from "@/features/badges/queries";
import toast from "@/lib/sonner";
import { usePlayers } from "@/features/players/queries";
@@ -13,35 +13,29 @@ const AwardBadges = () => {
const [selectedPlayerId, setSelectedPlayerId] = useState(null);
const [selectedBadgeId, setSelectedBadgeId] = useState(null);
- const [isAwarding, setIsAwarding] = useState(false);
+
+ const awardBadge = useAwardManualBadge();
const manualBadges = allBadges.filter((badge) => badge.type === "manual");
- const handleAwardBadge = async () => {
+ const handleAwardBadge = () => {
if (!selectedPlayerId || !selectedBadgeId) return;
- setIsAwarding(true);
- try {
- await awardManualBadge({
- data: {
- playerId: selectedPlayerId,
- badgeId: selectedBadgeId,
+ awardBadge.mutate(
+ { playerId: selectedPlayerId, badgeId: selectedBadgeId },
+ {
+ onSuccess: () => {
+ const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
+ const playerName = selectedPlayer
+ ? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
+ : t`Player`;
+
+ toast.success(t`Badge awarded to ${playerName}`);
+
+ setSelectedPlayerId(null);
},
- });
-
- const selectedPlayer = players.find((p) => p.id === selectedPlayerId);
- const playerName = selectedPlayer
- ? `${selectedPlayer.first_name} ${selectedPlayer.last_name}`
- : t`Player`;
-
- toast.success(t`Badge awarded to ${playerName}`);
-
- setSelectedPlayerId(null);
- } catch (error) {
- toast.error(t`Failed to award badge`);
- } finally {
- setIsAwarding(false);
- }
+ }
+ );
};
const playerOptions = players.map((player) => ({
@@ -98,7 +92,7 @@ const AwardBadges = () => {